跳转到主要内容

Nest 使用笔记

第五章:AOP 五件套——一次请求是怎么被层层处理的

打开 learnhub 的真实守卫、拦截器、过滤器,在 JWT 鉴权、统一响应、异常兜底这些生产代码里讲透 Middleware / Guard / Pipe / Interceptor / ExceptionFilter 的职责、执行顺序、注册顺序。learnhub 唯独没有 Middleware——这一章顺手补一个真实的 correlation-id 中间件,把最后一块拼图补上。

  • Nest
  • AOP

日志、鉴权、参数校验、统一响应、异常处理——这些逻辑每个接口都要、但都不属于业务本身。如果让它们写进每个 controller,会到处重复、改一处漏十处。Nest 借 AOP(面向切面) 的思路,把这类「横切关注点」抽成 5 种机制:Middleware、Guard、Pipe、Interceptor、ExceptionFilter,由框架透明地织进请求链路。这一章打开 learnhub 的真实守卫、拦截器、过滤器,在已经在生产里跑的代码里讲透它们的职责、执行顺序、注册顺序——还有 learnhub 唯独缺的那一块(Middleware),顺手补上。

先搞懂:AOP 是什么 / 为什么需要 / 企业级怎么用

AOP 是什么:把「和具体业务无关、但每个接口都要」的逻辑(日志、鉴权、统一响应、异常格式)抽成独立组件,框架在请求链路的固定位置自动调用它们。你在 controller 里写的是业务,AOP 组件在外面负责包裹:进业务前鉴权、出业务后包响应、出错兜底。

为什么需要它:没有 AOP 你也能写——每个 controller 头部手写 if (!req.headers.authorization) return 401、每个返回手动包成 {code, data}、每个 try/catch 手动格式化错误。问题不是写不出来,而是重复:50 个接口 50 份相同代码,改一处忘了 49 处。AOP 让这类逻辑只写一次、全局生效,业务代码回归纯粹。

企业级怎么用(下面每一条,这一章都会在 learnhub 里真做一遍):

  • 五件套各司其职,别混用:Middleware 做请求级预处理(CORS、日志、链路 ID)、Guard 做认证/鉴权决策、Pipe 做参数校验/转换、Interceptor 做响应变换和计时、ExceptionFilter 做错误兜底。
  • 全局生效用 APP_xxx token 注册到 AppModule 的 providers,注册顺序即执行顺序——learnhub 把 JwtAuthGuard 写在 PermissionGuard 前面,就是为了「先认证、再鉴权」。
  • 统一响应结构是拦截器的活、统一错误结构是过滤器的活——两者一前一后,对应成功路径和失败路径,缺一不可。
  • 日志要脱离控制台、按天切割分文件存——靠 winston 替换 Nest 内置 Logger,learnhub 在 main.ts 一行 useLogger 切换。

这一章你会做出什么

  • 打开 learnhub 的 JwtAuthGuard / PermissionGuard / TransformInterceptor / LoggingInterceptor / HttpExceptionFilter,逐个讲清楚为什么这么写。
  • 补齐 learnhub 唯一缺的那块:写一个真实的 correlation-id + 请求日志 Middleware,挂到 AppModule.configure(consumer) 里(教学补充,不动 learnhub 代码)。
  • 用一次真实请求的日志,亲眼看到五件套的执行顺序:Middleware → Guard → Interceptor(前) → Pipe → Handler → Interceptor(后),异常时 ExceptionFilter 兜底。
  • 理解全局 Guard / Interceptor / Filter 的注册顺序为什么不能乱,以及 winston 怎么替换内置 Logger。

第一步:五件套速览——和真实的执行顺序

切面实现触发时机干什么learnhub 对应
MiddlewareNestMiddleware路由匹配前后都行请求级预处理:CORS、日志、链路 ID(缺口,第二步补)
GuardCanActivate路由匹配之后、handler 之前认证 / 鉴权决策JwtAuthGuard + PermissionGuard
PipePipeTransformhandler 参数绑定时参数转换或校验全局 ValidationPipe
InterceptorNestInterceptorhandler 前后各一次响应变换、计时、缓存、记行为日志TransformInterceptor + LoggingInterceptor
ExceptionFilterExceptionFilter任何阶段抛异常时异常 → 响应的统一映射HttpExceptionFilter

