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_xxxtoken 注册到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 对应 |
|---|---|---|---|---|
| Middleware | NestMiddleware | 路由匹配前后都行 | 请求级预处理:CORS、日志、链路 ID | (缺口,第二步补) |
| Guard | CanActivate | 路由匹配之后、handler 之前 | 认证 / 鉴权决策 | JwtAuthGuard + PermissionGuard |
| Pipe | PipeTransform | handler 参数绑定时 | 参数转换或校验 | 全局 ValidationPipe |
| Interceptor | NestInterceptor | handler 前后各一次 | 响应变换、计时、缓存、记行为日志 | TransformInterceptor + LoggingInterceptor |
| ExceptionFilter | ExceptionFilter | 任何阶段抛异常时 | 异常 → 响应的统一映射 | 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.user 是 JwtAuthGuard 注入的。所以两个 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}`),
}),
);
}
}
和 TransformInterceptor 的 map 不同,tap 不改返回值、只做副作用——这是 RxJS 的设计:map 是转换、tap 是窥探。记日志、记耗时、写行为日志都属于副作用,一定用 tap 而不是 map,否则会让响应体悄悄带上日志字段。
思考:当 handler 抛异常时,TransformInterceptor 的 map 还会执行吗?不会。异常会让 next.handle() 的流进入 error 路径,map 是绑在 next 路径上的、不接 error。所以成功响应走拦截器的 map、失败响应走过滤器的 catch——两者必须同时存在,才能让成功和失败的响应结构都一致。这就是为什么 learnhub 同时注册了 TransformInterceptor 和 HttpExceptionFilter:一个管成功路径、一个管失败路径,少一个就有缺口。
第五步: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,是因为全局 ValidationPipe 的 transform: 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(';') 拼成中文字符串,错误提示立刻可读。
总结:成功响应靠 TransformInterceptor 的 map 包装、失败响应靠 HttpExceptionFilter 的 catch 包装,两者产出同一个外壳({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.user是JwtAuthGuard注入的——鉴权放前面、认证放后面,req.user就是undefined,每个非公开接口都会抛UnauthorizedException('未登录')。注册顺序即执行顺序,这条铁律要刻进肌肉记忆。APP_FILTER的顺序是反着的:异常出来时,后注册的先接(洋葱芯往外的逆序)。learnhub 只有一个全局过滤器,看不出差别;注册多个时记得是「洋葱外层先注册、内层后注册」。
为什么不直接 app.useGlobalGuards(new JwtAuthGuard())、要在 AppModule 用 APP_xxx?因为 useGlobalXxx 是在 main.ts 里 new 出来的、绕过了 IoC 容器,注入不了任何依赖。JwtAuthGuard 要用 JwtService、PermissionGuard 要用 UserService——必须走 APP_xxx 让 Nest 帮你注入。
winston 替换内置 Logger:上面的 LoggingInterceptor 用 new 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——包括 LoggingInterceptor、HttpExceptionFilter、各个 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 拼成了中文字符串,第六步讲的那个细节在真实响应里验证了一次。
这一章的成果
- 打开 learnhub 真实代码,吃透 5 种切面各自的职责:Middleware(请求级预处理)、Guard(认证 / 鉴权决策)、Pipe(参数校验 / 转换)、Interceptor(响应变换 / 计时)、ExceptionFilter(错误兜底)。
- 补齐 learnhub 唯一缺的那块:写了一个真实的 correlation-id + 请求日志 Middleware,挂到
AppModule.configure(consumer)里——learnhub 那个空configure的占位,这一章用真实代码填上。 - 看懂了全局注册的顺序为什么关键:
APP_GUARD注册顺序即执行顺序(先JwtAuthGuard后PermissionGuard)、APP_INTERCEPTOR多个按声明顺序穿洋葱、APP_FILTER反序接异常。 - 理解成功响应(
TransformInterceptor的map)和失败响应(HttpExceptionFilter的catch)必须同时存在,才能产出统一的{code, message, data}外壳。 - 看懂 winston 怎么靠
app.useLogger(...)全局替换内置 Logger,业务代码无感、底层三路输出(控制台 / 全量 / 错误)。
常见问题
useGlobalGuards(new Xxx())注入不了依赖:new是在 IoC 容器外创建的,注入不了。需要注入依赖(JwtService/UserService等)就改用APP_GUARD/APP_INTERCEPTOR/APP_FILTERprovider 注册到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覆盖了全局的。检查AppModule的APP_INTERCEPTOR是否还在。
下一章讲 文件上传——用 multer 接收单文件 / 多文件,做限制大小和类型、存到本地目录,最后能上传一张图片并通过 URL 访问。