/** * `registerErrorHandler` —— 全局错误成形:Valibot 校验失败 → `400` + 结构化 issues;业务 * 错误按 `err.statusCode` 或 `err.code`→HTTP 状态表映射;其余 → `500`。 * * 错误码→HTTP 状态是唯一一张表(`CODE_STATUS`),两个 server 合并去重后共用。业务错误 * (`status < 500`)若携带 `err.details` 且为对象则透传(vg 版行为,保留)。 */ import { ValiError, parse as vparse } from 'valibot' import { ApiError } from '@drama/contracts/platform' /** * 错误码 → HTTP 状态单表(drama + visual-game 两 server 合并去重)。 * 无显式 `err.statusCode` 时按 `err.code` 推断,避免业务拒绝退化成 500。 * @type {Record} */ export const CODE_STATUS = { // 认证 / 授权 INVALID_CREDENTIALS: 401, USER_NOT_FOUND: 401, UNAUTHORIZED: 401, TOKEN_EXPIRED: 401, TOKEN_INVALID: 401, ERR_JWT_EXPIRED: 401, REFRESH_REUSED: 401, // 冲突 USER_EXISTS: 409, EMAIL_TAKEN: 409, CONFLICT: 409, SAVE_SLOT_CONFLICT: 409, // 限流 RATE_LIMITED: 429, TOO_MANY_ATTEMPTS: 429, // 通用 FORBIDDEN: 403, NOT_FOUND: 404, BAD_REQUEST: 400, CONTENT_FILTER: 400, AI_OUTPUT_INVALID: 502, // 校验 / 验证码 EMAIL_VERIFICATION_REQUIRED: 400, CODE_INVALID: 400, CODE_EXPIRED: 400, // 计费 / 支付 INSUFFICIENT_BALANCE: 402, PRODUCT_NOT_FOUND: 404, RULE_NOT_FOUND: 404, ORDER_NOT_FOUND: 404, IAP_NOT_CONFIGURED: 400, IAP_VERIFY_FAILED: 400, STRIPE_NOT_CONFIGURED: 400, STRIPE_ERROR: 502, WEBHOOK_SIGNATURE_INVALID: 400, // waiting list 预期业务失败 'waitlist.invalid_input': 400, 'waitlist.invalid_email': 400, 'waitlist.invalid_source': 400, 'waitlist.invalid_status': 400, 'waitlist.invalid_limit': 400, 'waitlist.invalid_cursor': 400, 'waitlist.consent_required': 400, 'waitlist.invite_expired': 400, 'waitlist.invite_email_mismatch': 400, 'waitlist.invite_not_usable': 400, 'waitlist.account_already_registered': 409, 'waitlist.duplicate_email': 409, 'waitlist.invalid_status_transition': 409, 'waitlist.entry_not_found': 404, 'waitlist.delivery_not_found': 404, 'waitlist.invite_not_found': 404, 'waitlist.invite_delivery_failed': 502, } /** * @param {import('fastify').FastifyInstance} app */ export function registerErrorHandler(app) { app.setErrorHandler((/** @type {any} */ err, request, reply) => { if (err instanceof ValiError) { reply.code(400) return reply.send( vparse(ApiError, { error: { code: 'validation_error', message: 'Request validation failed', retryable: false, details: { issues: err.issues.map((i) => ({ path: pathOf(i), message: i.message })) }, }, }), ) } const status = typeof err.statusCode === 'number' ? err.statusCode : (err.code && CODE_STATUS[err.code]) || 500 const requestId = request?.id const traceId = request?.ctx?.traceId const details = status < 500 && err.details && typeof err.details === 'object' ? err.details : {} const traceDetails = { ...details, ...(traceId ? { traceId } : {}), ...(requestId ? { requestId } : {}), } if (request && request.log_ && typeof request.log_.error === 'function') { request.log_.error('request failed', { err, status, traceId, requestId }) } reply.code(status) return reply.send( vparse(ApiError, { error: { code: err.code && typeof err.code === 'string' ? err.code : 'internal_error', message: status >= 500 ? 'Internal server error' : String(err.message || 'Error'), retryable: status >= 500, ...(Object.keys(traceDetails).length > 0 ? { details: traceDetails } : {}), }, }), ) }) } /** * @param {any} issue * @returns {string} */ function pathOf(issue) { if (!issue.path) return '' return issue.path.map((/** @type {any} */ p) => p.key).join('.') }