请求穿过它们的真实顺序(第八步会亲眼看到):

请求进来

Middleware            (next() 之前)

Guard                 (认证 → 鉴权)

Interceptor · 前      (next.handle() 之前)

Pipe                  (@Body / @Param 校验、转换)

Controller handler    (业务方法)

Interceptor · 后      (next.handle() 的 map/tap)

Middleware            (next() 之后)

响应出去

任意阶段抛异常 → ExceptionFilter 接住,短路后面所有步骤

记忆口诀:Middleware → Guard → Interceptor(前) → Pipe → Handler → Interceptor(后) → ExceptionFilter(出错时)。下面每一节打开 learnhub 真实代码讲一件,第二步和第三步会穿插讲清「这块和它旁边那块怎么选」。

第二步:Middleware——learnhub 唯独这块空着,补一个

五件套里 learnhub 实现了四件,唯独 Middleware 是空的:

// learnhub/src/app.module.ts
export class AppModule implements NestModule {
  // 阶段1 占位:阶段18 会在这里注册全局/路由 Middleware【节18】
  configure(_consumer: MiddlewareConsumer) {}
}

这个空 configure 不是疏漏,是有意留的占位——learnhub 把鉴权交给了 Guard、把日志交给了 Interceptor,剩下需要 Middleware 做的事(CORS、body 解析)Nest 默认已经挂了。但作为教学,这一块得补全,否则五件套就少一角。

Middleware 干什么:在路由匹配前后做请求级的预处理。它是 Express 中间件的超集,签名和 Express 一模一样——(req, res, next)。最经典的用途是打请求日志、生成 correlation id(链路追踪用)、做 CORS、挂 body-parser。learnhub 没自己写 Middleware 的原因:日志已经交给 LoggingInterceptor,CORS 没开(部署时由 Nginx 处理),body 解析 Nest 默认挂好了。但有一个场景 Interceptor 干不了、必须用 Middleware:在请求最外层挂一个链路 ID,让后续所有日志(包括 Nest 内部日志)都能串起来

下面写一个真实的 correlation-id + 请求日志 Middleware(这是本章的教学补充代码,不写进 learnhub):

// 教学补充:src/middleware/correlation-id.middleware.ts(learnhub 没有这个文件)
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';

@Injectable()
export class CorrelationIdMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    // 1. 优先用上游传来的链路 ID(Nginx / API 网关通常会自己生成一个塞进 header)
    const id = (req.headers['x-request-id'] as string) || randomUUID();
    // 2. 挂到 req 上,后续 Logger / Interceptor / Service 都能拿到
    (req as any).correlationId = id;
    // 3. 回写到响应头,前端/调用方能在响应里看到这条请求的 ID,报错时一报一个准
    res.setHeader('x-request-id', id);

    next(); // 把控制权交给下一个 Middleware / 路由处理器

    // next() 之后的代码在响应发出后执行(类似 Interceptor 的「后」半段)
    // 这里也可以打「请求结束」日志,但耗时类日志更适合放 LoggingInterceptor
  }
}

然后在 AppModule.configure(consumer) 里挂上去(同样是教学示例,对照 learnhub 那个空 configure):

// 教学补充:把空 configure 改成下面这样
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { CorrelationIdMiddleware } from './middleware/correlation-id.middleware';

export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(CorrelationIdMiddleware)
      .forRoutes('*'); // 所有路由;也可以 forRoutes('posts', 'auth', ...) 指定
  }
}

consumer.apply(X).forRoutes(Y) 是 Nest 注册 Middleware 的固定写法:apply 接收一个或多个 Middleware 类,forRoutes 指定作用范围('*' 表示所有路由,也可以写具体的 controller 或路径模式)。

注意:Middleware 和 Guard 看起来都「在业务之前」,区别在于职责和能拿到的东西。Middleware 跑在 Express 层,对路由信息无感,它拿不到「这个 handler 要什么权限」这类 Nest 元数据;Guard 跑在路由匹配之后,能通过 Reflector 读 controller 上的 @Public() / @RequirePermission() 元数据,做出鉴权决策。简记:Middleware 做和路由无关的事(CORS、日志、链路 ID),Guard 做和路由有关的决策(能不能访问)。要决策用 Guard,要预处理用 Middleware——这是 AOP 里最容易混的一对,看准「这件事需要读 controller 元数据吗」就能立刻判出来。

