import type { ModuleRequestUser } from './platform/module-user.js' export type AuditJsonValue = | null | boolean | number | string | AuditJsonValue[] | { [key: string]: AuditJsonValue } export interface BusinessAuditEvent { action?: string summary: string target: { type: string; id: string | number; name?: string } changes?: Record metadata?: Record } export interface BusinessAuditRecord extends BusinessAuditEvent { action?: string actorId: number requestId: string sourceIp?: string userAgent?: string } export interface BusinessAuditSink { write(record: BusinessAuditRecord): Promise<{ id: string }> } export interface RequestAudit { record(event: BusinessAuditEvent): Promise<{ id: string }> } export class AuditValidationError extends Error { readonly code = 'AUDIT_VALIDATION_ERROR' constructor(message: string) { super(message) this.name = 'AuditValidationError' } } export class AuditIdentityError extends Error { readonly code = 'AUDIT_IDENTITY_ERROR' constructor() { super('业务审计要求已认证用户') this.name = 'AuditIdentityError' } } const ACTION_PATTERN = /^[a-z][a-z0-9_.-]{1,119}$/ const SENSITIVE_KEYS = /^(password|passwd|token|access_token|refresh_token|authorization|secret|api_key|private_key)$/i function clean(value: AuditJsonValue, seen: WeakSet): AuditJsonValue { if (typeof value === 'number' && !Number.isFinite(value)) { throw new AuditValidationError('审计字段不能包含非有限数字') } if (value === null || typeof value !== 'object') return value if (seen.has(value)) throw new AuditValidationError('审计字段不能包含循环引用') seen.add(value) if (Array.isArray(value)) return value.map((item) => clean(item, seen)) const result: Record = {} for (const [key, item] of Object.entries(value)) { result[key] = SENSITIVE_KEYS.test(key) ? '[REDACTED]' : clean(item, seen) } return result } export function normalizeBusinessAuditEvent(event: BusinessAuditEvent): BusinessAuditEvent { if (!event || typeof event !== 'object') throw new AuditValidationError('审计事件不能为空') const action = event.action?.trim() || undefined if (action && !ACTION_PATTERN.test(action)) throw new AuditValidationError('action 格式不合法') const summary = event.summary?.trim() const targetType = event.target?.type?.trim() const targetId = event.target?.id == null ? '' : String(event.target.id).trim() if (!summary || summary.length > 500) throw new AuditValidationError('summary 必填且不能超过 500 字符') if (!targetType || targetType.length > 100) throw new AuditValidationError('target.type 必填且不能超过 100 字符') if (!targetId || targetId.length > 200) throw new AuditValidationError('target.id 必填且不能超过 200 字符') const normalized: BusinessAuditEvent = { ...(action ? { action } : {}), summary, target: { type: targetType, id: targetId, ...(event.target.name?.trim() ? { name: event.target.name.trim() } : {}) }, ...(event.changes ? { changes: clean(event.changes as unknown as AuditJsonValue, new WeakSet()) as BusinessAuditEvent['changes'] } : {}), ...(event.metadata ? { metadata: clean(event.metadata, new WeakSet()) as Record } : {}), } const payloadSize = new TextEncoder().encode(JSON.stringify([normalized.changes, normalized.metadata])).byteLength if (payloadSize > 32 * 1024) throw new AuditValidationError('changes 与 metadata 合计不能超过 32 KiB') return normalized } export function createRequestAudit( sink: BusinessAuditSink, user: ModuleRequestUser | null, request: Request, ): RequestAudit { return { async record(event) { if (!user) throw new AuditIdentityError() const normalized = normalizeBusinessAuditEvent(event) return sink.write({ ...normalized, actorId: user.id, requestId: request.headers.get('x-request-id') || crypto.randomUUID(), sourceIp: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || undefined, userAgent: request.headers.get('user-agent') || undefined, }) }, } }