diff --git a/frontend-vue/src/shared/api-field-compat.ts b/frontend-vue/src/shared/api-field-compat.ts new file mode 100644 index 00000000..05549e93 --- /dev/null +++ b/frontend-vue/src/shared/api-field-compat.ts @@ -0,0 +1,179 @@ +/** + * API 字段兼容检查器(Task 96)。 + * + * 为 Java/Python/Vue 三端统一响应字段契约:声明 schema(字段名、类型、 + * 是否必填),对每个响应对象做兼容检查,产出缺失/类型错误清单。 + * 各端接入同一 schema 描述即可在联调前发现字段漂移。 + * + * 语义: + * - 必填字段缺失或类型不符产生 violation(kind: missing | type), + * 其余字段安全跳过; + * - checkBatch 遍历数组检查;空数组返回 passed,不创建无效检查记录; + * - maxChecked 限制累计检查对象数(超出跳过计数),内存有界; + * - 同一输入重复检查幂等,输入对象永不修改; + * - schema 非法(非对象、未知类型)、maxChecked 非正数、检查对象非普通 + * 对象均 fail-fast 抛错;schema 读取抛错时调用失败且零状态变更, + * 依赖恢复后同一检查器继续可用。 + */ +export type ApiFieldType = 'number' | 'string' | 'boolean' | 'object' | 'array' + +export interface ApiFieldSpec { + type: ApiFieldType + required?: boolean +} + +export interface ApiFieldViolation { + field: string + kind: 'missing' | 'type' + expected: string + actual?: string +} + +export interface ApiFieldCompatOptions { + /** 字段 schema:字段名 → 规格;空对象表示纯遍历(不校验) */ + schema: Record + /** 累计检查对象数上限,必须为正数,默认 1000 */ + maxChecked?: number +} + +export interface ApiFieldCompatResult { + violations: ApiFieldViolation[] + passed: boolean + checkedCount: number + violationCount: number +} + +export interface ApiFieldCompatStats { + checkedCount: number + violationCount: number + skippedCount: number + /** 按检查顺序排列的对象序号(从 1 开始) */ + checkedIds: number[] +} + +export interface ApiFieldCompatChecker { + /** 检查单个响应对象,返回违规清单(空数组表示通过) */ + check: (obj: unknown) => ApiFieldViolation[] + /** 检查响应数组,逐条走 check */ + checkBatch: (objs: unknown[]) => void + /** 最近一次 check/checkBatch 结果 */ + lastResult: () => ApiFieldCompatResult + /** 全部已检查过的字段名(按 schema 声明顺序) */ + checkedFields: () => string[] + stats: () => ApiFieldCompatStats +} + +const SUPPORTED_TYPES: ApiFieldType[] = ['number', 'string', 'boolean', 'object', 'array'] + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === 'object' && + value != null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ) +} + +export function createApiFieldCompatChecker(options: ApiFieldCompatOptions): ApiFieldCompatChecker { + if (!isPlainObject(options.schema)) { + throw new Error('schema 必须是对象') + } + const maxChecked = options.maxChecked ?? 1000 + if (!(maxChecked > 0)) { + throw new Error('maxChecked 必须为正数: ' + maxChecked) + } + + const fieldOrder: string[] = [] + for (const field of Object.keys(options.schema)) { + const spec = options.schema[field] + if (!SUPPORTED_TYPES.includes(spec.type)) { + throw new Error('不支持的字段类型: ' + spec.type) + } + fieldOrder.push(field) + } + + let checkedCount = 0 + let violationCount = 0 + let skippedCount = 0 + const checkedIds: number[] = [] + let lastViolations: ApiFieldViolation[] = [] + + function typeOf(value: unknown): ApiFieldType { + if (value == null) return 'object' + if (Array.isArray(value)) return 'array' + if (typeof value === 'number') return 'number' + if (typeof value === 'string') return 'string' + if (typeof value === 'boolean') return 'boolean' + if (typeof value === 'object') return 'object' + return 'object' + } + + function checkOne(obj: Record): ApiFieldViolation[] { + const violations: ApiFieldViolation[] = [] + for (const field of fieldOrder) { + const spec = options.schema[field] + const value = obj[field] + if (value === undefined) { + if (spec.required) { + violations.push({ field, kind: 'missing', expected: spec.type }) + } + continue + } + const actual = typeOf(value) + if (actual !== spec.type) { + violations.push({ field, kind: 'type', expected: spec.type, actual }) + } + } + return violations + } + + function record(violations: ApiFieldViolation[]) { + checkedCount += 1 + checkedIds.push(checkedCount) + if (violations.length > 0) { + violationCount += violations.length + } + lastViolations = violations + } + + function check(obj: unknown): ApiFieldViolation[] { + if (!isPlainObject(obj)) { + throw new Error('对象必须是普通对象') + } + if (checkedCount >= maxChecked) { + skippedCount += 1 + return [] + } + const violations = checkOne(obj) + record(violations) + return violations + } + + function checkBatch(objs: unknown[]) { + if (!Array.isArray(objs)) { + throw new Error('对象数组必须是数组') + } + for (const obj of objs) { + check(obj) + } + } + + function lastResult(): ApiFieldCompatResult { + return { + violations: lastViolations, + passed: lastViolations.length === 0, + checkedCount, + violationCount, + } + } + + function checkedFields(): string[] { + return [...fieldOrder] + } + + function stats(): ApiFieldCompatStats { + return { checkedCount, violationCount, skippedCount, checkedIds: [...checkedIds] } + } + + return { check, checkBatch, lastResult, checkedFields, stats } +} diff --git a/frontend-vue/tests/api-field-compat.test.ts b/frontend-vue/tests/api-field-compat.test.ts new file mode 100644 index 00000000..3b00ca09 --- /dev/null +++ b/frontend-vue/tests/api-field-compat.test.ts @@ -0,0 +1,124 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createApiFieldCompatChecker } from '../src/shared/api-field-compat.ts' + +const batchSchema = { + taskId: { type: 'number', required: true }, + status: { type: 'string', required: true }, + progress: { type: 'number' }, + message: { type: 'string' }, + items: { type: 'array' }, +} + +test('test_task_096_api_compat_normal_default_path', () => { + const checker = createApiFieldCompatChecker({ schema: batchSchema }) + const violations = checker.check({ taskId: 1, status: 'SUCCESS', progress: 50 }) + assert.deepEqual(violations, [], '全部必填字段存在且类型正确') + assert.equal(checker.lastResult().violations.length, 0) + assert.equal(checker.lastResult().checkedCount, 1) + assert.equal(checker.lastResult().passed, true) + assert.deepEqual(checker.checkedFields(), ['taskId', 'status', 'progress', 'message', 'items']) +}) + +test('test_task_096_api_compat_normal_multiple_items', () => { + const checker = createApiFieldCompatChecker({ schema: batchSchema }) + const rows = [ + { taskId: 1, status: 'RUNNING' }, + { taskId: 2, status: 'SUCCESS', progress: 100 }, + { taskId: 3, status: 'FAILED', message: 'err' }, + ] + for (const row of rows) { + assert.deepEqual(checker.check(row), []) + } + assert.equal(checker.stats().checkedCount, 3) + assert.equal(checker.stats().violationCount, 0) + // 顺序稳定:检查记录按输入顺序 + assert.deepEqual(checker.stats().checkedIds, [1, 2, 3]) +}) + +test('test_task_096_api_compat_normal_repeated_operation_is_idempotent', () => { + const checker = createApiFieldCompatChecker({ schema: batchSchema }) + const row = { taskId: 1, status: 'SUCCESS' } + assert.deepEqual(checker.check(row), []) + assert.deepEqual(checker.check(row), [], '同一输入重复检查结果一致') + assert.equal(checker.stats().checkedCount, 2) + assert.equal(checker.stats().violationCount, 0) + // 输入对象不被修改 + assert.equal('items' in row, false) + // 无 schema 的纯遍历模式 + const free = createApiFieldCompatChecker({ schema: {} }) + assert.deepEqual(free.check({ anything: 1, other: 'x' }), []) +}) + +test('test_task_096_api_compat_boundary_empty_input', () => { + const checker = createApiFieldCompatChecker({ schema: batchSchema }) + // 空对象:必填字段缺失 + const violations = checker.check({}) + assert.equal(violations.length, 2) + assert.ok(violations.some((v) => v.field === 'taskId' && v.kind === 'missing')) + assert.ok(violations.some((v) => v.field === 'status' && v.kind === 'missing')) + // 空数组响应:无字段可查,跳过 + const checker2 = createApiFieldCompatChecker({ schema: batchSchema }) + checker2.checkBatch([]) + assert.equal(checker2.stats().checkedCount, 0) + assert.equal(checker2.stats().violationCount, 0) + assert.equal(checker2.lastResult().passed, true) +}) + +test('test_task_096_api_compat_boundary_single_item', () => { + const checker = createApiFieldCompatChecker({ schema: batchSchema }) + const violations = checker.check({ taskId: 7, status: 'SUCCESS' }) + assert.deepEqual(violations, [], '单条最小合法记录通过') + assert.equal(checker.checkedFields().includes('progress'), true, '可选字段也纳入检查清单') + assert.equal(checker.lastResult().checkedCount, 1) +}) + +test('test_task_096_api_compat_boundary_limit_and_overflow', () => { + const checker = createApiFieldCompatChecker({ schema: batchSchema, maxChecked: 2 }) + checker.check({ taskId: 1, status: 'a' }) + checker.check({ taskId: 2, status: 'b' }) + checker.check({ taskId: 3, status: 'c' }) + assert.equal(checker.stats().checkedCount, 2, '超过 maxChecked 后不再检查') + assert.equal(checker.stats().skippedCount, 1) + assert.deepEqual(checker.stats().checkedIds, [1, 2]) + // 类型错误累计 + const checker2 = createApiFieldCompatChecker({ schema: batchSchema }) + checker2.check({ taskId: 'x' as never, status: 5 as never }) + assert.equal(checker2.stats().violationCount, 2) +}) + +test('test_task_096_api_compat_invalid_input_rejected', () => { + assert.throws(() => createApiFieldCompatChecker({} as never), /schema 必须是对象/) + assert.throws(() => createApiFieldCompatChecker({ schema: null as never }), /schema 必须是对象/) + assert.throws(() => createApiFieldCompatChecker({ schema: [] as never }), /schema 必须是对象/) + assert.throws( + () => createApiFieldCompatChecker({ schema: { a: { type: 'unknown' } } }), + /不支持的字段类型: unknown/, + ) + assert.throws( + () => createApiFieldCompatChecker({ schema: batchSchema, maxChecked: 0 }), + /maxChecked 必须为正数/, + ) + const checker = createApiFieldCompatChecker({ schema: batchSchema }) + assert.throws(() => checker.check(null as never), /对象必须是普通对象/) + assert.throws(() => checker.check([1, 2] as never), /对象必须是普通对象/) + assert.throws(() => checker.checkBatch('x' as never), /对象数组必须是数组/) +}) + +test('test_task_096_api_compat_dependency_failure_releases_resources', () => { + // 检查对象读取失败:check 抛错但状态不被污染,恢复后可用 + let broken = false + const checker = createApiFieldCompatChecker({ schema: batchSchema }) + const poisoned = new Proxy({ taskId: 1, status: 'SUCCESS' }, { + get(target, prop, receiver) { + if (broken && prop === 'taskId') throw new Error('row getter down') + return Reflect.get(target, prop, receiver) + }, + }) + broken = true + assert.throws(() => checker.check(poisoned), /row getter down/) + assert.equal(checker.stats().checkedCount, 0, '失败不产生检查记录') + broken = false + assert.deepEqual(checker.check(poisoned), [], '依赖恢复后同一检查器继续可用') + assert.equal(checker.stats().checkedCount, 1) +})