跳转到正文
Conglou's Space
返回

NestJS 使用 Interceptor 统一响应,并通过装饰器自定义 message

当接口数量增加后,如果每个 Controller 都手工返回 { success, data, message },很快就会出现结构不一致、提示语重复和业务数据被包装多次等问题。NestJS Interceptor 位于 Controller 返回结果与 HTTP 响应之间,非常适合集中处理成功响应。

本文实现一个全局 ResponseInterceptor,将返回值统一包装为 { success, data, message },再使用 SetMetadata 和自定义装饰器为特定接口声明成功提示语。

本文聚焦正常响应转换。异常状态码和错误响应应主要交给 Exception Filter,不建议用成功拦截器吞掉异常。

一、统一响应结构

先定义接口返回格式:

export interface ApiResponse<T> {
  success: boolean
  data: T | null
  message: string
}

创建用户成功时,响应可以是:

{
  "success": true,
  "data": {
    "id": 1,
    "username": "testuser2"
  },
  "message": "新增成功"
}

统一结构让前端、日志系统和接口文档拥有稳定约定。但统一包装应该发生在基础设施层,Controller 仍然只返回业务数据:

@Post()
create(@Body() dto: CreateUserDto) {
  return this.userService.create(dto)
}

二、理解 Interceptor 的位置

一次普通请求的简化链路是:

Request
  -> Middleware
  -> Guard
  -> Pipe
  -> Controller / Service
  -> Interceptor RxJS 管道
  -> Response

next.handle() 返回一个 Observable。通过 RxJS 的 map() 可以修改 Controller 的正常返回值:

return next.handle().pipe(
  map((data) => ({
    success: true,
    data,
    message: '请求成功',
  })),
)

如果 Controller 返回 Promise,NestJS 也会把它接入同一条响应流,因此拦截器不需要分别处理同步值和 Promise。

三、实现全局响应拦截器

创建 src/common/interceptors/response.interceptor.ts:

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common'
import { Reflector } from '@nestjs/core'
import { Observable, map } from 'rxjs'
import { SUCCESS_MESSAGE_KEY } from '../decorators/success-message.decorator'

export interface ApiResponse<T> {
  success: boolean
  data: T | null
  message: string
}

@Injectable()
export class ResponseInterceptor implements NestInterceptor<
  unknown,
  ApiResponse<unknown>
> {
  constructor(private readonly reflector: Reflector) {}

  intercept(
    context: ExecutionContext,
    next: CallHandler<unknown>,
  ): Observable<ApiResponse<unknown>> {
    return next.handle().pipe(
      map((data) => ({
        success: true,
        data: data ?? null,
        message: this.getSuccessMessage(context),
      })),
    )
  }

  private getSuccessMessage(context: ExecutionContext): string {
    return (
      this.reflector.get<string>(SUCCESS_MESSAGE_KEY, context.getHandler()) ??
      '请求成功'
    )
  }
}

这里有三个关键点:

  • next.handle() 获取后续处理器的响应流。
  • map() 只转换正常返回值,不会自动处理抛出的异常。
  • data ?? null 把 undefined 和 null 统一为 null,同时保留 0、false 和空字符串等合法值。

不要使用 data || null,否则 0 和 false 会被错误转换为 null。

四、用 SetMetadata 声明接口消息

不同操作需要不同提示语,例如新增成功、更新成功、删除成功。可以直接在 Controller 上调用 SetMetadata:

import { SetMetadata } from '@nestjs/common'

@SetMetadata('successMessage', '新增成功')
@Post()
create() {
  // ...
}

但字符串 Key 和调用细节会散落在业务代码中。更好的方式是封装语义明确的自定义装饰器:

import { SetMetadata } from '@nestjs/common'

export const SUCCESS_MESSAGE_KEY = 'successMessage'

export const SuccessMessage = (message: string) =>
  SetMetadata(SUCCESS_MESSAGE_KEY, message)

使用时只需要:

@SuccessMessage('新增成功')
@Post()
create(@Body() createUserDto: CreateUserDto) {
  return this.userService.create(createUserDto)
}

自定义装饰器没有修改方法返回值,它只是把元数据附加到路由处理器上。真正读取并应用消息的是 ResponseInterceptor。

五、使用 Reflector 读取元数据

ExecutionContext 能获取当前 Controller 类和处理方法:

context.getClass()
context.getHandler()

如果装饰器只允许写在方法上,可以使用:

this.reflector.get<string>(SUCCESS_MESSAGE_KEY, context.getHandler())

如果希望 Controller 级默认值能被方法级配置覆盖,推荐:

private getSuccessMessage(context: ExecutionContext): string {
  return (
    this.reflector.getAllAndOverride<string>(SUCCESS_MESSAGE_KEY, [
      context.getHandler(),
      context.getClass(),
    ]) ?? '请求成功'
  )
}

然后可以这样使用:

@SuccessMessage('用户操作成功')
@Controller('user')
export class UserController {
  @SuccessMessage('新增成功')
  @Post()
  create() {}

  @Get()
  findAll() {}
}

create() 返回“新增成功”,findAll() 继承“用户操作成功”。

元数据 Key 还可以用 Symbol 避免字符串冲突:

export const SUCCESS_MESSAGE_KEY = Symbol('SUCCESS_MESSAGE_KEY')

六、注册为全局拦截器

在根模块中使用 APP_INTERCEPTOR:

import { ClassSerializerInterceptor, Module } from '@nestjs/common'
import { APP_INTERCEPTOR } from '@nestjs/core'
import { ResponseInterceptor } from './common/interceptors/response.interceptor'

