跳转到主要内容

Nest 使用笔记

第二章:参数接收与校验——ValidationPipe、DTO 与自定义装饰器

打开 learnhub 的 main.ts 和真实 DTO,讲透全局 ValidationPipe 三个选项各自做什么、class-validator 怎么用、为什么 query 参数全是字符串、PageDto 计算属性为什么依赖 transform、自定义 @CurrentUser / @Public 装饰器的工作原理,以及为什么永远不该把 Entity 直接当 @Body 接收。每段代码都在 learnhub 里指得到文件。

  • Nest
  • 入门
  • 校验

第一章里 /hello?name=小明 这种接口是玩具——name 拿到什么就用什么,前端传空字符串、传一万字、传个 <script> 都照单全收。真实项目里这一关过不去:进业务代码之前必须先校验「字段齐不齐、类型对不对、长度合不合规」。这一章把第一章 main.ts 里第 ④ 件全局配置——ValidationPipe——在 learnhub 的真实代码里彻底展开。

先搞懂:参数从哪来、为什么必须校验

HTTP 协议层面,所有从 URL、query string、header 来的参数本质都是字符串,body 如果是 JSON 也只是「字符串里碰巧长得像数字」。哪怕前端写 ?page=1,到 Nest 里拿到的也是 "1" 这个字符串,不是数字 1。这是后面整章的根因——不解决它,类型注解就是骗自己。

校验这件事有三种位置可以放,各有利弊:

  • 写在 controller 里if (!dto.title) throw new BadRequestException()。直观,但每个接口都要重复写,业务代码一半在if-校验、一半在干活。
  • 写在 service 里:和业务逻辑混在一起,且校验失败时已经进了业务层,错误返回不统一。
  • 写在 DTO + 全局管道里(learnhub 的做法):规则用装饰器声明在 DTO 上,全局 ValidationPipe 在进 controller 之前自动执行——非法请求根本到不了 handler,业务代码只看见干净合法的数据。

第三种是 Nest 的标准做法。它的代价是你要熟 class-validator 的装饰器和 class-transformer 的转换规则,但收益是校验和业务彻底解耦、规则和接口契约写在一起、新接口零成本继承。

这一章在 learnhub 里要讲透的东西:全局 ValidationPipe 三个选项各自为什么开、class-validator 装饰器怎么用、query 字符串陷阱怎么绕、分页 DTO 的计算属性为什么依赖 transform、嵌套对象怎么校验、以及 @CurrentUser / @Public 这种自定义装饰器背后到底干了什么。

这一章你会做出什么

  • 看懂 learnhub main.tsValidationPipe 的三个选项,知道关掉任何一个会出什么事。
  • 在真实 CreatePostDto / QueryPostDto / PageDto 里吃透 class-validator + class-transformer 的常用装饰器。
  • 掌握五种取参方式(@Param / @Query / @Body / @Headers / @Ip)和它们的真实类型。
  • 看懂自定义 @CurrentUser@Public 装饰器的实现,为第十一章 JWT 做铺垫。
  • 明白「DTO 永远不和 Entity 混用」这条纪律背后的设计压力。

前置:第一章已经跑过 learnhub 的 main.ts,对全局管道的位置有印象。

第一步:全局 ValidationPipe——三个选项各做什么

learnhub 的启动里只挂了一个全局管道,但带了三个选项:

// learnhub/src/main.ts
app.useGlobalPipes(
  new ValidationPipe({
    transform: true,
    whitelist: true,
    forbidNonWhitelisted: true,
  }),
);

这一小段是整个校验体系的总开关。把三个选项一个个拆开看,才知道关掉哪一个会出什么事。

transform: true——把进来的普通对象「实例化」成 DTO 类的实例。HTTP body 经过 JSON 解析后是个普通对象(Object.create(null){} 的字面量),它没有原型上的 getter。这一点对 learnhub 的 PageDto 是命门:PageDto 上有 get skip() / get take() 两个计算属性,如果 body 没被转成 PageDto 实例,q.skip 就拿不到值(不是 undefined,是直接没这个属性)。transform 还顺手做了一件关键事——按 DTO 上的 @Type(() => Number) 把字符串转成数字(第四步细讲)。关掉 transform,DTO 里的计算属性全废、@Type 也全失效。

