Skip to content
第 185 / 250 章Node⏱ 12 分钟阅读

第 185 章:数据验证与 DTO

学习目标

  • 深入理解 DTO 模式
  • 掌握 class-validator 全部装饰器
  • 学会自定义验证规则
  • 理解嵌套与分组验证

一、什么是 DTO

DTO (Data Transfer Object) 用于在层之间传输数据,通常带验证规则。

二、基础 DTO

2.1 Create DTO

typescript
import { IsString, IsEmail, MinLength, MaxLength, IsInt, Min, Max, IsOptional, IsEnum } from 'class-validator';

export class CreateUserDto {
  @IsString()
  @MinLength(2, { message: '姓名至少 2 位' })
  @MaxLength(20)
  name: string;

  @IsEmail({}, { message: '邮箱格式不正确' })
  email: string;

  @IsString()
  @MinLength(6)
  password: string;

  @IsOptional()
  @IsInt()
  @Min(0)
  @Max(150)
  age?: number;

  @IsEnum(['admin', 'user'], { message: '角色必须是 admin 或 user' })
  role: 'admin' | 'user';
}

2.2 Update DTO(部分字段)

typescript
import { PartialType } from '@nestjs/mapped-types';

export class UpdateUserDto extends PartialType(CreateUserDto) {}

// 等价于
export class UpdateUserDto {
  @IsOptional()
  @IsString()
  name?: string;

  @IsOptional()
  @IsEmail()
  email?: string;

  // ... 所有字段都变可选
}

2.3 Query DTO(查询参数)

typescript
export class PaginationDto {
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page: number = 1;

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

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

  @IsOptional()
  @IsIn(['ASC', 'DESC'])
  order?: 'ASC' | 'DESC' = 'DESC';
}

三、PickType / OmitType

3.1 Pick(挑字段)

typescript
import { PickType } from '@nestjs/mapped-types';

export class LoginDto extends PickType(CreateUserDto, ['email', 'password']) {}
// 只有 email 和 password

3.2 Omit(排除字段)

typescript
import { OmitType } from '@nestjs/mapped-types';

export class SafeUserDto extends OmitType(CreateUserDto, ['password']) {}
// 排除 password

四、嵌套验证

4.1 嵌套对象

typescript
export class AddressDto {
  @IsString()
  city: string;

  @IsString()
  street: string;

  @IsString()
  zipCode: string;
}

export class CreateUserDto {
  @IsString()
  name: string;

  @ValidateNested()
  @Type(() => AddressDto)
  address: AddressDto;
}

4.2 嵌套数组

typescript
export class CreateOrderDto {
  @IsString()
  customerId: string;

  @IsArray()
  @ArrayMinSize(1)
  @ValidateNested({ each: true })
  @Type(() => OrderItemDto)
  items: OrderItemDto[];
}

export class OrderItemDto {
  @IsString()
  productId: string;

  @IsInt()
  @Min(1)
  quantity: number;
}

五、自定义验证装饰器

5.1 简单规则

typescript
import { registerDecorator, ValidationOptions } from 'class-validator';

export function IsStrongPassword(options?: ValidationOptions) {
  return function (object: object, propertyName: string) {
    registerDecorator({
      name: 'isStrongPassword',
      target: object.constructor,
      propertyName,
      options,
      validator: {
        validate(value: any) {
          return typeof value === 'string'
            && /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(value);
        },
        defaultMessage() {
          return '密码至少 8 位,包含大小写字母和数字';
        },
      },
    });
  };
}

5.2 关联字段验证

typescript
// 确认密码必须相同
export function IsMatch(property: string, options?: ValidationOptions) {
  return function (object: object, propertyName: string) {
    registerDecorator({
      name: 'isMatch',
      target: object.constructor,
      propertyName,
      constraints: [property],
      options,
      validator: {
        validate(value: any, args: any) {
          const [relatedPropertyName] = args.constraints;
          const relatedValue = (args.object as any)[relatedPropertyName];
          return value === relatedValue;
        },
        defaultMessage() {
          return '两次输入不一致';
        },
      },
    });
  };
}

export class RegisterDto {
  @IsStrongPassword()
  password: string;

  @IsMatch('password')
  confirmPassword: string;
}

5.3 异步验证

typescript
import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments } from 'class-validator';
import { Injectable } from '@nestjs/common';
import { UserService } from './user.service';

@ValidatorConstraint({ name: 'isEmailUnique', async: true })
@Injectable()
export class IsEmailUniqueConstraint implements ValidatorConstraintInterface {
  constructor(private userService: UserService) {}