第三步:Guard——认证 + 鉴权,两道关

learnhub 把权限切成两道独立的 Guard,全部全局注册。第一道是 JwtAuthGuard,做认证(你是谁):

// learnhub/src/core/guards/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    private readonly jwtService: JwtService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    // 1. @Public 白名单:标了 @Public() 的接口直接放行
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) return true;

    // 2. 取 Authorization: Bearer <token>
    const req = this.getRequest(context);
    const auth = req.headers['authorization'] || '';
    if (!auth.startsWith('Bearer ')) {
      throw new UnauthorizedException('缺少登录凭证');
    }
    const token = auth.slice('Bearer '.length).trim();

    // 3. 验签;失败抛 401
    let payload: RequestUser;
    try {
      payload = await this.jwtService.verifyAsync(token);
    } catch {
      throw new UnauthorizedException('登录凭证无效或已过期');
    }

    // 4. 把用户挂到 request.user,后面 PermissionGuard / @CurrentUser 都用它
    req.user = { userId: payload.userId, username: payload.username };
    return true;
  }
}

CanActivate 是 Guard 的接口,只有一个 canActivate(context) 方法,返回 boolean(或 Promise<boolean> / Observable<boolean>)。返回 true 放行,返回 false Nest 直接抛 ForbiddenException(403);更常见的做法是直接抛带语义的异常(这里抛 UnauthorizedException → 401),错误信息更准确。ExecutionContext 是 Guard / Interceptor / Pipe 共用的上下文对象,context.switchToHttp().getRequest() 拿到 Express 的 Request,WS 和 GraphQL 各有各的取法(learnhub 的 getRequest 私有方法就同时兼容了 HTTP 和 GraphQL)。

第二道是 PermissionGuard,做鉴权(你能干这件事吗):

// learnhub/src/core/guards/permission.guard.ts
async canActivate(context: ExecutionContext): Promise<boolean> {
  // @Public 接口放行
  if (isPublic) return true;

  // 接口要求的权限点(@RequirePermission('post:create', ...));没有则视为「仅需登录」
  const required = this.reflector.getAllAndOverride<string[] | undefined>(PERMISSIONS_KEY, [
    context.getHandler(),
    context.getClass(),
  ]);
  if (!required || required.length === 0) return true;

  const req = context.switchToHttp().getRequest();
  if (!req.user) throw new UnauthorizedException('未登录'); // JwtAuthGuard 应该已经注入了

  // 查用户、合并所有角色的权限点
  const user = await this.userService.findById(req.user.userId);
  const owned = new Set<string>();
  for (const role of user.roles ?? []) {
    for (const p of role.permissions ?? []) owned.add(p.name);
  }

  // 要求的权限点必须【全部】拥有
  const ok = required.every((p) => owned.has(p));
  if (!ok) throw new ForbiddenException(`权限不足,需要:${required.join(', ')}`);
  return true;
}

这里有一个隐含依赖PermissionGuard 直接读 req.user,而 req.userJwtAuthGuard 注入的。所以两个 Guard 必须先认证、后鉴权——这正是第七步要讲的注册顺序。JWT 本身怎么签发、怎么续期是第十一章的事,RBAC 的角色/权限/用户三张表怎么设计是第十二章的事,这一章只关注 Guard 这个机制:写一个 CanActivate、用 Reflector 读元数据、返回 boolean 或抛异常、全局注册即生效。

第四步:Interceptor——统一响应 + 请求计时

learnhub 的全局拦截器有两个,干的活完全不同。

TransformInterceptor——把每个成功响应包成 {code, message, data}

// learnhub/src/core/interceptors/transform.interceptor.ts
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler<T>): Observable<any> {
    // 只包装 HTTP 响应;GraphQL 由 Apollo 自己格式化,不能套 {code,message,data}
    if (context.getType() !== 'http') return next.handle();
    return next.handle().pipe(
      // data 可能是 undefined(如 204),包装时保持 data 字段
      map((data) => ({
        code: 200,
        message: 'success',
        data,
      })),
    );
  }
}

