今天调试前端接口,又被后端同事甩过来的报错恶心到了。点开 network 一看,一个 HTTP 500 直接把完整的 PostgreSQL 错误调用栈 org.postgresql.util.PSQLException: ERROR: duplicate key value violates unique constraint... 暴露在 JSON 响应里。不仅前端直接懵逼,连用户看一眼都能知道我们后台表结构,这简直是裸奔的安全大漏洞。

这种直接把底层 DB 异常原封不动甩给客户端的垃圾接口,在日常排查中简直是灾难。前端根本无法根据这个报错做优雅的 UI 降级提示,只能生硬地展示“系统繁忙”或者把一串英文报错怼给用户。而且,一旦线上出错,由于没有统一的排查 ID(Trace ID),我们要去 ELK 日志库里检索对应的堆栈信息就像大海捞针一样痛苦。

要解决这个问题,首先需要达成共识:API 接口的异常响应应该是一个高内聚的黑盒。底层技术细节(如具体的 SQL 语句、数据库连接错误、代码行数)绝对不能溢出到外网。

我们需要一套统一的 JSON 错误结构,它至少应该包含以下几个核心属性:

  1. HTTP Status Code:遵循标准 HTTP 规范(如 400 Bad Request, 401 Unauthorized, 422 Unprocessable Entity, 500 Internal Error)。
  2. Code (业务状态码):细粒度的字符串式业务错误编码,方便前端直接做分支判断(例如 AUTH_TOKEN_EXPIRED 远比 401 更好用)。
  3. Message:对用户友好的多语言提示语。
  4. TraceId:全局唯一的请求标识,方便开发人员直接根据该 ID 去日志系统精准定位堆栈。
  5. Details (可选):具体字段的校验错误信息。

下面是我们目前在 Node.js (TypeScript) 项目中落地并推行的一套统一 API 错误处理和 Express 中间件的具体实现:

// 1. 定义标准的统一错误响应结构
export interface ApiErrorResponse {
  code: string;       // 业务状态码,例如 'USER_NOT_FOUND', 'INVALID_PARAMETER'
  message: string;    // 用户友好的多语言错误描述
  traceId: string;    // 全局唯一请求 ID,便于定位问题
  details?: Array<{
    field: string;
    message: string;
  }>;
}

// 2. 自定义基类异常类
export class AppError extends Error {
  public readonly statusCode: number;
  public readonly code: string;
  public readonly details: any;

  constructor(statusCode: number, code: string, message: string, details: any = null) {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.details = details;
    Object.setPrototypeOf(this, new.target.prototype);
  }
}

// 3. 统一全局异常处理中间件
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';

export function errorHandler(
  err: any,
  req: Request,
  res: Response,
  next: NextFunction
): void {
  // 生产环境务必生成一个全局排查 ID,并绑定到响应和日志中
  const traceId = (req.headers['x-trace-id'] as string) || uuidv4();

  let statusCode = 500;
  let errorCode = 'INTERNAL_SERVER_ERROR';
  let message = '服务器开小差了,请稍后再试';
  let details = undefined;

  if (err instanceof AppError) {
    statusCode = err.statusCode;
    errorCode = err.code;
    message = err.message;
    details = err.details;
  } else {
    // 未知系统异常,在这里记录堆栈到后台日志系统,但绝对不能输出给客户端
    console.error(`[CRITICAL ERROR] TraceId: ${traceId} - Details:`, err);
  }

  const errorResponseBody: ApiErrorResponse = {
    code: errorCode,
    message,
    traceId,
    ...(details && { details }),
  };

  res.status(statusCode).json(errorResponseBody);
}

有了这套设计,前端拿到报错后,可以直接提取 traceId 贴给客服或提工单,后端复制这个 ID 到 Kibana 里一敲,两秒钟就能从几十亿条日志里拽出完整的 DB 异常堆栈,排查效率直线上升。同时,对于表单校验失败(HTTP 422),前端可以非常方便地拿到 details 数组,动态绑定到对应的表单输入框下方进行爆红提示。

不要把接口当作甩锅的工具,给客户端多一点温柔,也就是在拯救我们自己深夜值班的睡眠。

你们公司的 API 错误码是采用纯数字的编码(比如 100021)还是类似我们这种语义化的字符串?在做多语言国际化错误提示时,你们是把字典库放在前端还是后端动态翻译?