NestJS 拦截器与跳过拦截器

用 NestJS 全局拦截器统一包装成功响应,并通过自定义装饰器为 Webhook、文件下载等接口跳过包装。

写 API 接口时,常见需求是让成功响应保持统一结构。比如业务方法返回用户信息:

{
  "user": "xxx",
  "imageUrl": "https://example.com/avatar.png"
}

接口返回时,希望统一包一层:

{
  "code": 0,
  "success": true,
  "data": {
    "user": "xxx",
    "imageUrl": "https://example.com/avatar.png"
  }
}

这类逻辑很适合放在 NestJS interceptor 里。拦截器可以在 handler 执行前后介入,也可以用 RxJS operator 改写 handler 的返回值。

English version: NestJS Interceptors and How to Skip Response Wrapping

统一成功响应

一个最基础的成功响应包装拦截器可以这样写:

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

interface ApiResponse<T> {
  code: number;
  success: true;
  data: T;
}

@Injectable()
export class ResponseWrapInterceptor<T>
  implements NestInterceptor<T, ApiResponse<T>>
{
  intercept(
    context: ExecutionContext,
    next: CallHandler<T>,
  ): Observable<ApiResponse<T>> {
    return next.handle().pipe(
      map((data) => ({
        code: 0,
        success: true,
        data,
      })),
    );
  }
}

这里的关键点是 next.handle() 返回的是一个 Observable。handler 原本返回的数据会进入这个流,map() 可以把它转换成统一结构。

注册成全局拦截器时,推荐使用 APP_INTERCEPTOR,这样拦截器仍然在 Nest 的依赖注入上下文里:

import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ResponseWrapInterceptor } from './response-wrap.interceptor';

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

这样大多数接口只需要返回业务数据,不需要每个 controller 都手写 { code, success, data }

错误响应更适合放在异常过滤器

有些代码会用另一个 interceptor 配合 catchError() 把异常也包装起来。这个做法能工作,但不一定是更清晰的边界。

成功响应包装属于“handler 正常返回后的数据转换”,很适合 interceptor。错误响应则是异常处理,NestJS 里更直接的工具是 exception filter。这样成功和失败两条链路更清楚,也不容易在 interceptor 里误吞异常。

一个简化的 HTTP 异常过滤器可以这样写:

import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';

@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();

    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    const message =
      exception instanceof HttpException
        ? exception.message
        : 'Internal server error';

    response.status(status).json({
      code: status,
      success: false,
      message,
    });
  }
}

全局注册可以放在 main.ts

const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new HttpExceptionFilter());
await app.listen(3000);

实际项目里还可以继续补充错误码映射、日志记录、请求 ID、业务异常基类等内容。核心原则是:成功响应用 interceptor 转换,异常响应用 filter 处理。

为什么需要跳过包装

全局拦截器的问题在于它会影响所有接口。但有些接口不能返回统一 JSON:

  • 微信、GitHub、Stripe 等 Webhook 可能要求返回固定文本或固定状态码。
  • 文件下载接口需要直接返回文件流。
  • 图片、二维码、CSV 导出等接口有自己的 Content-Type
  • 代理接口可能需要原样透传上游响应。

如果这些接口也被包成 { code, success, data },调用方就无法按协议识别响应。解决办法是给接口打一个“跳过包装”的标记,让拦截器读这个 metadata。

定义跳过包装装饰器

可以用 SetMetadata 定义一个装饰器:

import { SetMetadata } from '@nestjs/common';

export const SKIP_RESPONSE_WRAP = 'skipResponseWrap';

export const SkipResponseWrap = () => SetMetadata(SKIP_RESPONSE_WRAP, true);

然后在拦截器里注入 Reflector,同时读取 handler 和 controller 上的 metadata:

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { SKIP_RESPONSE_WRAP } from './skip-response-wrap.decorator';

interface ApiResponse<T> {
  code: number;
  success: true;
  data: T;
}

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

  intercept(
    context: ExecutionContext,
    next: CallHandler<T>,
  ): Observable<ApiResponse<T> | T> {
    const skip = this.reflector.getAllAndOverride<boolean>(
      SKIP_RESPONSE_WRAP,
      [context.getHandler(), context.getClass()],
    );

    if (skip) {
      return next.handle();
    }

    return next.handle().pipe(
      map((data) => ({
        code: 0,
        success: true,
        data,
      })),
    );
  }
}

context.getHandler() 对应当前路由方法,context.getClass() 对应 controller 类。用 getAllAndOverride() 的好处是装饰器既可以放在方法上,也可以放在整个 controller 上。

使用方式

比如微信消息推送要求服务端返回纯文本 success,就可以给这个接口加上 @SkipResponseWrap()

import { Body, Controller, HttpCode, Post } from '@nestjs/common';
import { SkipResponseWrap } from './skip-response-wrap.decorator';

@Controller('wechat')
export class WechatController {
  @Post('push')
  @HttpCode(200)
  @SkipResponseWrap()
  async handleWechatPush(@Body() data: unknown) {
    // 校验签名、处理消息、记录日志等
    return 'success';
  }
}

这样这个接口会返回纯字符串 success,不会被包装成:

{
  "code": 0,
  "success": true,
  "data": "success"
}

如果一个 controller 下所有接口都不需要包装,也可以把装饰器放在类上:

@SkipResponseWrap()
@Controller('files')
export class FilesController {}

需要注意的边界

NestJS 文档里有一个重要提醒:response mapping 不适用于直接使用 library-specific response strategy 的场景,也就是在 handler 里直接使用 @Res() 操作原始响应对象。

所以实践里可以按下面的规则分工:

  • 普通 JSON API:直接返回业务对象,让全局 interceptor 包装。
  • 固定协议响应:加 @SkipResponseWrap(),直接返回协议需要的内容。
  • 文件流或强控制响应头:加 @SkipResponseWrap(),必要时使用 @Res()StreamableFile
  • 错误响应:优先交给 exception filter,而不是在成功响应 interceptor 里混着处理。

这样做的好处是全局规则仍然简单,但特殊接口有明确出口。拦截器负责统一成功响应,装饰器负责声明例外,异常过滤器负责错误结构,三者的职责边界比较清楚。

扩展阅读