NestInterceptor 的接口也只有一个 intercept(context, next) 方法。和 Guard 的关键差别是:它包在 handler 的返回值外面——next.handle() 返回一个 RxJS Observable,handler 的返回值会从这个流里冒出来。pipe(map(...)) 在流上挂一个转换,handler 返回什么、map 就拿到什么、再返回什么给前端。这就是「拦截器能改响应、Guard 不能」的原因:Guard 在 handler 之前就跑完了,根本看不到返回值;Interceptor 横跨 handler 前后,前半段在 next.handle() 之前、后半段在 pipe(...) 里。

为什么统一响应结构要靠 Interceptor:每个 controller 只管返回业务数据(一个 Post、一个 PageResult),由 TransformInterceptor 在最外层统一包成 {code, message, data}。前端拿到的东西长一个样:成功 {code:200, message:'success', data:{...}}、失败 {code:404, message:'帖子不存在', data:null}(失败那条是第六步的过滤器产的)。如果不用拦截器,每个 controller 都要手动写 return {code:200, message:'success', data: ...},50 个接口 50 份重复。

LoggingInterceptor——用 tap 记录每个请求的方法、路径、耗时:

// learnhub/src/core/interceptors/logging.interceptor.ts
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger('HTTP');

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    if (context.getType() !== 'http') return next.handle();
    const req = context.switchToHttp().getRequest();
    const { method, url } = req;
    const now = Date.now();

    return next.handle().pipe(
      tap({
        next: () => this.logger.log(`${method} ${url} - ${Date.now() - now}ms`),
        // 异常路径也会经过这里(tap.error),顺手记一笔
        error: (err) =>
          this.logger.warn(`${method} ${url} - ${Date.now() - now}ms - ERR ${err?.message ?? err}`),
      }),
    );
  }
}

TransformInterceptormap 不同,tap 不改返回值、只做副作用——这是 RxJS 的设计:map 是转换、tap 是窥探。记日志、记耗时、写行为日志都属于副作用,一定用 tap 而不是 map,否则会让响应体悄悄带上日志字段。

思考:当 handler 抛异常时,TransformInterceptormap 还会执行吗?不会。异常会让 next.handle() 的流进入 error 路径,map 是绑在 next 路径上的、不接 error。所以成功响应走拦截器的 map、失败响应走过滤器的 catch——两者必须同时存在,才能让成功和失败的响应结构都一致。这就是为什么 learnhub 同时注册了 TransformInterceptorHttpExceptionFilter:一个管成功路径、一个管失败路径,少一个就有缺口。

第五步:Pipe——全局 ValidationPipe(+ 内置 ParseIntPipe 对照)

learnhub 全局挂的是 Nest 内置的 ValidationPipe(第二章细讲过用法,这里看它在五件套图里的位置):

// learnhub/src/main.ts
app.useGlobalPipes(
  new ValidationPipe({
    transform: true,            // 把普通对象转成 DTO 类实例
    whitelist: true,            // 去掉 DTO 里没声明的字段
    forbidNonWhitelisted: true, // 出现未声明字段直接报错
  }),
);

PipeTransform 的接口是 transform(value, metadata):拿到参数值、返回转换后的值(或抛异常)。注意它和 Interceptor 的差别:Pipe 跑在 handler 参数绑定时,只针对 @Body() / @Param() / @Query() 这类参数,不能改 handler 的最终返回值;Interceptor 跑在 handler 前后、能看到完整响应。所以校验请求体用 Pipe、改响应用 Interceptor。

learnhub 没有自己写 PipeTransform 类——全局 ValidationPipe + DTO 上的 class-validator 装饰器已经覆盖了所有校验场景。但 Nest 内置的 Pipe 还有几个常用的,最典型是 ParseIntPipe,把字符串参数转成数字、转不动直接抛 400:

// 内置 Pipe 的典型用法
@Get(':id')
detail(@Param('id', ParseIntPipe) id: number) {  // 'abc' → 400;'5' → 5
  return this.postService.findOne(id);
}