  async validate(email: string) {
    const user = await this.userService.findByEmail(email);
    return !user;
  }

  defaultMessage() {
    return '邮箱已被注册';
  }
}

export function IsEmailUnique(options?: ValidationOptions) {
  return function (object: object, propertyName: string) {
    registerDecorator({
      target: object.constructor,
      propertyName,
      options,
      validator: IsEmailUniqueConstraint,
    });
  };
}

// 使用
export class CreateUserDto {
  @IsEmailUnique()
  email: string;
}

// module
@Module({
  providers: [IsEmailUniqueConstraint],   // 注册
})
export class UserModule {}

六、分组验证

6.1 定义分组

typescript
export class CreateUserDto {
  @IsString()
  @MinLength(2)
  name: string;

  @IsEmail()
  @Validate(IsEmailUnique)
  email: string;

  @IsString()
  @MinLength(6)
  password: string;
}

6.2 不同接口用不同校验

typescript
@Post()
create(
  @Body(new ValidationPipe({ groups: ['create'] })) dto: CreateUserDto,
) {}

@Patch(':id')
update(
  @Param('id') id: string,
  @Body(new ValidationPipe({ groups: ['update'] })) dto: UpdateUserDto,
) {}

七、class-transformer

7.1 类型转换

typescript
import { Type, Transform } from 'class-transformer';

export class SearchDto {
  @Type(() => Number)        // 字符串转数字
  page: number;

  @Type(() => Date)          // 字符串转 Date
  startDate: Date;
}

7.2 字段转换

typescript
import { Transform } from 'class-transformer';

// 自动 trim
@Transform(({ value }) => value?.trim())
name: string;

// 转小写
@Transform(({ value }) => value?.toLowerCase())
email: string;

// 数字加千分位
@Transform(({ value }) => value?.toLocaleString())
amount: number;

八、错误消息定制

8.1 全局定制

typescript
app.useGlobalPipes(new ValidationPipe({
  exceptionFactory: (errors) => {
    const messages = errors
      .map(err => Object.values(err.constraints || {}).join(', '))
      .join('; ');

    return new BadRequestException({
      code: 400,
      message: messages,
      data: null,
    });
  },
}));

8.2 详细错误

typescript
app.useGlobalPipes(new ValidationPipe({
  exceptionFactory: (errors) => {
    const formatted = errors.map(err => ({
      field: err.property,
      errors: Object.values(err.constraints || {}),
    }));

    return new BadRequestException({
      code: 400,
      message: 'Validation failed',
      errors: formatted,
    });
  },
}));

输出:

json
{
  "code": 400,
  "message": "Validation failed",
  "errors": [
    { "field": "email", "errors": ["邮箱格式不正确"] },
    { "field": "password", "errors": ["密码至少 8 位"] }
  ]
}

九、嵌套 + 条件验证

typescript
export class CreateEventDto {
  @IsString()
  name: string;

  @IsEnum(['online', 'offline'])
  type: 'online' | 'offline';

  // 在线 → 不需要 location
  // 线下 → 需要 location
  @ValidateIf(o => o.type === 'offline')
  @IsString()
  location?: string;

  // 在线 → 需要 url
  @ValidateIf(o => o.type === 'online')
  @IsUrl()
  url?: string;
}

十、常用验证装饰器速查

类别装饰器
类型@IsString @IsInt @IsBoolean @IsDate @IsNumber
字符串@MinLength @MaxLength @Length @IsEmail @IsUrl @IsUUID @IsAlpha @IsAlphanumeric
数字@Min @Max @IsPositive @IsNegative
数组@ArrayMinSize @ArrayMaxSize @ArrayContains @ArrayUnique
对象@ValidateNested @IsInstance @IsDefined
日期@MinDate @MaxDate
通用@IsOptional @IsNotEmpty @IsEmpty @Equals @NotEquals
枚举@IsEnum @IsIn
格式@IsJSON @IsBase64 @IsHexColor
网络@IsIP @IsPort @IsMimeType

十一、本章小结

概念关键
DTO数据传输对象
ValidationPipe全局验证
PartialType部分字段
PickType / OmitType选/排除字段
@ValidateNested嵌套
@ValidateIf条件验证
registerDecorator自定义规则

动手练习

  1. 创建 RegisterDto,带密码确认
  2. 创建一个嵌套 DTO(用户 + 地址)
  3. 写一个 @IsPhone 自定义验证器
  4. ValidateIf 实现条件必填

推荐阅读


下一章:第 186 章:认证与授权(JWT/Passport)

本站基于 VitePress 构建 · 由 Codebook 团队维护