errors.js 3.95 KB
/**
 * `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<string, number>}
 */
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('.')
}