whitelist: true——请求体里多出来的字段(DTO 没声明的)会被自动剥掉。比如 CreatePostDto 只声明了 title / content / tagIds,前端却传了 { title, content, tagIds, role: "admin" }role 会被静默丢掉,handler 拿到的 dto 里没有它。这是反 mass-assignment 的第一道闸——防止前端通过「多塞字段」篡改不该改的列。

forbidNonWhitelisted: true——比 whitelist 更狠:多出来的字段不是静默丢,而是直接 400 拒绝,并把多余字段名塞进错误信息返回。learnhub 把它打开,等于在接口契约上画了条硬线:「DTO 里没声明的字段,一律视为非法请求」

思考forbidNonWhitelisted 对 learnhub 这种公开 API 是对的——前端写错了立刻暴露,不留下「我传了字段被静默吞掉、于是排查三天」的坑。但内部服务间调用(比如微服务、定时任务调接口)有时候反而要关掉它:内部调用方经常先于服务端升级、带了新字段,如果后端用 forbidNonWhitelisted,新字段一旦出现就直接 400,内部调用全部失败。规则是:对外用严格、对内看团队节奏。learnhub 是单体后端对外,所以全开。

还有一个选项 learnhub 没开但值得知道:disableErrorMessages: false(默认就是 false)。错误信息在开发期有用,生产期把每条校验错误都序列化返回会有性能和信息泄露的小代价——大流量服务会显式设成 true,只返回「校验失败」不返回细节。learnhub 没开是因为它在内部教学阶段,错误细节比性能更重要。

第二步:五种取参方式——@Param / @Query / @Body / @Headers / @Ip

把 learnhub 帖子控制器里所有取参装饰器集中看一遍:

// learnhub/src/modules/post/post.controller.ts
@Controller({ path: 'posts', version: '1' })
export class PostController {
  constructor(private readonly postService: PostService) {}

  @Post()
  @RequirePermission('post:create')
  create(@Body() dto: CreatePostDto, @CurrentUser() user: RequestUser): Promise<PostEntity> {
    return this.postService.create(dto, user.userId);
  }

  @Public()
  @Get()
  list(@Query() q: QueryPostDto): Promise<PageResult<PostEntity>> {
    return this.postService.findMany(q);
  }

  @Public()
  @Get('search')
  search(
    @Query('q') q: string,
    @Query('page') page = 1,
    @Query('size') size = 10,
  ) {
    return this.postService.search(q ?? '', Number(page), Number(size));
  }

  @Public()
  @Get(':id')
  detail(@Param('id') id: number): Promise<PostEntity> {
    return this.postService.findOne(id);
  }

  @Patch(':id')
  update(
    @Param('id') id: number,
    @Body() dto: UpdatePostDto,
    @CurrentUser() user: RequestUser,
  ): Promise<PostEntity> {
    return this.postService.update(id, dto, user.userId);
  }
}

五种取参方式按出现频率排:

  • @Body():取请求体。create(@Body() dto: CreatePostDto) 把整个 body 映射成 DTO 实例(第一步的 transform 干这件事)。也可以 @Body('field') 取单个字段,但那样就丢了 DTO 的整体校验,learnhub 里没有这种用法。
  • **@Query() / @Query('field')**:取 URL query string。list(@Query() q: QueryPostDto) 是把所有 query 参数整体映射成 DTO(?page=1&pageSize=10&keyword=... 直接落到 DTO 字段上),这种写法配合 DTO 校验最干净。search 里用 @Query('q') 是取单字段——因为 search 的参数太简单(就 q/page/size),单独写个 DTO 划不来。
  • @Param('id'):取路径参数(/posts/42 里的 42)。注意:路径参数也是字符串,detail(@Param('id') id: number) 这个 number 类型注解能成立,是因为全局 ValidationPipetransform 开着——它会在管道里把 "42" 转成 42。关掉 transformid 实际是字符串 "42",类型注解骗过编译器、骗不过运行时,传给 findOne(42) 就在 TypeORM 那一层出问题。
  • @Headers('accept-language'):取请求头。learnhub 的 i18n 演示接口用了它:
