task-96: Java/Python/Vue 三端统一 API 字段兼容检查器
This commit is contained in:
@@ -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<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 }
|
||||
}
|
||||
Reference in New Issue
Block a user