task-96: Java/Python/Vue 三端统一 API 字段兼容检查器

This commit is contained in:
2026-08-30 23:12:27 +08:00
parent cc41b5d81b
commit 1b491d972a
2 changed files with 303 additions and 0 deletions
+179
View File
@@ -0,0 +1,179 @@
/**
* API 字段兼容检查器(Task 96)。
*
* 为 Java/Python/Vue 三端统一响应字段契约:声明 schema(字段名、类型、
* 是否必填),对每个响应对象做兼容检查,产出缺失/类型错误清单。
* 各端接入同一 schema 描述即可在联调前发现字段漂移。
*
* 语义:
* - 必填字段缺失或类型不符产生 violationkind: 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<string, ApiFieldSpec>
/** 累计检查对象数上限,必须为正数,默认 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<string, unknown> {
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<string, unknown>): 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 }
}
+124
View File
@@ -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)
})