// learnhub/src/modules/i18n/i18n.controller.ts
@Public()
@Get('greet')
greet(@Headers('accept-language') lang?: string) {
  const resolved = this.i18n.resolve(lang);
  return { lang: resolved, msg: this.i18n.t(resolved, 'greet.hello') };
}

header 名大小写无关——Nest 用 Express/Fastify 的 header 取值,内部统一小写。不传参数 @Headers() 拿到整个 header 对象,但大多数场景你只需要一两个。

  • @Ip():取客户端 IP。learnhub 当前没有直接用,常见场景是限流、风控、日志埋点。Nest 内部读的是 request.ip,在反向代理(Nginx)后面要配合 app.set('trust proxy', ...) 才能拿到真实 IP,否则拿到的是代理的 IP——这是部署期才会踩的坑,先记住。

@Session() 取 session 对象,learnhub 用 JWT 无状态认证(第十一章),所以整个项目里没有 session 用法。如果项目走 cookie-session 那套,才会在 session 里放登录态。

第三步:DTO 的写法——class-validator 装饰器巡览

打开 CreatePostDto,逐装饰器看真实项目里到底用了哪些:

// learnhub/src/modules/post/dto/create-post.dto.ts
export class CreatePostDto {
  @ApiProperty({ example: 'TypeORM 三种关系怎么记' })
  @IsString()
  @Length(1, 100)
  title: string;

  @ApiProperty({ example: '一对多看 owning side...' })
  @IsString()
  @IsNotEmpty()
  content: string;

  @ApiPropertyOptional({ type: [Number], description: '标签 ID 列表(可选)' })
  @IsOptional()
  @IsArray()
  @ArrayNotEmpty()
  @IsInt({ each: true })
  @Type(() => Number)
  tagIds?: number[];
}

几个关键点,按装饰器顺序看:

  • @IsString():先确保是字符串。class-validator 的规则是「按声明顺序执行,一条不过就这个字段直接挂掉」。title@IsString()@Length() 前面——如果传了 123(数字),先挂在 @IsString,错误信息是「必须是字符串」;写反了,会先挂在 @Length,错误信息不知所云。
  • @Length(1, 100):字符串长度上下界(含端点)。class-validator 还有 @MinLength / @MaxLength 各管一头,@Length 是一次性两头都管。learnhub 给标题 1–100,是和 post.entity.ts@Column({ length: 100 }) 对齐的——DTO 的长度上限必须和数据库列长度一致,否则校验过了、写库时 MySQL 截断或报错,错误就会越过校验层泄到数据层。
  • @IsNotEmpty():拒绝空字符串 ""。注意它和 @IsString() 的关系——单独 @IsString() 是允许 "" 的,因为空字符串也是合法字符串。发帖内容不能是空字符串,所以加 @IsNotEmpty()
  • @IsOptional():字段不存在时跳过后续所有校验。tagIds? 是可选的——不传走 @IsOptional 放行,传了就必须满足 @IsArray / @ArrayNotEmpty / @IsInt({ each: true })注意@IsOptional 检查的是 undefined(字段不存在),不是 null。前端传 tagIds: null 不会被 @IsOptional 放行,会被 @IsArray 拒。如果想接受 null,要写 @IsOptional() + @ValidateIf((o) => o.tagIds != null)
  • @IsArray() + @ArrayNotEmpty():先确认是数组、再确认非空。learnhub 不允许发帖时传一个空标签数组(要么不传、要么至少一个),所以 @ArrayNotEmpty()。如果业务允许空数组,去掉它即可。
  • @IsInt({ each: true }):数组里每个元素都得是整数。each: true 是 class-validator 的「数组遍历开关」,对 @IsString / @IsNumber 等装饰器都生效。
  • @Type(() => Number):这条是 class-transformer 的,不是 class-validator 的——但它和 @IsInt({ each: true }) 必须配套出现,下一节展开。