learnhub 的 PostController.detail 没显式加 ParseIntPipe,是因为全局 ValidationPipetransform: true 已经做了类型转换(DTO 里声明 id: number 就自动转)。这个例子只是让你看到 Pipe 的转换型用法——校验型(ValidationPipe)、转换型(ParseIntPipe),都属于同一机制。如果哪天需要自定义(比如「把传进来的手机号统一格式化」),写一个 @Injectable() class XxxPipe implements PipeTransform 即可。

第六步:ExceptionFilter——错误兜底

TransformInterceptor 把成功响应包成 {code, message, data},失败路径谁来管?HttpExceptionFilter

// learnhub/src/core/filters/http-exception.filter.ts
@Catch() // 不传参 = 捕获所有异常
export class HttpExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger(HttpExceptionFilter.name);

  catch(exception: unknown, host: ArgumentsHost) {
    // 本过滤器只处理 HTTP;GraphQL / WS 等交给各自的默认错误处理
    if (host.getType() !== 'http') {
      throw exception;
    }

    const ctx = host.switchToHttp();
    const response = ctx.getResponse();

    // 大多数业务异常是 HttpException;非 HttpException(如数据库错误)按 500 处理
    const isHttp = exception instanceof HttpException;
    const status = isHttp ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;

    let message = '服务器内部错误';
    if (isHttp) {
      const res = exception.getResponse();
      if (typeof res === 'string') {
        message = res;
      } else if (typeof res === 'object' && res !== null) {
        const r = res as any;
        // ValidationPipe 的 message 是数组:['username must be a string', ...]
        if (Array.isArray(r.message)) {
          message = r.message.join(';');
        } else {
          message = r.message || message;
        }
      }
    } else {
      // 非预期异常打完整堆栈,方便排查
      this.logger.error(exception instanceof Error ? exception.stack : String(exception));
    }

    response.status(status).json({
      code: status,
      message,
      data: null,
    });
  }
}

@Catch() 不传参表示捕获所有异常——业务抛的 HttpException、TypeORM 抛的 QueryFailedError、JS 运行时抛的 TypeError 都接得住。如果只写 @Catch(HttpException),非 HTTP 异常就会冒到 Nest 默认处理器,返回结构和你的过滤器不一致——这是初学最常踩的坑,learnhub 一开始就 @Catch() 兜底。

第二个值得注意的点是 r.message 可能是数组ValidationPipe 校验失败抛的 BadRequestException,它的 response.message['username must be a string', 'password must be longer than 6 characters'] 这种数组(每个字段一条)。直接 JSON.stringify 返回前端,用户看到一坨数组、根本读不动。learnhub 在这里 join(';') 拼成中文字符串,错误提示立刻可读。

总结:成功响应靠 TransformInterceptormap 包装、失败响应靠 HttpExceptionFiltercatch 包装,两者产出同一个外壳{code, message, data}),前端就能用一套逻辑处理所有响应。少了任何一边,响应结构就会出现「成功一种格式、失败另一种格式」的撕裂。

第七步:全局注册——APP_FILTER / APP_INTERCEPTOR / APP_GUARD 的顺序与 winston 日志

到目前为止讲了 5 种切面各自的写法,但怎么让它们全局生效才是企业级的关键。learnhub 在 AppModule 的 providers 里用 APP_FILTER / APP_INTERCEPTOR / APP_GUARD 三个 token 把它们挂上去:

