最近接手了一个老项目,打开 Controller 层一看,我整个人都麻了。每个路由函数里都塞满了厚重的 try-catch 块,甚至有的接口里面还套了两三层。写代码的人可能是为了稳妥,害怕服务崩溃,但这种“防御性编程”直接导致业务逻辑被淹没在大量的样板代码中。更致命的是,每个 catch 里的错误处理逻辑完全不统一:有的直接 res.status(500).json(err),有的丢个 error_code,还有的干脆把敏感的数据库报错直接吐给了前端。这种野路子写法不仅给前端联调带来了巨大的心智负担,也让线上链路追踪变成了黑盒,出了 Bug 根本无法排查。

要解决这个痛点,最直接的手段就是将异常捕获逻辑彻底从 Controller 中剥离,统一收拢到框架的错误管道中。对于 NestJS 应用,我们可以借助继承 BaseExceptionFilter 的全局拦截过滤器来实现;而对于 Express,则是通过在路由最后挂载一个带有四个入参的 Error-handling middleware

以下是我们在 NestJS 中落地的一套生产级全局异常过滤器实现。它不仅统一了向客户端输出的 JSON 结构,还做到了根据错误类型自动降级、自动提取 HTTP 请求的上下文指标(如 Headers、Query、Body 及当前操作用户),并将其结构化上报给 Sentry,同时还对敏感的数据底层异常进行了脱敏。

import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { Request, Response } from 'express';
import * as Sentry from '@sentry/node';

interface ErrorResponse {
  success: boolean;
  statusCode: number;
  errorCode: string;
  message: string;
  timestamp: string;
  path: string;
}

@Catch()
export class GlobalExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();

    let status = HttpStatus.INTERNAL_SERVER_ERROR;
    let errorCode = 'INTERNAL_SERVER_ERROR';
    let message = '服务器内部错误,请稍后再试';

    if (exception instanceof HttpException) {
      status = exception.getStatus();
      const exceptionResponse = exception.getResponse();
      if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
        message = (exceptionResponse as any).message || exception.message;
        errorCode = (exceptionResponse as any).errorCode || `HTTP_${status}`;
      } else {
        message = exception.message;
        errorCode = `HTTP_${status}`;
      }
    } else if (exception instanceof Error) {
      // 针对底层的系统错误、数据库连接错误等进行逻辑分支判定
      if (exception.name === 'QueryFailedError' || exception.name === 'PrismaClientKnownRequestError') {
        status = HttpStatus.BAD_REQUEST;
        errorCode = 'DATABASE_QUERY_ERROR';
        message = '数据操作异常,请检查输入规范';
      } else {
        // 未捕获的未知异常,通常为系统性崩溃级 Bug
        message = exception.message;
      }
    }

    const errorResponse: ErrorResponse = {
      success: false,
      statusCode: status,
      errorCode,
      message,
      timestamp: new Date().toISOString(),
      path: request.url,
    };

    // 核心设计:仅对 5xx 级别以及关键的 4xx 业务阻塞错误上报 Sentry,避免垃圾数据刷屏
    if (status >= 500 || ['DATABASE_QUERY_ERROR', 'PAYMENT_FAILED'].includes(errorCode)) {
      Sentry.withScope((scope) => {
        scope.setTag('path', request.url);
        scope.setTag('method', request.method);
        scope.setExtra('query', request.query);
        // 敏感数据注意脱敏,避免将明文密码写入日志
        if (request.body && typeof request.body === 'object') {
          const bodyCopy = { ...request.body };
          delete bodyCopy.password;
          delete bodyCopy.token;
          scope.setExtra('body', bodyCopy);
        }
        // 如果存在鉴权中间件写入的用户信息,一同上报
        if ((request as any).user) {
          scope.setUser({ id: (request as any).user.id, email: (request as any).user.email });
        }
        
        Sentry.captureException(exception);
      });
    }

    // 打印结构化日志,便于中间件侧的日志收集工具(如 Filebeat/Loki)捞取
    console.error(JSON.stringify({
      level: 'error',
      ...errorResponse,
      stack: exception instanceof Error ? exception.stack : String(exception),
    }));

    response.status(status).json(errorResponse);
  }
}

把这个过滤器通过 app.useGlobalFilters(new GlobalExceptionsFilter()) 注入全局后,Controller 里的那些 try-catch 全都可以干脆利落地干掉了。Controller 只需要关注健康的业务流,遇到不符合预期的参数或权限不足时,直接 throw new BadRequestException('无效的订单状态') 即可。

这种方案唯一的踩坑点在于 Sentry 的流量控制。高并发场景下一旦遇到数据库抖动,瞬间爆出的几万条 500 报错会瞬间击穿 Sentry 的限额。针对这种极端情况,我们目前是在网关层或者 Sentry SDK 的 beforeSend 回调中做频率限制和去重。

你们团队目前是如何处理未捕获异常的?是在框架拦截器做兜底,还是宁愿写冗余的 try-catch 来保证代码的可读性?