import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify' export type ErrorCode = | 'VALIDATION_ERROR' | 'NOT_FOUND' | 'CONFLICT' | 'AI_NOT_CONFIGURED' | 'INTERNAL_ERROR' /** 业务错误:handlers / services 抛出,统一转换为约定的错误响应格式 */ export class ApiError extends Error { constructor( readonly statusCode: number, readonly code: ErrorCode, message: string, readonly details: { path: string; message: string }[] = [] ) { super(message) this.name = 'ApiError' } static notFound(message: string, details?: { path: string; message: string }[]) { return new ApiError(404, 'NOT_FOUND', message, details) } static conflict(message: string, details?: { path: string; message: string }[]) { return new ApiError(409, 'CONFLICT', message, details) } static aiNotConfigured(message: string) { return new ApiError(503, 'AI_NOT_CONFIGURED', message) } } function sendError(reply: FastifyReply, statusCode: number, code: ErrorCode, message: string, details: { path: string; message: string }[] = []) { return reply.status(statusCode).send({ error: { code, message, details } }) } /** 将 AJV 校验信息翻译为可读中文 */ function translateAjvMessage(message: string): string { if (/must have required property/.test(message)) { const prop = message.match(/'([^']+)'/)?.[1] ?? '' return `缺少必填字段 ${prop}` } if (message === 'must be equal to one of the allowed values') return '取值必须是允许的枚举值之一' if (message === 'must be string') return '类型应为字符串' if (message === 'must be number') return '类型应为数字' if (message === 'must be integer') return '应为整数' if (message === 'must be boolean') return '应为布尔值' if (message === 'must be array') return '应为数组' if (message === 'must be object') return '应为对象' if (message === 'must match a schema in anyOf') return '取值不符合预设选项' return message } /** * 聚合同一路径下的多条校验信息: * Zod union(如 duration: 5|10|15)经 AJV anyOf 校验失败时会产生 * 多条 "must be equal to constant" + 一条 "must match a schema in anyOf", * 按路径合并为一条可读信息。 */ function consolidateDetails(items: { path: string; message: string }[]): { path: string; message: string }[] { const byPath = new Map() for (const item of items) { const list = byPath.get(item.path) ?? [] list.push(item) byPath.set(item.path, list) } const result: { path: string; message: string }[] = [] for (const [path, list] of byPath) { const constFailures = list.filter((i) => i.message === 'must be equal to constant') const anyOfFailure = list.some((i) => i.message === 'must match a schema in anyOf') if (anyOfFailure) { result.push({ path, message: '取值不符合预设选项' }) continue } if (constFailures.length > 0 && list.length === constFailures.length) { result.push({ path, message: '取值不符合预设选项' }) continue } const seen = new Set() for (const item of list) { const msg = translateAjvMessage(item.message) if (!seen.has(msg)) { seen.add(msg) result.push({ path, message: msg }) } } } return result } export function errorHandler(error: FastifyError | ApiError | Error, request: FastifyRequest, reply: FastifyReply) { if (error instanceof ApiError) { return sendError(reply, error.statusCode, error.code, error.message, error.details) } const fastifyError = error as FastifyError if (fastifyError.validation || fastifyError.statusCode === 400) { if (!fastifyError.validation) { // 非 AJV 的 400(如 JSON body 解析失败),直接透出可理解的原因 return sendError(reply, 400, 'VALIDATION_ERROR', fastifyError.message || '请求参数不合法', []) } const details = consolidateDetails( fastifyError.validation.map((item) => ({ path: item.instancePath.replace(/^\//, '') || (item.params as { missingProperty?: string })?.missingProperty || '', message: item.message ?? '参数不合法' })) ) return sendError(reply, 400, 'VALIDATION_ERROR', '请求参数不合法', details) } request.log.error({ err: error }, '未处理异常') return sendError(reply, 500, 'INTERNAL_ERROR', '服务器内部错误') } export function notFoundHandler(request: FastifyRequest, reply: FastifyReply) { return sendError(reply, 404, 'NOT_FOUND', `接口不存在:${request.method} ${request.url}`) }