// learnhub/src/app.module.ts
providers: [
  // 全局异常过滤器:统一错误结构【节22】
  { provide: APP_FILTER, useClass: HttpExceptionFilter },
  // 全局拦截器:统一返回结构 + 请求日志 + 行为日志(按声明顺序执行)
  { provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
  { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
  { provide: APP_INTERCEPTOR, useClass: BehaviorLogInterceptor },
  // 全局 Guard。顺序即执行顺序:先认证后鉴权【节70】
  { provide: APP_GUARD, useClass: JwtAuthGuard },
  { provide: APP_GUARD, useClass: PermissionGuard },
],

几个要点新人很容易看漏:

  • 同一个 token 多次注册就是按顺序生效APP_INTERCEPTOR 注册了 3 个,请求会按 TransformInterceptor → LoggingInterceptor → BehaviorLogInterceptor 顺序穿过「前」半段、再倒序穿过「后」半段(洋葱模型);APP_GUARD 注册了 2 个,按 JwtAuthGuard → PermissionGuard 顺序执行。
  • APP_GUARD 的顺序不能反PermissionGuard 依赖 req.user,而 req.userJwtAuthGuard 注入的——鉴权放前面、认证放后面,req.user 就是 undefined,每个非公开接口都会抛 UnauthorizedException('未登录')。注册顺序即执行顺序,这条铁律要刻进肌肉记忆
  • APP_FILTER 的顺序是反着的:异常出来时,后注册的先接(洋葱芯往外的逆序)。learnhub 只有一个全局过滤器,看不出差别;注册多个时记得是「洋葱外层先注册、内层后注册」。

为什么不直接 app.useGlobalGuards(new JwtAuthGuard())、要在 AppModuleAPP_xxx?因为 useGlobalXxx 是在 main.tsnew 出来的、绕过了 IoC 容器,注入不了任何依赖。JwtAuthGuard 要用 JwtServicePermissionGuard 要用 UserService——必须走 APP_xxx 让 Nest 帮你注入。

winston 替换内置 Logger:上面的 LoggingInterceptornew Logger('HTTP') 打日志,这个 Logger 默认是 Nest 内置的、只往控制台写。生产上要按天切割、分文件、分级别存,所以 learnhub 在 main.ts 一行切换到 winston:

// learnhub/src/main.ts
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // 用 winston 替换内置 Logger(由 LoggerModule 配置三路输出)
  app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER));
  // ...
}

切换之后,整个应用里所有 new Logger(...) 都走 winston——包括 LoggingInterceptorHttpExceptionFilter、各个 service 里的日志。winston 的具体配置在 LoggerModule 里,三路输出:

// learnhub/src/modules/logger/logger.module.ts
WinstonModule.forRoot({
  transports: [
    // ① 控制台:开发期彩色 debug 级,生产 info 级
    new winston.transports.Console({
      format: consoleFmt,
      level: isProd ? 'info' : 'debug',
    }),
    // ② 全量日志按天切割,保留 14 天
    new DailyRotateFile({
      filename: 'logs/learnhub-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      maxSize: '20m',
      maxFiles: '14d',
      level: 'info',
      format: json,
    }),
    // ③ 错误日志单独按天切割,保留 30 天(方便单独收集 / 告警)
    new DailyRotateFile({
      filename: 'logs/learnhub-error-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      maxSize: '20m',
      maxFiles: '30d',
      level: 'error',
      format: json,
    }),
  ],
}),

app.useLogger(...) 是个全局替换,业务代码完全无感——new Logger('HTTP') 的调用方式不变,底层换成了 winston。错误日志单独一份文件是生产上的常规做法:错误量小但重要,单独收集方便接告警系统(ELK、Loki 都能按文件分通道)。日志体系第一章第六步里已经预告过,这里看到的是它的完整落地。

第八步:跑起来,亲眼看请求穿过五件套

光讲理论不够,跑一次最直观。先起 learnhub(种子账号 admin / admin123456,登录机制第十一章讲,这里直接用):

cd learnhub
docker compose -f docker/docker-compose.yml up -d mysql redis
npm run start:dev

带 token 请求一个需要权限的接口(发帖):

# 1. 登录拿 token
curl -X POST http://localhost:3000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"admin123456"}'
# {"code":200,"message":"success","data":{"accessToken":"eyJ...","refreshToken":"eyJ..."}}

# 2. 带 token 发帖
curl -X POST http://localhost:3000/api/v1/posts \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJ..." \
  -d '{"title":"五件套穿越记","content":"...","tagIds":[1]}'
# {"code":200,"message":"success","data":{"id":1,"title":"五件套穿越记",...}}

终端里(start:dev 的输出)能看到这次请求穿越的痕迹:

[Nest] POST /api/v1/posts - 38ms      ← LoggingInterceptor 在 handler 之后 tap 出来

