180 lines
5.4 KiB
TypeScript
180 lines
5.4 KiB
TypeScript
/**
|
||
* 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 }
|
||
}
|