@Module({
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: ResponseInterceptor,
    },
    {
      provide: APP_INTERCEPTOR,
      useClass: ClassSerializerInterceptor,
    },
  ],
})
export class AppModule {}

通过依赖注入注册后,拦截器可以正常注入 Reflector 等依赖,也无需在每个 Controller 上重复添加 @UseInterceptors()。

多个拦截器的顺序

多个拦截器会形成嵌套调用,响应阶段按相反方向执行。统一响应包装与 ClassSerializerInterceptor 同时存在时,必须通过接口测试确认最终结果,尤其要检查:

  • @Exclude() 是否仍能隐藏密码。
  • 日期 @Transform() 是否仍然生效。
  • 包装对象是否被重复序列化。

不要只依赖对 providers 数组顺序的直觉,测试最终 JSON 才是可靠约束。

七、默认 message 的两种策略

最简单的策略是所有未声明接口都返回“请求成功”:

return customMessage ?? '请求成功'

也可以根据 HTTP 方法提供默认消息:

const DEFAULT_MESSAGES: Record<string, string> = {
  GET: '查询成功',
  POST: '新增成功',
  PATCH: '更新成功',
  PUT: '更新成功',
  DELETE: '删除成功',
}

private getSuccessMessage(context: ExecutionContext): string {
  const customMessage = this.reflector.getAllAndOverride<string>(
    SUCCESS_MESSAGE_KEY,
    [context.getHandler(), context.getClass()],
  )

  const request = context.switchToHttp().getRequest<Request>()
  return customMessage ?? DEFAULT_MESSAGES[request.method] ?? '请求成功'
}

HTTP 方法默认值适合 REST 风格稳定的项目;显式装饰器更准确,适合登录、导入、审核等无法由方法名推断语义的操作。

八、错误响应应该怎样处理

有些项目让 Service 返回特殊对象:

export interface ServiceErrorResult {
  error: true
  message: string
}

再由响应拦截器识别:

if (isServiceErrorResult(data)) {
  return {
    success: false,
    data: null,
    message: data.message,
  }
}

这种方式可以统一 JSON 外形,但它仍然是一次正常返回,HTTP 状态码通常还是 200。监控系统、网关和客户端无法准确区分冲突、未授权与服务器错误。

更符合 HTTP 语义的做法是在 Service 中抛出 NestJS 异常:

if (usernameExists) {
  throw new ConflictException('用户名已存在')
}

再由全局 Exception Filter 统一错误结构:

{
  "success": false,
  "data": null,
  "message": "用户名已存在"
}

职责边界可以概括为:

  • Interceptor:包装正常结果、增加响应元数据、记录耗时。
  • Exception Filter:转换异常、保留正确 HTTP 状态码、统一错误格式。
  • Service:执行业务规则,失败时抛出有语义的异常。

九、避免重复包装

统一响应后,Controller 不应再返回完整 envelope:

// 不推荐
return {
  success: true,
  data: user,
  message: '新增成功',
}

否则拦截器会得到:

{
  "success": true,
  "data": {
    "success": true,
    "data": {},
    "message": "新增成功"
  },
  "message": "请求成功"
}

如果文件下载、SSE 或第三方回调不适合包装,可以增加跳过元数据:

export const SKIP_RESPONSE_WRAP_KEY = Symbol('SKIP_RESPONSE_WRAP_KEY')
export const SkipResponseWrap = () => SetMetadata(SKIP_RESPONSE_WRAP_KEY, true)

拦截器读取该标记后直接 return next.handle()。

十、单元测试

拦截器属于横切逻辑,一处错误会影响全部接口,应至少测试默认消息、自定义消息和空返回值:

import { CallHandler, ExecutionContext } from '@nestjs/common'
import { Reflector } from '@nestjs/core'
import { lastValueFrom, of } from 'rxjs'

it('使用处理器声明的成功消息', async () => {
  const reflector = new Reflector()
  const interceptor = new ResponseInterceptor(reflector)
  const handler = () => undefined

  Reflect.defineMetadata(SUCCESS_MESSAGE_KEY, '新增成功', handler)

  const context = {
    getHandler: () => handler,
    getClass: () => class TestController {},
  } as unknown as ExecutionContext

  const next: CallHandler = {
    handle: () => of({ id: 1 }),
  }

  await expect(
    lastValueFrom(interceptor.intercept(context, next)),
  ).resolves.toEqual({
    success: true,
    data: { id: 1 },
    message: '新增成功',
  })
})

还应增加端到端测试,确认真实 HTTP 状态码、异常格式以及序列化拦截器共同工作时的最终响应。

十一、常见误区

1. 在 map 中捕获所有错误

map() 处理的是正常值。若要观测错误可以使用 catchError(),但不要无条件把异常转成 200 正常响应。

2. 直接使用 Reflect.getMetadata

原生 Reflect API 可以读取元数据,但 NestJS 的 Reflector 对方法级、类级覆盖和依赖注入更友好。

3. 使用 @Res() 手工返回

Controller 注入 @Res() 并调用 res.json() 后,通常绕过 Nest 标准响应处理链路,统一包装可能不再生效。

4. 文件流也统一包装

StreamableFile、SSE 和原始响应不是普通 JSON 数据,应提供明确的跳过机制。

小结

响应拦截器解决“所有正常接口怎样返回”,SetMetadata 解决“单个路由需要携带什么声明”,自定义装饰器则把底层元数据封装成清晰的业务语义。三者结合后,Controller 只返回业务数据,同时仍能用一行 @SuccessMessage('新增成功') 精确控制提示语。

在生产项目中,应继续用 Exception Filter 处理失败响应,并用端到端测试锁定状态码、响应结构和多个拦截器的执行效果。