请求成功的链路:CorrelationIdMiddleware(如果你补了第二步)→ JwtAuthGuard 认证通过 → PermissionGuard 鉴权通过 → TransformInterceptor 前 → ValidationPipe 校验 DTO → PostController.create 执行业务 → TransformInterceptor 后(包成 {code, message, data})→ LoggingInterceptor 记耗时 → 响应出去。

再试两个边界,把失败路径也走一遍:

# 不带 token:JwtAuthGuard 抛 401,后面全部短路
curl -X POST http://localhost:3000/api/v1/posts \
  -H "Content-Type: application/json" \
  -d '{"title":"试试"}'
# {"code":401,"message":"缺少登录凭证","data":null}   ← HttpExceptionFilter 接住 UnauthorizedException

# 带 token 但参数非法:ValidationPipe 抛 400
curl -X POST http://localhost:3000/api/v1/posts \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJ..." \
  -d '{"content":"没标题"}'
# {"code":400,"message":"title must be a string;title should not be empty","data":null}

两次失败响应都是 HttpExceptionFilter 兜底——map(成功路径)和 catch(失败路径)产出同一个 {code, message, data} 外壳。第二条还能看到过滤器把 ValidationPipe 的数组 message 拼成了中文字符串,第六步讲的那个细节在真实响应里验证了一次。

这一章的成果

  1. 打开 learnhub 真实代码,吃透 5 种切面各自的职责:Middleware(请求级预处理)、Guard(认证 / 鉴权决策)、Pipe(参数校验 / 转换)、Interceptor(响应变换 / 计时)、ExceptionFilter(错误兜底)。
  2. 补齐 learnhub 唯一缺的那块:写了一个真实的 correlation-id + 请求日志 Middleware,挂到 AppModule.configure(consumer) 里——learnhub 那个空 configure 的占位,这一章用真实代码填上。
  3. 看懂了全局注册的顺序为什么关键:APP_GUARD 注册顺序即执行顺序(先 JwtAuthGuardPermissionGuard)、APP_INTERCEPTOR 多个按声明顺序穿洋葱、APP_FILTER 反序接异常。
  4. 理解成功响应(TransformInterceptormap)和失败响应(HttpExceptionFiltercatch)必须同时存在,才能产出统一的 {code, message, data} 外壳。
  5. 看懂 winston 怎么靠 app.useLogger(...) 全局替换内置 Logger,业务代码无感、底层三路输出(控制台 / 全量 / 错误)。

常见问题

  • useGlobalGuards(new Xxx()) 注入不了依赖new 是在 IoC 容器外创建的,注入不了。需要注入依赖(JwtService / UserService 等)就改用 APP_GUARD / APP_INTERCEPTOR / APP_FILTER provider 注册到 AppModule
  • 多个 Guard / Interceptor 谁先执行APP_GUARD / APP_INTERCEPTOR 按 providers 里声明的顺序执行;APP_FILTER反序(后注册的先接异常)。learnhub 把 JwtAuthGuard 写在 PermissionGuard 前,靠的就是这条规则保证「先认证、再鉴权」。
  • @Catch(HttpException) 捕不到数据库错误@Catch(HttpException) 只接 HttpException 子类,TypeORM 的 QueryFailedError 这类会漏到 Nest 默认处理器,返回结构和你过滤器不一致。生产用 @Catch() 不传参兜底所有异常。
  • ValidationPipe 错误返回数组难读:那是 class-validator 抛的 BadRequestException.response.message 是数组(每个字段一条),过滤器里 Array.isArray(r.message) ? r.message.join(';') : ... 拼成字符串。
  • Interceptor 和 Middleware 怎么选:和路由无关的请求级预处理(CORS、body 解析、链路 ID)用 Middleware;要看 handler 元数据、要改响应、要计 handler 耗时用 Interceptor。决策(能不能访问)用 Guard,别混进 Middleware。
  • 响应有时是 {code,data}、有时是裸对象:漏注册了 TransformInterceptor、或者某个 controller 自己 @UseInterceptors 覆盖了全局的。检查 AppModuleAPP_INTERCEPTOR 是否还在。

下一章讲 文件上传——用 multer 接收单文件 / 多文件,做限制大小和类型、存到本地目录,最后能上传一张图片并通过 URL 访问。