@ApiProperty / @ApiPropertyOptional 是 Swagger 用的,给文档生成提供示例和描述,不参与运行时校验。它们和校验装饰器写在同一个字段上、看起来混在一起,但功能完全分离。Swagger 第十四章细讲。

第四步:query 参数的字符串陷阱——@Type(() => Number) 怎么救场

这是上一章里 transform: true 选项真正发挥作用的地方。看 QueryPostDto

// learnhub/src/modules/post/dto/query-post.dto.ts
export class QueryPostDto extends PageDto {
  @ApiPropertyOptional({ description: '按标签过滤' })
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  tagId?: number;

  @ApiPropertyOptional({ description: '按作者过滤' })
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  authorId?: number;
}

请求 GET /api/v1/posts?page=2&pageSize=10&tagId=5,HTTP 层面 Nest 拿到的是:

{ page: "2", pageSize: "10", tagId: "5" }   // 全是字符串

如果不做任何处理,@IsInt() 会全部失败——"5" 不是整数、是字符串。这时候有两种救法:

  • @Type(() => Number)(learnhub 选的):class-transformertransform: true 的管道里先把字段值按 Number 构造一次(Number("5")5),然后才交给 class-validator 校验。写在 @IsInt 前面,所以校验时已经是数字 5 了。这是和「DTO 风格」一致的做法——规则都写在 DTO 上,controller 不用关心。
  • ParseIntPipe:在 controller 参数上写 @Query('tagId', new ParseIntPipe()) tagId: number。这是 Nest 自带的「参数级管道」,对单个字段做转换+校验。但 query DTO 是整体接收的,对每个字段都套一个 pipe 太啰嗦,learnhub 在 DTO 里走 @Type

为什么 @Type(() => Number) 写法要重复 () => Number 这个箭头函数、不能直接写 @Type(Number)?因为 class-transformer 需要的是一个「构造器」而不是「实例」,写 @Type(Number) 是把 Number 当成实例传进去(虽然巧合能跑,但语义不对)。() => Number 明确返回构造器,是官方推荐写法。

这个机制最漂亮的应用是 PageDto

// learnhub/src/common/dto/page.dto.ts
export class PageDto {
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page: number = 1;

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100)
  pageSize: number = 10;

  @IsOptional()
  @IsString()
  keyword?: string;

  get skip(): number {
    return (this.page - 1) * this.pageSize;
  }

  get take(): number {
    return this.pageSize;
  }
}

注意三件事:

  1. page = 1pageSize = 10 是默认值。前端不传时,class-validator 跳过校验(因为 @IsOptional),但字段还是有值——通过默认值赋的。这是 DTO 提供默认值的合法位置。
  2. pageSize@Max(100)——防止前端传 pageSize=10000 一次性把数据库拖死。这种「上限」校验在分页 DTO 里必须有。
  3. get skip() / get take() 是计算属性——它们不存在于请求里,是 DTO 派生出来的。

注意get skip() 能用,前提有两条——第一,transform: true 已经把对象实例化成 PageDto(普通对象没有 getter);第二,page / pageSize 已经被 @Type(() => Number) 转成了真正的数字。少任何一条,(this.page - 1) * this.pageSize 都会算出 NaN(字符串相减得到 NaN),传给 TypeORM 的 skip(NaN).take(NaN) 就是灾难。

QueryPostDto extends PageDto——所有列表接口共享同一份分页契约。评论列表、用户列表都继承 PageDto,分页参数校验、计算属性、上限保护一次写好到处复用。这是 DTO 继承的真实价值,不是面向对象的炫技。

第五步:嵌套对象校验——@ValidateNested + @Type

learnhub 的评论 DTO 是个看起来「嵌套」但其实不是的例子:

// learnhub/src/modules/comment/dto/create-comment.dto.ts
export class CreateCommentDto {
  @ApiProperty({ description: '帖子 ID' })
  @Type(() => Number)
  @IsInt()
  postId: number;

