119 lines
4.6 KiB
TypeScript
119 lines
4.6 KiB
TypeScript
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<string, { path: string; message: string }[]>()
|
||
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<string>()
|
||
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}`)
|
||
}
|