Skip to content

NestJS 异常处理模块设计指南

一、为什么要统一异常处理?

在 Web 应用中,代码可能因为各种原因出错:

  • 用户输入不合法(如邮箱格式错误)
  • 权限不足(如普通用户访问管理员接口)
  • Token 过期
  • 数据库连接失败
  • 服务器内部逻辑错误

如果不统一处理这些错误,会出现:

  • 前端收到五花八门的响应格式(有时是 { error: 'xxx' },有时是 HTML 错误页)
  • 状态码混乱(业务错误也返回 500)
  • 开发者难以排查问题(没有日志)

统一异常处理的目标

  1. 所有接口返回 一致的 JSON 格式
  2. 业务错误返回 HTTP 200 + 自定义错误码(方便前端统一拦截)
  3. 系统错误返回标准 HTTP 状态码(如 401、500)
  4. 自动记录错误日志

二、NestJS 中的异常基础

1. 内置异常类

NestJS 提供了多种内置异常,都继承自 HttpException

ts
throw new BadRequestException('请求参数错误');
throw new UnauthorizedException('未认证');
throw new ForbiddenException('无权限');
throw new NotFoundException('资源不存在');

它们会自动返回对应的 HTTP 状态码(如 400、401、403、404)。

2. 自定义异常

对于业务逻辑错误(比如“余额不足”、“手机号已注册”),我们通常不想返回 4xx/5xx,而是希望:

  • HTTP 状态码为 200
  • 响应体包含自定义 codemessage

这就需要我们自定义异常类。

三、模块结构设计(推荐目录)

src/
└── common/
    └── exceptions/
        ├── business.exception.ts     // 自定义业务异常类
        ├── business.error.code.ts    // 业务错误码常量
        └── all-exceptions.filter.ts  // 全局异常过滤器

四、详细实现步骤

步骤 1:定义业务错误码(business.error.code.ts

ts
// src/common/exceptions/business.error.code.ts

/**
 * 业务错误码规范:
 * - 10000 ~ 19999:通用业务错误
 * - 20000+:模块专属错误(如用户模块 20001~29999)
 */
export const BUSINESS_ERROR_CODE = {
  // 通用错误
  COMMON: 10001,           // 一般性错误
  TOKEN_INVALID: 10002,    // Token 无效或过期
  ACCESS_FORBIDDEN: 10003, // 无权限
  USER_DISABLED: 10004,    // 账号被禁用
} as const;

使用 as const 可以让 TypeScript 推断为字面量类型,避免被当作 number

步骤 2:创建业务异常类(business.exception.ts

ts
// src/common/exceptions/business.exception.ts

import { HttpException, HttpStatus } from '@nestjs/common';
import { BUSINESS_ERROR_CODE } from './business.error.code';

// 定义业务错误结构
export type BusinessError = {
  code: number;
  message: string;
};

/**
 * 业务异常类
 * - 继承 HttpException,但状态码固定为 200
 * - 响应体为 { code, message }
 */
export class BusinessException extends HttpException {
  constructor(err: BusinessError | string) {
    // 如果传入的是字符串,使用通用错误码
    const error =
      typeof err === 'string'
        ? { code: BUSINESS_ERROR_CODE.COMMON, message: err }
        : err;

    // 注意:这里故意使用 HttpStatus.OK (200)
    super(error, HttpStatus.OK);
  }

  // 静态方法:快速抛出特定错误
  static TokenInvalid(): never {
    throw new BusinessException({
      code: BUSINESS_ERROR_CODE.TOKEN_INVALID,
      message: '认证失败,请重新登录',
    });
  }

  static Forbidden(): never {
    throw new BusinessException({
      code: BUSINESS_ERROR_CODE.ACCESS_FORBIDDEN,
      message: '抱歉,您没有此操作权限!',
    });
  }
}

为什么用 HttpStatus.OK
因为这是“业务逻辑上的失败”,不是“HTTP 请求失败”。前端可以统一处理 res.code !== 0 的情况,而不用关心 HTTP 状态码。

步骤 3:编写全局异常过滤器(all-exceptions.filter.ts

ts
// src/common/exceptions/all-exceptions.filter.ts

import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpStatus,
  Logger,
  HttpException,
} from '@nestjs/common';
import { BusinessException } from './business.exception';
import { BUSINESS_ERROR_CODE } from './business.error.code';

@Catch() // 捕获所有类型的异常
export class AllExceptionsFilter implements ExceptionFilter {
  private readonly logger = new Logger(AllExceptionsFilter.name);

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();

    // 🔹 第一步:记录日志(非常重要!)
    if (exception instanceof Error) {
      this.logger.error(exception.message, exception.stack, 'ExceptionFilter');
    } else {
      this.logger.error('Unknown exception', JSON.stringify(exception));
    }

    // 🔹 第二步:处理业务异常(BusinessException)
    if (exception instanceof BusinessException) {
      const error = exception.getResponse() as { code: number; message: string };
      return response.status(HttpStatus.OK).json({
        code: error.code,
        message: error.message,
        data: null, // 保持结构统一
      });
    }

    // 🔹 第三步:处理标准 HTTP 异常(如 UnauthorizedException)
    if (exception instanceof HttpException) {
      const status = exception.getStatus();
      const errorResponse = exception.getResponse();

      // 特殊处理:401 未授权 → 转为业务错误(可选)
      if (status === HttpStatus.UNAUTHORIZED) {
        return response.status(HttpStatus.OK).json({
          code: BUSINESS_ERROR_CODE.TOKEN_INVALID,
          message: '登录已过期,请重新登录',
          data: null,
        });
      }

      // 通用 HTTP 异常格式
      const message =
        typeof errorResponse === 'string'
          ? errorResponse
          : (errorResponse as any)?.message || '请求处理失败';

      return response.status(status).json({
        code: status,
        message,
        data: null,
        timestamp: new Date().toISOString(),
        path: request.url,
      });
    }

    // 🔹 第四步:兜底处理(未知异常,如数据库崩溃、空指针等)
    this.logger.error('Unexpected system error', exception as any);
    return response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
      code: HttpStatus.INTERNAL_SERVER_ERROR,
      message: '服务器开小差了,请稍后再试~',
      data: null,
      timestamp: new Date().toISOString(),
      path: request.url,
    });
  }
}

