NestJS 实战:全局 Pipe、Guard、Interceptor 与异常处理链路
前言
上一篇从 main.ts、AppModule、common、database、crm 和 saas 几个层面,梳理了真实 NestJS 项目的整体结构。
但只看目录还不够。一个请求真正进入项目后,还会经过参数转换、参数校验、登录认证、权限判断、业务处理、响应包装和异常处理等多个环节。
如果这些环节没有统一设计,项目很容易出现:
- 每个接口都重复写参数校验
- 每个 Controller 都重复写异常处理
- 不同接口返回格式不一致
- 前端无法区分未登录和无权限
- 数据库异常直接暴露给客户端
- 空字符串、数字和日期类型处理混乱
本篇结合真实项目中的 ValidationPipe、自定义 Pipe、JWT Guard、PermissionGuard、ResponseInterceptor 和异常过滤器,完整梳理一条 NestJS 请求链路。
一、先建立整体请求模型
一个中后台接口可以抽象成下面这条链路:
前端发起请求
↓
请求进入 NestJS
↓
Middleware
↓
Guards:认证和权限
↓
Pipes:参数转换和校验
↓
Interceptors:请求前处理
↓
Controller
↓
Service
↓
Repository / 外部服务
↓
Interceptors:响应后处理
↓
Exception Filter:异常转换
↓
返回客户端
这里有两个需要注意的点:
- 这不是简单的“Controller 调 Service”
- 全局能力的价值在于让业务代码保持简洁
业务 Controller 不应该重复实现登录判断、参数转换、统一响应和错误格式化。
二、main.ts 中的全局能力
真实项目的启动入口通常会集中注册全局能力:
const app = await NestFactory.create(AppModule)
app.useGlobalInterceptors(new ResponseInterceptor())
app.useGlobalPipes(
new EmptyStringToNullPipe(),
new ValidationPipe({
whitelist: true,
transform: true
})
)
app.useGlobalFilters(new AllExceptionsFilter())
可以把这些配置理解成三组职责:
| 能力 | 主要职责 |<br>|---|---|<br>| Pipe | 转换和校验输入参数 |<br>| Guard | 判断请求是否允许继续 |<br>| Interceptor | 处理请求前后逻辑、统一包装响应 |<br>| Filter | 捕获异常并转换成统一错误响应 |
这些能力一起构成了项目的基础运行时。
三、Pipe:请求参数的第一道处理层
Pipe 主要处理两件事:
- 转换参数
- 校验参数
3.1 为什么要使用 DTO
一个新增客户接口可能接收这样的数据:
export class CreateCustomerDto {
@IsNotEmpty({ message: '客户名称不能为空' })
name: string
@IsOptional()
@IsPhoneNumber('CN', { message: '手机号格式不正确' })
phone?: string
@IsOptional()
remark?: string
}
DTO 的作用是明确描述接口输入结构。它不只是 TypeScript 类型提示,还可以配合 class-validator 在运行时校验数据。
如果没有 DTO,业务代码就可能变成:
if (!body.name) {
throw new BadRequestException('客户名称不能为空')
}
if (body.phone && !isValidPhone(body.phone)) {
throw new BadRequestException('手机号格式不正确')
}
当接口越来越多时,这种写法会造成大量重复代码。
3.2 ValidationPipe 的全局配置
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true
})
)
whitelist
whitelist: true 会剔除 DTO 中没有声明的字段。
例如 DTO 只允许:
class LoginDto {
username: string
password: string
}
客户端额外提交:
{
"username": "admin",
"password": "123456",
"isAdmin": true
}
未声明的 isAdmin 不应该直接进入业务层。
transform
查询参数通常都是字符串:
?page=1&pageSize=20&enabled=true
开启 transform 后,可以配合 DTO 把它们转换成业务需要的类型:
export class PaginationDto {
@Type(() => Number)
@IsInt()
@Min(1)
page = 1
@Type(() => Number)
@IsInt()
@Min(1)
pageSize = 20
}
3.3 自定义 Pipe:空字符串转 null
中后台表单经常把未填写的字段提交为空字符串:
{
"remark": "",
"contactName": ""
}
但在数据库中,空字符串和 NULL 可能代表不同含义。项目通过自定义 EmptyStringToNullPipe,在 ValidationPipe 之前完成转换:
请求 Body
↓
空字符串转 null
↓
DTO 校验
↓
进入 Controller
这类转换应该集中处理,而不是让每个 Service 手动判断:
value.remark = value.remark || null
value.contactName = value.contactName || null
四、Guard:请求能不能继续执行
Guard 的职责是回答一个问题:
当前请求是否允许进入 Controller?
常见 Guard 包括:
- JWT 登录 Guard
- 团队上下文 Guard
- 权限码 Guard
- 特殊接口访问 Guard
4.1 JWT Guard
项目的 JWT Guard 基于 Passport:
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(
'isPublic',
[context.getHandler(), context.getClass()]
)
if (isPublic) return true
return super.canActivate(context)
}
}
它首先判断当前接口是否被标记为公开接口:
@Public()
@Get('health')
healthCheck() {
return { status: 'ok' }
}
公开接口可以跳过 JWT 校验,其他接口则必须携带有效 Token。
4.2 未登录和 Token 失效
JWT Guard 在以下场景应该抛出 UnauthorizedException:
- 没有 Authorization 请求头
- Token 格式不正确
- Token 已过期
- Token 签名校验失败
- Token 中没有有效用户信息
HTTP 状态码通常是 401,前端可以据此跳转到登录页或清理本地登录状态。
4.3 PermissionGuard
登录认证通过,不代表用户拥有所有权限。项目还会通过 PermissionGuard 继续校验:
用户是否已登录
↓
是否存在团队上下文
↓
是否为管理员或团队创建者
↓
当前接口声明了哪些权限码
↓
用户是否拥有权限码
接口可以通过装饰器声明权限:
@Permission({
code: 'export'
})
@Get('export')
exportCustomer() {
return this.customerService.export()
}
PermissionGuard 再根据模块路径和权限配置,构造最终权限码并完成校验。
4.4 401 和 403 的区别
这两个状态码不要混用:
| 状态码 | 含义 | 典型场景 |<br>|---|---|---|<br>| 401 | 未完成身份认证 | 没登录、Token 过期 |<br>| 403 | 已认证但无权访问 | 没有权限码、团队权限不足 |
前端可以根据状态码进行不同处理:
- 401:清理 Token,跳转登录页
- 403:提示无权限,不应该强制退出登录
五、Interceptor:统一处理请求和响应
Interceptor 可以在 Controller 执行前后插入逻辑:
@Injectable()
export class ResponseInterceptor implements NestInterceptor {
intercept(
context: ExecutionContext,
next: CallHandler
): Observable<any> {
return next.handle().pipe(
map(data => ({
code: 200,
message: 'success',
data
}))
)
}
}
5.1 为什么统一响应
统一响应可以让前端请求层稳定处理数据:
const result = await getCustomerList(params)
if (result.code === 200) {
tableData.value = result.data.list
}
如果不同接口返回格式不同,前端就需要编写大量特殊判断:
result.data.data.list
result.result.items
result.rows
统一响应结构可以明显降低联调成本。
5.2 哪些响应不适合包装
并不是所有响应都适合统一包装:
- 文件下载
- 图片或视频流
- Server-Sent Events
- 特殊第三方回调
因此拦截器通常需要识别响应类型,对特殊响应直接放行。
5.3 Interceptor 还能做什么
除了统一返回结构,Interceptor 还适合处理:
- 请求耗时统计
- 操作日志
- 链路追踪 ID
- 敏感字段脱敏
- 缓存读取与写入
- 统一转换分页数据
但不要把所有业务逻辑都塞进 Interceptor。它适合横切逻辑,不适合替代 Service。
六、Exception Filter:统一处理错误
如果接口执行过程中抛出异常,NestJS 会把异常交给 Exception Filter 处理。
项目使用全局异常过滤器,目标是把各种异常转换为稳定的错误结构:
{
"code": 400,
"message": "客户名称不能为空",
"data": null
}
6.1 业务异常
业务代码可以抛出有语义的异常:
const customer = await this.customerRepository.findOneBy({ uuid })
if (!customer) {
throw new NotFoundException('客户不存在')
}
Filter 负责统一处理,不需要每个 Controller 都重复写 try/catch。
6.2 未知异常
对于未预期异常,客户端不应该看到完整堆栈:
客户端:返回通用错误提示
服务端:记录完整堆栈、请求路径和用户信息
这样既方便排查问题,也避免泄露数据库结构、文件路径和内部实现。
6.3 数据库异常
数据库异常经常包含不适合直接返回给用户的信息,例如:
- 唯一索引冲突
- 外键约束失败
- SQL 字段错误
- 数据库连接失败
Filter 可以根据异常类型转换成更容易理解的业务提示:
Duplicate entry
↓
该数据已存在,请勿重复创建
七、一个客户列表接口的完整示例
7.1 DTO
export class CustomerQueryDto extends PaginationDto {
@IsOptional()
@IsString()
keyword?: string
@IsOptional()
@IsUUID()
teamUuid?: string
}
7.2 Controller
@Controller('customer')
export class CustomerController {
constructor(
private readonly customerService: CustomerService
) {}
@Get('list')
async list(@Query() query: CustomerQueryDto) {
return this.customerService.findPage(query)
}
}
7.3 Service
@Injectable()
export class CustomerService {
async findPage(query: CustomerQueryDto) {
const { page = 1, pageSize = 20, keyword } = query
const qb = this.customerRepository
.createQueryBuilder('customer')
.orderBy('customer.createdAt', 'DESC')
.skip((page - 1) * pageSize)
.take(pageSize)
if (keyword) {
qb.andWhere('customer.name LIKE :keyword', {
keyword: `%${keyword}%`
})
}
const [list, total] = await qb.getManyAndCount()
return {
list,
total,
page,
pageSize
}
}
}
7.4 请求执行过程
GET /customer/list?page=1&pageSize=20
↓
PaginationDto 转换 page 和 pageSize
↓
ValidationPipe 校验参数
↓
JwtAuthGuard 校验 Token
↓
PermissionGuard 校验客户列表权限
↓
CustomerController.list()
↓
CustomerService.findPage()
↓
TypeORM 查询 MySQL
↓
ResponseInterceptor 包装响应
如果任何一个环节抛出异常,都会由统一异常过滤器转换后返回。
八、全局能力与业务代码如何分工
可以按照下面的规则进行拆分:
| 问题 | 合适的位置 |<br>|---|---|<br>| 请求参数格式校验 | DTO + Pipe |<br>| 是否登录 | JWT Guard |<br>| 是否有接口权限 | PermissionGuard |<br>| 统一返回格式 | Interceptor |<br>| 错误转换和记录 | Exception Filter |<br>| 客户查询逻辑 | CustomerService |<br>| 数据库查询 | Repository / Database 层 |<br>| 外部服务调用 | 独立 Service |
当一个逻辑不知道放在哪里时,可以先问自己:
这个逻辑是通用的横切能力,还是某个业务领域的具体规则?
如果是通用横切能力,就考虑 Pipe、Guard、Interceptor 或 Filter;如果只服务于客户、订单、供应商等具体领域,就应该留在对应业务模块中。
九、常见错误与改进方式
9.1 在每个接口中手写参数校验
问题:重复、容易漏校验、错误格式不统一。
改进:使用 DTO 和全局 ValidationPipe。
9.2 Controller 里直接查询数据库
问题:Controller 变得臃肿,难以复用和测试。
改进:Controller 只负责接收请求,将业务交给 Service。
9.3 用 try/catch 包住所有业务代码
问题:重复代码多,异常处理标准不一致。
改进:只在需要补充上下文或转换异常时使用 try/catch,其余交给全局 Filter。
9.4 前端收到 403 后清除登录状态
问题:用户只是没有权限,却被错误地当成登录失效。
改进:401 和 403 分开处理。
9.5 Interceptor 中编写业务规则
问题:业务逻辑隐藏在全局代码中,调试困难。
改进:Interceptor 只处理响应包装、日志、缓存等横切逻辑。
9.6 返回完整异常堆栈
问题:泄露内部结构和敏感信息。
改进:服务端记录完整日志,客户端返回安全提示。
十、面试聚焦
10.1 Pipe 和 Guard 的区别是什么?
Pipe 主要处理请求参数转换和校验,Guard 主要判断请求是否有资格继续执行。
10.2 Interceptor 和 Middleware 有什么区别?
Middleware 更靠近底层请求,可以在 Nest 路由处理之前执行;Interceptor 能访问执行上下文,并且可以处理 Controller 执行前后的逻辑。
10.3 为什么要区分 401 和 403?
401 表示未完成认证,403 表示已经认证但没有权限。前端处理策略不同,不能混用。
10.4 为什么统一异常处理很重要?
它可以保证不同模块返回一致的错误格式,同时避免数据库和系统内部信息直接暴露给客户端。
10.5 全局 Pipe、Guard、Interceptor 是不是越多越好?
不是。全局能力应该稳定、通用、可预测。只有真正适合所有模块的逻辑,才应该注册为全局能力。
十一、思考与练习
- 如果某个接口允许匿名访问,应该如何跳过 JWT Guard?
- 为什么空字符串转
null应该放在全局 Pipe 中处理? - 如果一个接口返回文件流,统一响应拦截器应该如何处理?
- 为什么没有权限应该返回 403,而不是 401?
- 如果数据库唯一索引冲突,应该直接把数据库错误返回给前端吗?
- 哪些逻辑适合放在 Interceptor,哪些逻辑应该放在 Service?
总结
- Pipe 负责输入参数的转换和校验
- DTO 让接口输入结构清晰且可复用
- Guard 负责登录认证、团队校验和权限判断
- JWT Guard 解决“是谁”,PermissionGuard 解决“能做什么”
- Interceptor 适合统一响应、日志和其他横切逻辑
- Exception Filter 负责统一异常格式并保护内部信息
- 401 表示未认证,403 表示已认证但无权限
- 全局能力应该保持通用,业务规则应该留在对应 Service
- 一条清晰的请求链路,是大型 NestJS 项目可维护性的基础
更多推荐

所有评论(0)