  @ApiProperty({ example: '好文!' })
  @IsString()
  @IsNotEmpty()
  content: string;

  @ApiPropertyOptional({ description: '父评论 ID(回复评论时传)' })
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  parentId?: number;
}

它只有扁平的 number 字段(都用 @Type(() => Number) 解决字符串问题),没有真正的嵌套对象——postId / parentId 是数字,不是另一个对象。learnhub 目前没有需要嵌套对象校验的场景。

但如果你要在 learnhub 里加一个「带作者信息一起发帖」的接口(请求体里 author 是个对象),class-validator 默认不会递归校验嵌套对象——@IsDefined() 只检查字段存在,不进入嵌套对象的字段校验。要让它递归,必须两个装饰器配套:

// 假设的扩展:嵌套对象的写法(learnhub 当前没有,作为模式补充)
import { ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';

class AuthorDto {
  @IsString() @Length(2, 30)
  name: string;

  @IsEmail()
  email: string;
}

class CreatePostWithAuthorDto {
  @IsString() @Length(1, 100)
  title: string;

  @ValidateNested()
  @Type(() => AuthorDto)   // 必须配 @Type,否则 @ValidateNested 拿不到类元信息
  author: AuthorDto;
}

注意@ValidateNested 必须配 @Type(() => AuthorDto)——前者告诉 class-validator「这个字段是另一个 DTO,请递归校验」,后者告诉 class-transformer「把字段值实例化成 AuthorDto,否则它只是个普通对象、原型上没规则」。漏掉 @Type@ValidateNested 就什么都校验不到——这是嵌套校验最常见的「我写了为什么不生效」坑。数组里嵌对象则写 @Type(() => AuthorDto)@ValidateNested({ each: true })

第六步:自定义参数装饰器——@CurrentUser 的原理

post.controller.ts 里反复出现的 @CurrentUser() 不是 Nest 内置的:

// learnhub/src/core/decorators/current-user.decorator.ts
export interface RequestUser {
  userId: number;
  username: string;
}

export const CurrentUser = createParamDecorator(
  (_data: unknown, ctx: ExecutionContext): RequestUser => {
    const req = ctx.switchToHttp().getRequest();
    return req.user as RequestUser;
  },
);

createParamDecorator 是 Nest 提供的「自定义参数装饰器工厂」。它接收一个回调,回调返回什么,handler 里这个参数就是什么。这里的逻辑简单到一句话——从 request 上把 user 字段拿出来。

req.user 是哪来的?现在还是空的。第十一章讲 JWT 时,JwtAuthGuard 会在 token 校验通过后把 payload({ userId, username })写到 req.user 上。所以 @CurrentUser() 现在用、第十一 章 才能拿到真实数据——但装饰器本身的实现先写好,因为它和接口签名绑在一起,先定形后填充是正常节奏。

ExecutionContext 是 Nest 对执行上下文的抽象——switchToHttp() 切到 HTTP 上下文(同一个 ExecutionContext 也覆盖 RPC、WebSocket,所以要先切)。getRequest() 拿到 Express 的 Request 对象。这种「在装饰器里直接碰 request」的写法很常见,但要克制——业务逻辑往 service 走,装饰器只负责取参数、不做业务判断。

第一个参数 _data 是「装饰器参数」:写成 @CurrentUser('field')'field' 会传进来。learnhub 这里不需要(永远返回整个 req.user),所以命名成 _data 表示忽略。如果将来要支持 @CurrentUser('userId') 只取一个字段,回调里读 data 做分支即可。

为什么不用 @Req() 直接拿 request?因为 @Req() 把整个 Express Request 暴露到 handler 里,handler 就和 Express 耦合了(换 Fastify 就要改 handler)。@CurrentUser() 抽出来一个类型明确的最小契约 RequestUser,handler 只依赖这个接口,不依赖底层框架——这是「依赖倒置」在参数层的具体落地。

第七步:自定义元数据装饰器——@Public 与 Reflector

@Public() 是另一种装饰器,它不取参数、而是往 handler 上贴元数据

// learnhub/src/core/decorators/public.decorator.ts
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

SetMetadata 是 Nest 提供的工具——把 { isPublic: true } 这条元数据贴到被装饰的 handler(或类)上。它本身不产生任何运行时行为,真正的消费方是守卫(Guard)。第十一章的全局 JwtAuthGuard 会做这样的事:

// 守卫里的伪代码(learnhub 第十一章会真正实现)
@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(ctx: ExecutionContext): boolean {
    // 用 Reflector 把 @Public() 贴的元数据读出来
    const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [
      ctx.getHandler(),
      ctx.getClass(),
    ]);
    if (isPublic) return true;        // 标了 @Public 就跳过认证
    // 否则校验 JWT...
  }
}

