task-27: api-field-compat 高频响应 schema
新增 src/shared/api-field-schemas.ts:task 核心字段、result 文件阶段 字段、task-result 组合 schema 注册表(带版本号);12 个业务模块 注册同一 task-result schema;getApiFieldSchema()/createModuleFieldChecker() 供契约测试与联调前跑字段漂移检查。 10 个测试:schema 必填/结果字段声明、checker 合法/缺必填/类型不符/ 批量、模块注册齐全、版本标记、未知模块抛错。
This commit is contained in:
@@ -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<string, ApiFieldSpec>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** task 核心响应字段 */
|
||||||
|
export const taskCoreSchema: Record<string, ApiFieldSpec> = {
|
||||||
|
taskId: { type: 'number', required: true },
|
||||||
|
status: { type: 'string', required: true },
|
||||||
|
progress: { type: 'number' },
|
||||||
|
message: { type: 'string' },
|
||||||
|
resultId: { type: 'number' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 结果文件阶段字段(与 ResultFilePhase 对应,全部可选) */
|
||||||
|
export const resultFileSchema: Record<string, ApiFieldSpec> = {
|
||||||
|
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<string, ApiFieldSpec> = {
|
||||||
|
...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<string, ApiFieldSchemaDef> = {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
@@ -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'), /未注册的模块/)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user