Files
crawler-plugin/frontend-vue/src/shared/api-field-compat.ts
T

180 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 }
}