前言

上一篇从 main.tsAppModulecommondatabasecrmsaas 几个层面,梳理了真实 NestJS 项目的整体结构。

但只看目录还不够。一个请求真正进入项目后,还会经过参数转换、参数校验、登录认证、权限判断、业务处理、响应包装和异常处理等多个环节。

如果这些环节没有统一设计,项目很容易出现:

  • 每个接口都重复写参数校验
  • 每个 Controller 都重复写异常处理
  • 不同接口返回格式不一致
  • 前端无法区分未登录和无权限
  • 数据库异常直接暴露给客户端
  • 空字符串、数字和日期类型处理混乱

本篇结合真实项目中的 ValidationPipe、自定义 Pipe、JWT Guard、PermissionGuard、ResponseInterceptor 和异常过滤器,完整梳理一条 NestJS 请求链路。


一、先建立整体请求模型

一个中后台接口可以抽象成下面这条链路:

前端发起请求
  ↓
请求进入 NestJS
  ↓
Middleware
  ↓
Guards:认证和权限
  ↓
Pipes:参数转换和校验
  ↓
Interceptors:请求前处理
  ↓
Controller
  ↓
Service
  ↓
Repository / 外部服务
  ↓
Interceptors:响应后处理
  ↓
Exception Filter:异常转换
  ↓
返回客户端

这里有两个需要注意的点:

  1. 这不是简单的“Controller 调 Service”
  2. 全局能力的价值在于让业务代码保持简洁

业务 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 是不是越多越好?

不是。全局能力应该稳定、通用、可预测。只有真正适合所有模块的逻辑,才应该注册为全局能力。


十一、思考与练习

  1. 如果某个接口允许匿名访问,应该如何跳过 JWT Guard?
  2. 为什么空字符串转 null 应该放在全局 Pipe 中处理?
  3. 如果一个接口返回文件流,统一响应拦截器应该如何处理?
  4. 为什么没有权限应该返回 403,而不是 401?
  5. 如果数据库唯一索引冲突,应该直接把数据库错误返回给前端吗?
  6. 哪些逻辑适合放在 Interceptor,哪些逻辑应该放在 Service?

总结

  • Pipe 负责输入参数的转换和校验
  • DTO 让接口输入结构清晰且可复用
  • Guard 负责登录认证、团队校验和权限判断
  • JWT Guard 解决“是谁”,PermissionGuard 解决“能做什么”
  • Interceptor 适合统一响应、日志和其他横切逻辑
  • Exception Filter 负责统一异常格式并保护内部信息
  • 401 表示未认证,403 表示已认证但无权限
  • 全局能力应该保持通用,业务规则应该留在对应 Service
  • 一条清晰的请求链路,是大型 NestJS 项目可维护性的基础
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