diff --git a/frontend-vue/src/shared/api-field-schemas.ts b/frontend-vue/src/shared/api-field-schemas.ts new file mode 100644 index 00000000..c5436987 --- /dev/null +++ b/frontend-vue/src/shared/api-field-schemas.ts @@ -0,0 +1,70 @@ +/** + * API 高频响应 schema 注册表(Task 27)。 + * + * 为 task/result 核心响应与各业务模块注册字段 schema,供 + * createApiFieldCompatChecker 在契约测试与联调前做字段漂移检查。 + * 模块级 schema 目前复用 task+result 组合,后续某模块有独特字段时 + * 在其定义内覆盖/追加即可,无需改动检查器。 + */ +import { createApiFieldCompatChecker, type ApiFieldSpec } from './api-field-compat.ts' + +export interface ApiFieldSchemaDef { + /** schema 版本号,变更时递增 */ + version: number + /** 字段名 → 规格 */ + schema: Record +} + +/** task 核心响应字段 */ +export const taskCoreSchema: Record = { + taskId: { type: 'number', required: true }, + status: { type: 'string', required: true }, + progress: { type: 'number' }, + message: { type: 'string' }, + resultId: { type: 'number' }, +} + +/** 结果文件阶段字段(与 ResultFilePhase 对应,全部可选) */ +export const resultFileSchema: Record = { + fileStatus: { type: 'string' }, + fileReady: { type: 'boolean' }, + fileProgress: { type: 'number' }, + fileProgressLabel: { type: 'string' }, + fileProgressStage: { type: 'string' }, + downloadUrl: { type: 'string' }, + freshDownloadUrl: { type: 'string' }, +} + +/** task + result 组合 schema */ +export const taskWithResultSchema: Record = { + ...taskCoreSchema, + ...resultFileSchema, +} + +const BUSINESS_MODULES = [ + 'dedupe', 'split', 'convert', 'publish', 'similar-asin', 'appearance-patent', + 'delete-brand', 'price-track', 'shop-match', 'query-asin', 'withdraw', + 'collect-data', 'image-video', +] as const + +export const apiFieldSchemas: Record = { + task: { version: 1, schema: taskCoreSchema }, + result: { version: 1, schema: resultFileSchema }, + 'task-result': { version: 1, schema: taskWithResultSchema }, +} + +for (const module of BUSINESS_MODULES) { + apiFieldSchemas[module] = { version: 1, schema: taskWithResultSchema } +} + +export function getApiFieldSchema(name: string): ApiFieldSchemaDef | null { + return apiFieldSchemas[name] ?? null +} + +export function createModuleFieldChecker(module: string) { + const def = getApiFieldSchema(module) + if (!def) { + throw new Error(`未注册的模块: ${module}`) + } + return createApiFieldCompatChecker({ schema: def.schema }) +} diff --git a/frontend-vue/tests/api-field-schemas.test.ts b/frontend-vue/tests/api-field-schemas.test.ts new file mode 100644 index 00000000..7f84dab6 --- /dev/null +++ b/frontend-vue/tests/api-field-schemas.test.ts @@ -0,0 +1,104 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + taskCoreSchema, + resultFileSchema, + taskWithResultSchema, + apiFieldSchemas, + getApiFieldSchema, + createModuleFieldChecker, +} from '../src/shared/api-field-schemas.ts' + +test('test_schema_task_required_fields', () => { + assert.equal(taskCoreSchema.taskId.type, 'number') + assert.equal(taskCoreSchema.taskId.required, true) + assert.equal(taskCoreSchema.status.type, 'string') + assert.equal(taskCoreSchema.status.required, true) + assert.equal(taskCoreSchema.progress.type, 'number') + assert.equal(taskCoreSchema.progress.required, undefined) +}) + +test('test_schema_result_required_fields', () => { + assert.equal(resultFileSchema.fileStatus.type, 'string') + assert.equal(resultFileSchema.fileReady.type, 'boolean') + assert.equal(resultFileSchema.fileProgress.type, 'number') + assert.equal(resultFileSchema.downloadUrl.type, 'string') + assert.equal(resultFileSchema.freshDownloadUrl.type, 'string') + assert.ok('fileProgressLabel' in resultFileSchema) + assert.ok('fileProgressStage' in resultFileSchema) +}) + +test('test_checker_passes_valid', () => { + const checker = createModuleFieldChecker('task') + const violations = checker.check({ taskId: 1, status: 'SUCCESS', progress: 100 }) + assert.deepEqual(violations, []) + assert.equal(checker.lastResult().passed, true) +}) + +test('test_checker_missing_required', () => { + const checker = createModuleFieldChecker('task') + const violations = checker.check({ taskId: 1 }) + assert.equal(violations.length, 1) + assert.equal(violations[0].field, 'status') + assert.equal(violations[0].kind, 'missing') +}) + +test('test_checker_wrong_type', () => { + const checker = createModuleFieldChecker('task') + const violations = checker.check({ taskId: 'x', status: 'SUCCESS' }) + assert.equal(violations.length, 1) + assert.equal(violations[0].field, 'taskId') + assert.equal(violations[0].kind, 'type') + assert.equal(violations[0].expected, 'number') + assert.equal(violations[0].actual, 'string') +}) + +test('test_checker_batch', () => { + const checker = createModuleFieldChecker('task-result') + checker.checkBatch([ + { taskId: 1, status: 'SUCCESS', fileStatus: 'SUCCESS', fileReady: true }, + { taskId: 2, status: 'RUNNING', downloadUrl: '/files/2.zip' }, + { taskId: 'bad', status: 3 }, + ]) + const stats = checker.stats() + assert.equal(stats.checkedCount, 3) + assert.equal(stats.violationCount, 2) + assert.equal(checker.lastResult().passed, false) +}) + +test('test_schema_registered_modules', () => { + const modules = [ + 'task', 'result', 'task-result', + 'dedupe', 'split', 'convert', 'publish', 'similar-asin', 'appearance-patent', + 'delete-brand', 'price-track', 'shop-match', 'query-asin', 'withdraw', + 'collect-data', 'image-video', + ] + for (const name of modules) { + const def = getApiFieldSchema(name) + assert.ok(def, `${name} 应注册 schema`) + assert.ok(Object.keys(def.schema).length > 0, `${name} schema 非空`) + } + assert.equal(getApiFieldSchema('not-a-module'), null) +}) + +test('test_schema_versioned', () => { + const taskDef = apiFieldSchemas.task + assert.equal(typeof taskDef.version, 'number') + assert.ok(taskDef.version >= 1) + const resultDef = apiFieldSchemas.result + assert.ok(resultDef.version >= 1) + // 版本号唯一性:不同注册项允许同版本,但同模块只能有一个定义 + const versions = Object.values(apiFieldSchemas).map((d) => d.version) + assert.ok(versions.every((v) => Number.isInteger(v) && v >= 1)) +}) + +test('test_module_checker_uses_task_schema', () => { + const checker = createModuleFieldChecker('dedupe') + const violations = checker.check({ taskId: 1, status: 'SUCCESS', fileReady: true }) + assert.deepEqual(violations, [], '模块 schema 应含 task + result 字段') + assert.deepEqual(checker.checkedFields(), Object.keys(taskWithResultSchema)) +}) + +test('test_checker_unknown_module_throws', () => { + assert.throws(() => createModuleFieldChecker('ghost-module'), /未注册的模块/) +})