// Only in One - 统一错误码表 + 统一响应封装 + 全局错误中间件(R-P1-04 / R-P1-05)。 // // 三条硬约束(架构 §4.2): // 1. 401 类响应不返回 requestId,且 INVALID_CREDENTIALS 的响应体必须**逐字节稳定** // (不携带用户名是否存在等差异信息)。 // 2. 5xx 响应体绝不包含堆栈。 // 3. message / hint 只允许由本文件的映射表单向生成,业务代码禁止手写文案。 import { randomUUID } from 'node:crypto'; /** * 错误码总表。`status` 为 HTTP 状态码,`message` 为面向用户的中文文案, * `hint` 为可选的处置建议。 */ export const ERROR_TABLE = Object.freeze({ VALIDATION_FAILED: { status: 400, message: '请求参数不正确', hint: '检查输入后重试', }, INVALID_USERNAME: { status: 400, message: '用户名需为 3–32 位字母、数字、下划线或短横线', hint: '更换用户名', }, WEAK_PASSWORD: { status: 400, message: '密码需至少 10 位,且不能是纯数字或纯字母', hint: '使用更长的混合密码', }, INVALID_CERT: { status: 400, message: '证书格式不正确,请只上传 Moonlight 公钥证书(.pem/.crt)', hint: '确认未上传含私钥的文件', }, INVALID_ARGUMENT: { status: 400, message: '参数格式不合法', hint: '按提示格式输入', }, UNAUTHORIZED: { status: 401, message: '未登录或登录已失效', hint: '重新登录', }, INVALID_CREDENTIALS: { status: 401, message: '用户名或密码错误', hint: '检查大小写后重试', }, TOKEN_EXPIRED: { status: 401, message: '登录已过期', hint: '自动续期中,若持续失败请重新登录', }, TOKEN_REVOKED: { status: 401, message: '该登录已被登出', hint: '重新登录', }, FORBIDDEN: { status: 403, message: '无权访问该资源', hint: '联系管理员', }, NOT_FOUND: { status: 404, message: '资源不存在', hint: '刷新后重试', }, USER_EXISTS: { status: 409, message: '该用户名已被注册', hint: '更换用户名或直接登录', }, PAYLOAD_TOO_LARGE: { status: 413, message: '请求内容过大', hint: '请检查证书文件大小(≤8KB)', }, RATE_LIMITED: { status: 429, message: '尝试过于频繁,请 15 分钟后再试', hint: '等待后重试,或联系管理员清除锁定', }, INTERNAL: { status: 500, message: '服务器内部错误', hint: '记录错误码并联系管理员', }, }); /** 全部合法错误码。 */ export const ERROR_CODES = Object.freeze(Object.keys(ERROR_TABLE)); /** * 业务异常。业务代码禁止 `throw new Error(字面量)`,一律 `throw new AppError(code)`。 */ export class AppError extends Error { /** * @param {string} code 必须是 ERROR_TABLE 中的键 * @param {Record} [details] 结构化补充信息(仅 4xx 会下发) */ constructor(code, details) { const entry = ERROR_TABLE[code] || ERROR_TABLE.INTERNAL; super(entry.message); this.name = 'AppError'; this.code = ERROR_TABLE[code] ? code : 'INTERNAL'; this.status = entry.status; this.details = details && typeof details === 'object' ? details : undefined; } } /** * 取错误码对应的 HTTP 状态码(未知码回落 500)。 * @param {string} code * @returns {number} */ export function statusOf(code) { const entry = ERROR_TABLE[code]; return entry ? entry.status : 500; } /** * 构造失败响应体。键顺序固定(ok → error{code,message,hint,details}), * 保证 401 响应体**逐字节稳定**。 * * @param {string} code 错误码 * @param {Record} [details] 结构化补充(仅 4xx 下发) * @param {string} [requestId] 仅 5xx 传入 * @returns {Record} */ export function buildErrorBody(code, details, requestId) { const entry = ERROR_TABLE[code] || ERROR_TABLE.INTERNAL; const safeCode = ERROR_TABLE[code] ? code : 'INTERNAL'; /** @type {Record} */ const error = { code: safeCode, message: entry.message }; if (entry.hint) error.hint = entry.hint; if (safeCode !== 'INTERNAL' && details && typeof details === 'object' && Object.keys(details).length > 0) { error.details = details; } if (safeCode === 'INTERNAL' && typeof requestId === 'string') error.requestId = requestId; return { ok: false, error }; } /** * 发送成功响应:`{ ok: true, data }`。 * @param {import('express').Response} res * @param {unknown} data * @param {number} [status=200] * @returns {void} */ export function sendOk(res, data, status = 200) { res.status(status).json({ ok: true, data: data === undefined ? null : data }); } /** * 发送失败响应。 * @param {import('express').Response} res * @param {string} code * @param {Record} [details] * @param {string} [requestId] * @returns {void} */ export function sendError(res, code, details, requestId) { res.status(statusOf(code)).json(buildErrorBody(code, details, requestId)); } /** * 请求 ID + 安全响应头中间件(G-08 / R-P1-05)。 * 手写 3–5 行等价于 helmet 的最小集合,零新增依赖。 * @returns {import('express').RequestHandler} */ export function securityHeaders() { return function securityHeadersMiddleware(req, res, next) { req.id = typeof req.headers['x-request-id'] === 'string' && req.headers['x-request-id'].length <= 128 ? req.headers['x-request-id'] : randomUUID(); res.setHeader('X-Request-Id', req.id); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('Referrer-Policy', 'no-referrer'); res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"); res.setHeader('Cache-Control', 'no-store'); // CORS:本服务不发送任何 Access-Control-Allow-* 头 ⇒ 浏览器默认拒绝跨源读取, // 即「非本机来源 CORS 默认拒绝」,无需额外中间件。 next(); }; } /** * `/api/*` → `/api/v1/*` 的 308 永久重定向(架构 §4.1)。 * * 例外:`/api/health` 不重定向(健康检查/容器探针不应依赖重定向), * 由路由双注册提供。 * * @returns {import('express').RequestHandler} */ export function legacyApiRedirect() { return function legacyApiRedirectMiddleware(req, res, next) { const url = req.originalUrl || req.url || ''; if (!url.startsWith('/api/')) return next(); if (url.startsWith('/api/v1/')) return next(); if (url === '/api/health' || url.startsWith('/api/health?') || url.startsWith('/api/health/')) return next(); const target = `/api/v1${url.slice('/api'.length)}`; res.setHeader('Location', target); res.status(308).type('text/plain; charset=utf-8').send(`Moved Permanently to ${target}`); }; } /** * 404 兜底:把未匹配的路径转成统一错误。 * @returns {import('express').RequestHandler} */ export function notFoundHandler() { return function notFoundMiddleware(req, _res, next) { next(new AppError('NOT_FOUND', { path: req.path })); }; } /** * 全局错误中间件(必须是 4 参签名)。5xx 绝不泄漏堆栈,只下发 requestId。 * * @param {{error: Function, warn?: Function}} logger * @returns {import('express').ErrorRequestHandler} */ export function errorHandler(logger) { return function unifiedErrorHandler(err, req, res, next) { if (res.headersSent) { // 响应已发出,交给 Express 默认处理(关闭连接),不再尝试写 body if (typeof next === 'function') next(err); return; } // ---- body-parser 的既知错误 ---- if (err && typeof err === 'object' && typeof err.type === 'string') { if (err.type === 'entity.too.large') { sendError(res, 'PAYLOAD_TOO_LARGE'); return; } if (err.type === 'entity.parse.failed') { sendError(res, 'VALIDATION_FAILED', { reason: 'body 不是合法 JSON' }); return; } if (err.type === 'encoding.unsupported' || err.type === 'charset.unsupported') { sendError(res, 'VALIDATION_FAILED', { reason: '不支持的编码' }); return; } } // ---- 业务异常 ---- if (err instanceof AppError) { if (err.status >= 500) { safeLog(logger, 'error', err.message, { code: err.code, requestId: req.id, path: req.path }); sendError(res, err.code, err.details, req.id); return; } sendError(res, err.code, err.details); return; } // ---- 未预期异常:记日志 + 统一 500,绝不回显堆栈 ---- safeLog(logger, 'error', '未捕获的服务端异常', { requestId: req.id, path: req.path, method: req.method, error: err instanceof Error ? { name: err.name, message: err.message } : String(err), }); sendError(res, 'INTERNAL', undefined, req.id); }; } /** * 安全地调用 logger(logger 可能未注入或字段非法)。 * @param {{error?: Function, warn?: Function}|undefined|null} logger * @param {'error'|'warn'} level * @param {string} msg * @param {Record} fields * @returns {void} */ function safeLog(logger, level, msg, fields) { try { const fn = logger && typeof logger[level] === 'function' ? logger[level] : null; if (fn) fn.call(logger, msg, fields); } catch { /* 日志失败不影响错误响应 */ } } export default { AppError, ERROR_TABLE, ERROR_CODES, buildErrorBody, sendOk, sendError, statusOf, securityHeaders, legacyApiRedirect, notFoundHandler, errorHandler, };