NestJS 异常处理模块设计指南
一、为什么要统一异常处理?
在 Web 应用中,代码可能因为各种原因出错:
- 用户输入不合法(如邮箱格式错误)
- 权限不足(如普通用户访问管理员接口)
- Token 过期
- 数据库连接失败
- 服务器内部逻辑错误
如果不统一处理这些错误,会出现:
- 前端收到五花八门的响应格式(有时是
{ error: 'xxx' },有时是 HTML 错误页) - 状态码混乱(业务错误也返回 500)
- 开发者难以排查问题(没有日志)
统一异常处理的目标:
- 所有接口返回 一致的 JSON 格式
- 业务错误返回
HTTP 200+ 自定义错误码(方便前端统一拦截) - 系统错误返回标准 HTTP 状态码(如 401、500)
- 自动记录错误日志
二、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 - 响应体包含自定义
code和message
这就需要我们自定义异常类。
三、模块结构设计(推荐目录)
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 = 0 或 200,失败时为其他值。
七、常见问题 FAQ
Q1:为什么业务错误要用 200 状态码?
A:因为这是“逻辑失败”,不是“通信失败”。前端可以统一处理 JSON 响应,而不用区分 200/400/500。
Q2:会不会影响 SEO 或 REST 规范?
A:内部系统或 API 服务通常不需要严格遵循 REST 状态码。如果对外提供标准 REST API,可调整策略(业务错误也返回 4xx)。
Q3:如何记录更详细的日志?
A:可集成 Winston 或 Pino 日志库,在 filter 中记录 request.method, request.url, userId 等上下文。
八、总结
| 组件 | 作用 |
|---|---|
BUSINESS_ERROR_CODE | 统一管理错误码,避免魔法数字 |
BusinessException | 封装业务错误,返回 200 + 自定义 code |
AllExceptionsFilter | 全局捕获异常,统一响应格式 + 记录日志 |
这套设计:
- 简单清晰,适合新手
- 前端对接成本低
- 便于后期扩展(如加 traceId、国际化)