Reflector 是 Nest 专门用来「反向读元数据」的工具——@Public() 是写、Reflector 是读。这一写一读就是 Nest 里声明式权限/行为的标准模式:在 controller 上贴标签声明意图、在守卫里集中执行规则。learnhub 的 @RequirePermission('post:create') 走的是同一套机制(贴 permissions 元数据、PermissionGuard 读出来校验),只是数据结构换成数组。

注意@Public() 在 learnhub 当前阶段(第二章)什么都不会做——还没有全局守卫消费它。但接口上先贴着,第十一章把守卫一加,立刻生效。这种「先标记、后实现」是真实项目常见的节奏,不要因为「现在不生效」就先不写。

第八步:DTO 与 Entity 的纪律——别把 Entity 当 @Body

这一节没有新代码,但它是这一章最容易被人跳过、又最容易在实战里出事的设计纪律。

learnhub 里 CreatePostDtoPost(Entity)字段很像,但它们是两个类,绝不混用。原因有三层:

  • mass-assignment:Entity 上有 viewCount / likeCount / pinned / createTime 这些不该由前端传的字段。如果 @Body() dto: Post,前端传 { title, content, viewCount: 999999 },即便有 whitelist,只要字段名对得上就会被赋值——前端就能直接给自己刷赞、刷浏览量、置顶自己的帖子。DTO 只暴露允许用户填的字段,是反 mass-assignment 的硬隔离。
  • 类型契约:Entity 的字段反映库里的存储createTime: DateviewCount: number),DTO 的字段反映前端的输入title: stringtagIds: number[])。两者形状根本不一样——tagIds 是 DTO 的输入、tags 是 Entity 的关系字段;createTime 是 Entity 的自动列、DTO 里根本没有。强行复用会让两边都变形。
  • 校验规则归属@Length(1, 100) 是「用户输入的约束」,应该贴在 DTO 上;@Column({ length: 100 }) 是「数据库列的约束」,贴在 Entity 上。两个约束目标不同、来源不同,混在一个类里就把两种关注点压在一起,改一处影响另一处。
// 反面教材:千万别这么干(别学这个)
@Post()
create(@Body() post: Post): Promise<Post> {   // 直接收 Entity
  return this.postRepo.save(post);            // viewCount、pinned、author 全靠前端自觉
}

learnhub 的真实写法永远是 @Body() dto: CreatePostDto → service 把 DTO 字段挑出来、拼到 Entity 上 → save。第八章 PostService.create 里你能看到这一步——它显式地从 dtotitle / content / tagIds,绝不直接 Object.assign(post, dto)

第九步:合法与非法请求,各会发生什么

把 learnhub 跑起来(第一章已讲),用 curl 触发校验:

# 登录拿 token(第十一章细讲,先用)
curl -X POST http://localhost:3000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"admin123456"}'
# {"accessToken":"eyJ...","refreshToken":"eyJ..."}

合法发帖(标题 1–100 字、内容非空、tagIds 全是整数):

curl -X POST http://localhost:3000/api/v1/posts \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJ..." \
  -d '{"title":"校验这章看完了","content":"@Type 那段是关键","tagIds":[1,2]}'
# 201,返回新帖子(DTO 校验通过、进入 PostService.create)

非法发帖(标题太长、内容空、tagIds 里有字符串):

