gwy-exam/server/src/errors.ts

119 lines
4.6 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.

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}`)
}