步骤 4:在主应用中启用全局过滤器(main.ts

ts
// src/main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/exceptions/all-exceptions.filter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // 注册全局异常过滤器
  app.useGlobalFilters(new AllExceptionsFilter());

  await app.listen(3000);
}
bootstrap();

注意:不要同时注册多个全局异常过滤器,否则可能冲突。

五、如何在业务代码中使用?

场景 1:抛出通用业务错误

ts
if (!user) {
  throw new BusinessException('用户不存在');
}

场景 2:抛出特定业务错误

ts
if (user.isDisabled) {
  throw BusinessException.Forbidden(); // 静态方法
}

场景 3:使用 NestJS 内置异常(会走 HTTP 异常分支)

ts
throw new UnauthorizedException(); // 会被转为业务错误(因我们在 filter 中特殊处理了 401)
throw new BadRequestException('邮箱格式错误'); // 返回 400 + 标准格式

六、前端如何对接?

无论什么错误,前端只需判断 res.code

ts
// axios 响应拦截器示例
axios.interceptors.response.use(
  (response) => {
    const { code, message, data } = response.data;
    if (code !== 200 && code !== 0) {
      // 业务错误
      alert(message);
      if (code === 10002) {
        // 跳转登录页
        window.location.href = '/login';
      }
      return Promise.reject({ code, message });
    }
    return data;
  },
  (error) => {
    // 网络错误或 5xx
    alert('网络异常,请检查后重试');
    return Promise.reject(error);
  }
);

建议:约定成功时 code = 0200,失败时为其他值。

七、常见问题 FAQ

Q1:为什么业务错误要用 200 状态码?

A:因为这是“逻辑失败”,不是“通信失败”。前端可以统一处理 JSON 响应,而不用区分 200/400/500。

Q2:会不会影响 SEO 或 REST 规范?

A:内部系统或 API 服务通常不需要严格遵循 REST 状态码。如果对外提供标准 REST API,可调整策略(业务错误也返回 4xx)。

Q3:如何记录更详细的日志?

A:可集成 WinstonPino 日志库,在 filter 中记录 request.method, request.url, userId 等上下文。

八、总结

组件作用
BUSINESS_ERROR_CODE统一管理错误码,避免魔法数字
BusinessException封装业务错误,返回 200 + 自定义 code
AllExceptionsFilter全局捕获异常,统一响应格式 + 记录日志

这套设计:

  • 简单清晰,适合新手
  • 前端对接成本低
  • 便于后期扩展(如加 traceId、国际化)