curl -X POST http://localhost:3000/api/v1/posts \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJ..." \
  -d '{"title":"超长超长超长...(100 字以上)...","content":"","tagIds":[1,"abc"]}'
# 400,返回:
# {
#   "message": ["title must be shorter than or equal to 100 characters",
#               "content should not be empty",
#               "each value in tagIds must be an integer number"],
#   "error": "Bad Request",
#   "statusCode": 400
# }

多传字段whitelist + forbidNonWhitelisted 起作用):

curl -X POST http://localhost:3000/api/v1/posts \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJ..." \
  -d '{"title":"测试","content":"内容","pinned":true}'
# 400,返回:
# {
#   "message": ["property pinned should not exist"],
#   "error": "Bad Request",
#   "statusCode": 400
# }

最后这条是 forbidNonWhitelisted 的功劳——前端想偷偷把帖子置顶,被直接拒掉。如果只开 whitelist 不开 forbidNonWhitelistedpinned 会被静默丢掉、请求 201 成功——前端以为置顶了、其实没置顶,调试起来更痛苦。这就是第一步说「公开 API 一定要开 forbidNonWhitelisted」的实证。

这一章的成果

  1. 看懂 learnhub main.tsValidationPipe 三个选项各自做什么、关掉会出什么事:transform 把对象实例化(保住 PageDto 计算属性和 @Type 转换)、whitelist 静默剥多余字段、forbidNonWhitelisted 直接拒绝多余字段。
  2. CreatePostDto / QueryPostDto 里吃透 class-validator 装饰器(@IsString / @Length / @IsNotEmpty / @IsArray / @ArrayNotEmpty / @IsInt({ each: true }) / @IsOptional)和 class-transformer@Type(() => Number)
  3. 掌握五种取参(@Param / @Query / @Body / @Headers / @Ip),并知道 query 和 path 参数都是字符串、要靠 @TypeParseIntPipe 转换。
  4. 理解 PageDto 这个可复用模式:默认值 + 计算属性 + @Max(100) 上限保护,所有列表 DTO 继承它。
  5. 看懂 @CurrentUsercreateParamDecorator,从 req.user 取)和 @PublicSetMetadata,等守卫消费)背后的机制,为第十一章 JWT 做铺垫。
  6. 明白 DTO 和 Entity 不能混用的三层原因(mass-assignment、类型契约、校验归属)。

常见问题

  • 校验不生效:先确认 main.tsapp.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })),再确认装了 class-validator + class-transformer。learnhub 的 package.json 里都有。
  • @IsInt 总是失败、即便传的是数字:大概率是 query/path 参数没被转换。检查 DTO 上有没有 @Type(() => Number),或全局 ValidationPipetransform 是否打开。body 里的 JSON 数字不需要 @Type(JSON 解析时已经是数字),query 才需要。
  • PageDtoget skip() 返回 NaN:几乎一定是 transform: false@Type(() => Number) 缺了。(this.page - 1) * this.pageSize 里只要 page / pageSize 有一个是字符串,减法就出 NaN。
  • DTO 上写了规则但请求带的多余字段还是被接收了:检查 whitelist 是不是开着。如果开了 forbidNonWhitelisted 还能进,说明字段名恰好和 DTO 上的对上了——不是多余字段。
  • 嵌套对象校验不生效@ValidateNested 必须配 @Type(() => NestedDto),缺一个就废。数组嵌套要加 { each: true }
  • 想统一错误格式(都包成 {code, message, data}):用全局 ExceptionFilter,第五章 AOP 会讲。
  • @CurrentUser() 拿到 undefined:现在(第二章)是预期的——第十一章的 JwtAuthGuard 还没接上,req.user 还没人写。装饰器本身没错。

下一章讲 IoC 与依赖注入——为什么 controller 里写一句 private readonly postService: PostService 就能用、Nest 是怎么自动 new 和组装这些对象、provider 的几种注册方式(useClass / useValue / useFactory)各适合什么场景。这是理解 Nest 剩下所有特性的核心一课。