{T}

NestJS自定义校验装饰器详解

学习目标:掌握自定义校验装饰器的实现、两种校验方式对比、transformObjToArr 工具函数、class-validator 自定义装饰器扩展。


一、自定义校验装饰器概述

1.1 为什么需要自定义校验装饰器

code
自定义校验装饰器的必要性:
│
├── 内置装饰器的局限
│   ├── @IsString():只能校验固定字段
│   ├── @IsEnum():只能校验枚举值
│   ├── @ValidateNested():需要固定类型
│   └── 无法校验动态键值对
│
├── OrderType 校验问题
│   ├── 键是动态的:{ [key: string]: 'asc' | 'desc' }
│   ├── 值是固定的:只能是 'asc' 或 'desc'
│   ├── 内置装饰器无法校验动态键
│   └── 需要自定义校验规则
│
└── 解决方案
    ├── 方案一:工具函数校验(简单)
    └── 方案二:自定义装饰器(推荐)

1.2 两种校验方式对比

维度工具函数校验自定义装饰器校验
实现位置Controller 层DTO 层
复用性每次都需要调用自动应用
代码量较少较多
学习成本
维护性低(分散在多处)高(集中在装饰器)
推荐度

二、方案一:工具函数校验

2.1 创建工具函数

typescript
// src/utils/pagination.ts
import { NotAcceptableException } from '@nestjs/common';

/**
 * 将对象转换为数组形式
 * 用于 Prisma orderBy 参数
 */
export function transformObjToArr(
  originObj: Record<string, any>,
  defaultObj: Record<string, any> = { order: 'asc' },
) {
  // 1. 获取所有唯一的键
  const uniqueKeys = new Set([
    ...Object.keys(originObj || {}),
    ...Object.keys(defaultObj),
  ]);

  // 2. 转换为数组形式
  const arr = Array.from(uniqueKeys).map((key) => {
    // 优先使用 originObj 的值,否则使用 defaultObj 的值
    const value = originObj?.[key] ?? defaultObj[key];

    // 3. 校验 value 必须是 'asc' 或 'desc'
    if (value !== 'asc' && value !== 'desc') {
      throw new NotAcceptableException(
        `${key} 的值必须是 'asc' 或 'desc',当前值是:${value}`,
      );
    }

    return { [key]: value };
  });

  return arr;
}

2.2 工具函数参数说明

参数类型默认值说明
originObjRecord<string, any>-前端传递的 order 对象
defaultObjRecord<string, any>{ order: 'asc' }默认排序对象
返回值Array<{ [key: string]: 'asc' | 'desc' }>-orderBy 数组

2.3 工具函数使用示例

typescript
// src/modules/course/course.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/prisma/prisma.service';
import { GetCoursesByTypeDto } from './dto/get-courses-by-type.dto';
import { transformObjToArr } from '@/utils/pagination';

@Injectable()
export class CourseService {
  constructor(private prisma: PrismaService) {}

  async getCoursesByType(dto: GetCoursesByTypeDto) {
    // 1. 计算分页参数
    const skip = dto.page ? (dto.page - 1) * (dto.size || 10) : 0;
    const take = dto.size || 10;

    // 2. 使用工具函数转换和校验 order
    const orderBy = transformObjToArr(dto.order || {});

    // 3. 执行查询
    return this.prisma.courseTypes.findMany({
      skip,
      take,
      orderBy,
      include: {
        tags: true,
      },
    });
  }
}

2.4 工具函数转换示例

code
转换示例:
│
├── 示例一:前端传递 order
│   ├── 输入:{ id: 'asc', createdAt: 'desc' }
│   └── 输出:[{ id: 'asc' }, { createdAt: 'desc' }]
│
├── 示例二:前端未传递 order
│   ├── 输入:{}
│   └── 输出:[{ order: 'asc' }](使用默认值)
│
├── 示例三:校验失败
│   ├── 输入:{ id: 'invalid' }
│   └── 输出:抛出 NotAcceptableException
│
└── 示例四:混合使用
    ├── 输入:{ id: 'asc' }
    └── 输出:[{ id: 'asc' }, { order: 'asc' }]

2.5 工具函数优缺点

code
工具函数校验优缺点:
│
├── 优点
│   ├── 实现简单
│   ├── 学习成本低
│   └── 易于理解和调试
│
└── 缺点
    ├── 每次都需要手动调用
    ├── 代码分散在多处
    ├── 不够优雅
    └── 维护性较差

三、方案二:自定义装饰器校验

3.1 自定义装饰器完整实现

typescript
// src/common/decorators/is-valid-value-in-arr.decorator.ts
import {
  registerDecorator,
  ValidationOptions,
  ValidationArguments,
} from 'class-validator';

/**
 * 自定义校验装饰器:校验动态键值对的值是否在指定数组中
 * 
 * @param validValues 有效值数组
 * @param validationOptions 校验选项
 * 
 * @example
 * @IsValidValueInArr(['asc', 'desc'])
 * order?: OrderType;
 */
export function IsValidValueInArr(
  validValues: string[],
  validationOptions?: ValidationOptions,
) {
  return function (object: any, propertyName: string) {
    registerDecorator({
      name: 'isValidValueInArr',
      target: object.constructor,
      propertyName: propertyName,
      constraints: [validValues],
      options: validationOptions,
      validator: {
        /**
         * 校验逻辑
         * @param value 前端传递的对象值
         * @param args 校验参数
         */
        validate(value: any, args: ValidationArguments) {
          // 1. 获取装饰器传入的有效值数组
          const [validValues] = args.constraints;

          // 2. 遍历对象的每个键
          for (const key in value) {
            // 3. 校验值是否在有效值数组中
            if (!validValues.includes(value[key])) {
              return false;
            }
          }

          return true;
        },

        /**
         * 默认错误消息
         * @param args 校验参数
         */
        defaultMessage(args: ValidationArguments) {
          const [validValues] = args.constraints;
          return `动态属性 ${args.property} 的值必须在 [${validValues.join(', ')}] 中`;
        },
      },
    });
  };
}

3.2 装饰器参数说明

参数类型说明
validValuesstring[]有效值数组,如 ['asc', 'desc']
validationOptionsValidationOptions校验选项,可自定义错误消息

3.3 ValidationArguments 参数详解

code
ValidationArguments 参数详解:
│
├── value: any
│   └── 前端传递的对象值
│       示例:{ id: 'asc', createdAt: 'desc' }
│
├── constraints: any[]
│   └── 装饰器传入的参数数组
│       示例:[['asc', 'desc']]
│
├── propertyName: string
│   └── 被校验的属性名
│       示例:'order'
│
├── object: any
│   └── 被校验的对象实例
│       示例:PaginationDto 实例
│
└── property: string
    └── 属性名(与 propertyName 相同)

3.4 使用自定义装饰器

typescript
// src/common/dto/pagination.dto.ts
import { IsNumber, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
import { IsValidValueInArr } from '../decorators/is-valid-value-in-arr.decorator';

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

  @IsNumber()
  @IsOptional()
  @Type(() => Number)
  size: number = 10;

  /**
   * 排序规则
   * 格式:{ [fieldName]: 'asc' | 'desc' }
   * 示例:{ id: 'asc', createdAt: 'desc' }
   */
  @IsValidValueInArr(['asc', 'desc'])
  @IsOptional()
  order?: Record<string, any>;
}

四、自定义装饰器工作原理

4.1 装饰器注册流程

code
自定义装饰器注册流程:
│
├── 第一步:定义装饰器函数
│   └── export function IsValidValueInArr(validValues: string[])
│
├── 第二步:调用 registerDecorator
│   └── 注册装饰器到 class-validator
│
├── 第三步:定义校验器
│   ├── validate(value, args):校验逻辑
│   └── defaultMessage(args):默认错误消息
│
├── 第四步:在 DTO 上使用装饰器
│   └── @IsValidValueInArr(['asc', 'desc'])
│
└── 第五步:ValidationPipe 自动校验
    ├── 调用 validate 方法
    ├── 返回 true 或 false
    └── 失败时返回错误消息

4.2 校验执行流程

code
校验执行流程:
│
├── 第一步:前端发送请求
│   └── GET /courses?order[id]=asc
│
├── 第二步:ValidationPipe 接收参数
│   └── 解析 Query 参数为 DTO
│
├── 第三步:执行装饰器校验
│   ├── 调用 validate(value, args)
│   ├── value = { id: 'asc' }
│   └── validValues = ['asc', 'desc']
│
├── 第四步:遍历对象的键
│   ├── key = 'id'
│   └── value[key] = 'asc'
│
├── 第五步:校验值是否在数组中
│   ├── validValues.includes('asc') → true
│   └── 返回 true
│
└── 第六步:校验通过
    └── Controller 接收 DTO

4.3 校验失败流程

code
校验失败流程:
│
├── 第一步:前端发送错误请求
│   └── GET /courses?order[id]=invalid
│
├── 第二步:执行装饰器校验
│   ├── value = { id: 'invalid' }
│   └── validValues = ['asc', 'desc']
│
├── 第三步:遍历对象的键
│   ├── key = 'id'
│   └── value[key] = 'invalid'
│
├── 第四步:校验值是否在数组中
│   ├── validValues.includes('invalid') → false
│   └── 返回 false
│
└── 第五步:返回错误消息
    └── "动态属性 order 的值必须在 [asc, desc] 中"

五、Postman 测试示例

5.1 测试一:校验通过(asc)

code
请求配置:
│
├── Method: GET
├── URL: http://localhost:3000/courses/by-type
│
├── Query Params:
│   ├── page: 1
│   ├── size: 10
│   ├── Key: order[id]
│   └── Value: asc
│
├── 实际 URL
│   └── ?page=1&size=10&order[id]=asc
│
└── 预期响应
    ├── 状态码:200
    └── 返回正常的课程数据

5.2 测试二:校验通过(desc)

code
请求配置:
│
├── Method: GET
├── URL: http://localhost:3000/courses/by-type
│
├── Query Params:
│   ├── page: 1
│   ├── size: 10
│   ├── Key: order[id]
│   └── Value: desc
│
├── 实际 URL
│   └── ?page=1&size=10&order[id]=desc
│
└── 预期响应
    ├── 状态码:200
    └── 返回降序排列的课程数据

5.3 测试三:校验失败

code
请求配置:
│
├── Method: GET
├── URL: http://localhost:3000/courses/by-type
│
├── Query Params:
│   ├── page: 1
│   ├── size: 10
│   ├── Key: order[id]
│   └── Value: invalid
│
├── 实际 URL
│   └── ?page=1&size=10&order[id]=invalid
│
└── 预期响应
    ├── 状态码:400
    └── 错误消息:
        {
          "statusCode": 400,
          "message": ["动态属性 order 的值必须在 [asc, desc] 中"],
          "error": "Bad Request"
        }

5.4 测试四:多字段排序校验

code
请求配置:
│
├── Method: GET
├── URL: http://localhost:3000/courses/by-type
│
├── Query Params:
│   ├── page: 1
│   ├── size: 10
│   ├── Key: order[id], Value: asc
│   └── Key: order[createdAt], Value: desc
│
├── 实际 URL
│   └── ?page=1&size=10&order[id]=asc&order[createdAt]=desc
│
└── 预期响应
    ├── 状态码:200
    └── 返回先按 id 升序,再按 createdAt 降序的数据

六、完整实战示例

6.1 项目结构

code
project/
├── src/
│   ├── common/
│   │   ├── decorators/
│   │   │   └── is-valid-value-in-arr.decorator.ts
│   │   └── dto/
│   │       └── pagination.dto.ts
│   ├── modules/
│   │   └── course/
│   │       ├── dto/
│   │       │   └── get-courses-by-type.dto.ts
│   │       ├── course.controller.ts
│   │       ├── course.service.ts
│   │       └── course.module.ts
│   └── main.ts
└── package.json

6.2 完整自定义装饰器实现

typescript
// src/common/decorators/is-valid-value-in-arr.decorator.ts
import {
  registerDecorator,
  ValidationOptions,
  ValidationArguments,
} from 'class-validator';

/**
 * 自定义校验装饰器:校验动态键值对的值是否在指定数组中
 * 
 * @param validValues 有效值数组
 * @param validationOptions 校验选项
 * 
 * @example
 * @IsValidValueInArr(['asc', 'desc'])
 * order?: OrderType;
 */
export function IsValidValueInArr(
  validValues: string[],
  validationOptions?: ValidationOptions,
) {
  return function (object: any, propertyName: string) {
    registerDecorator({
      name: 'isValidValueInArr',
      target: object.constructor,
      propertyName: propertyName,
      constraints: [validValues],
      options: validationOptions,
      validator: {
        /**
         * 校验逻辑
         * @param value 前端传递的对象值
         * @param args 校验参数
         */
        validate(value: any, args: ValidationArguments) {
          // 1. 获取装饰器传入的有效值数组
          const [validValues] = args.constraints;

          // 2. 遍历对象的每个键
          for (const key in value) {
            // 3. 校验值是否在有效值数组中
            if (!validValues.includes(value[key])) {
              return false;
            }
          }

          return true;
        },

        /**
         * 默认错误消息
         * @param args 校验参数
         */
        defaultMessage(args: ValidationArguments) {
          const [validValues] = args.constraints;
          return `动态属性 ${args.property} 的值必须在 [${validValues.join(', ')}] 中`;
        },
      },
    });
  };
}

6.3 完整 PaginationDto 实现

typescript
// src/common/dto/pagination.dto.ts
import { IsNumber, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
import { IsValidValueInArr } from '../decorators/is-valid-value-in-arr.decorator';

/**
 * 分页查询通用参数
 */
export class PaginationDto {
  /**
   * 页码(从 1 开始)
   */
  @IsNumber()
  @IsOptional()
  @Type(() => Number)
  page: number = 1;

  /**
   * 每页数量
   */
  @IsNumber()
  @IsOptional()
  @Type(() => Number)
  size: number = 10;

  /**
   * 排序规则
   * 格式:{ [fieldName]: 'asc' | 'desc' }
   * 示例:{ id: 'asc', createdAt: 'desc' }
   */
  @IsValidValueInArr(['asc', 'desc'])
  @IsOptional()
  order?: Record<string, any>;
}

6.4 完整 Service 实现

typescript
// src/modules/course/course.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/prisma/prisma.service';
import { GetCoursesByTypeDto } from './dto/get-courses-by-type.dto';

@Injectable()
export class CourseService {
  constructor(private prisma: PrismaService) {}

  /**
   * 根据分类查询课程(带分页和排序)
   */
  async getCoursesByType(dto: GetCoursesByTypeDto) {
    // 1. 计算分页参数
    const skip = dto.page ? (dto.page - 1) * (dto.size || 10) : 0;
    const take = dto.size || 10;

    // 2. 处理排序参数
    // 方式一:直接使用 dto.order(需要在 DTO 层校验)
    const orderBy = dto.order ? [dto.order] : [{ order: 'asc' as const }];

    // 3. 执行查询
    return this.prisma.courseTypes.findMany({
      skip,
      take,
      orderBy,
      include: {
        tags: {
          include: {
            courses: {
              include: {
                course: {
                  include: {
                    author: true,
                  },
                },
              },
            },
          },
        },
      },
    });
  }
}

6.5 完整 Controller 实现

typescript
// src/modules/course/course.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { CourseService } from './course.service';
import { GetCoursesByTypeDto } from './dto/get-courses-by-type.dto';

@Controller('courses')
export class CourseController {
  constructor(private readonly courseService: CourseService) {}

  /**
   * 根据分类查询课程(带分页和排序)
   * GET /courses/by-type?page=1&size=10&order[id]=asc
   */
  @Get('by-type')
  async getCoursesByType(@Query() dto: GetCoursesByTypeDto) {
    return this.courseService.getCoursesByType(dto);
  }
}

七、两种方案对比总结

7.1 实现复杂度对比

维度工具函数方案自定义装饰器方案
代码行数约 20 行约 50 行
文件数量1 个(utils/pagination.ts)1 个(decorators)
学习曲线平缓陡峭
调试难度

7.2 使用便捷性对比

维度工具函数方案自定义装饰器方案
使用方式手动调用函数自动校验
复用性每次都需要调用自动应用到所有 DTO
维护性分散在多处集中在装饰器
扩展性

7.3 性能对比

维度工具函数方案自定义装饰器方案
校验时机Controller 层DTO 层(更早)
错误提示需要手动处理自动生成
性能影响可忽略可忽略

7.4 推荐选择

code
方案选择建议:
│
├── 推荐使用自定义装饰器方案 
│   ├── 优点:自动应用、集中维护、扩展性强
│   ├── 缺点:学习成本稍高
│   └── 适用:团队协作、长期维护的项目
│
└── 工具函数方案 
    ├── 优点:实现简单、学习成本低
    ├── 缺点:每次需要手动调用、维护性差
    └── 适用:快速原型、个人项目

八、常见问题与解决方案

8.1 装饰器不生效问题

问题原因解决方案
装饰器不执行未在 DTO 上使用添加 @IsValidValueInArr(['asc', 'desc'])
校验不生效ValidationPipe 未启用在 main.ts 启用 ValidationPipe
错误消息不显示未定义 defaultMessage添加 defaultMessage 方法

8.2 校验逻辑错误

typescript
//  错误:只校验第一个键
validate(value: any, args: ValidationArguments) {
  const [validValues] = args.constraints;
  const firstKey = Object.keys(value)[0];
  return validValues.includes(value[firstKey]);
}

//  正确:校验所有键
validate(value: any, args: ValidationArguments) {
  const [validValues] = args.constraints;
  for (const key in value) {
    if (!validValues.includes(value[key])) {
      return false;
    }
  }
  return true;
}

8.3 错误消息不友好

typescript
//  错误:消息不够具体
defaultMessage(args: ValidationArguments) {
  return '校验失败';
}

//  正确:提供详细的错误信息
defaultMessage(args: ValidationArguments) {
  const [validValues] = args.constraints;
  return `动态属性 ${args.property} 的值必须在 [${validValues.join(', ')}] 中`;
}

九、最佳实践总结

9.1 自定义装饰器最佳实践

code
自定义装饰器最佳实践:
│
├── 命名规范
│   ├── 以 Is 开头,表示校验
│   ├── 使用驼峰命名法
│   └── 名称清晰表达用途
│
├── 参数设计
│   ├── 必要参数放在前面
│   ├── 可选参数使用 ? 标记
│   └── 提供默认值
│
├── 错误消息
│   ├── 提供 defaultMessage
│   ├── 消息清晰、具体
│   └── 包含有效值提示
│
└── 文档注释
    ├── 添加 JSDoc 注释
    ├── 提供使用示例
    └── 说明参数用途

9.2 校验逻辑最佳实践

code
校验逻辑最佳实践:
│
├── 边界情况处理
│   ├── 处理 null 和 undefined
│   ├── 处理空对象
│   └── 处理非对象类型
│
├── 性能优化
│   ├── 避免重复计算
│   ├── 使用 break 提前退出
│   └── 避免不必要的遍历
│
└── 错误处理
    ├── 提供明确的错误消息
    ├── 记录校验失败日志
    └── 返回友好的错误信息

9.3 DTO 设计最佳实践

code
DTO 设计最佳实践:
│
├── 通用 DTO
│   ├── 放在 common 文件夹
│   ├── 使用继承复用
│   └── 添加清晰的注释
│
├── 业务 DTO
│   ├── 放在模块文件夹
│   ├── 继承通用 DTO
│   └── 添加业务特定字段
│
└── 校验装饰器
    ├── 通用校验放在通用 DTO
    ├── 业务校验放在业务 DTO
    └── 自定义装饰器放在 common/decorators

十、命令速查表

10.1 自定义装饰器命令速查

操作命令/代码说明
注册装饰器registerDecorator({ ... })注册自定义装饰器
定义校验逻辑validate(value, args)校验逻辑实现
定义错误消息defaultMessage(args)默认错误消息
获取装饰器参数args.constraints获取装饰器传入的参数
获取属性名args.property获取被校验的属性名
获取对象值value前端传递的对象值

10.2 校验逻辑速查

操作代码说明
遍历对象键for (const key in value)遍历对象的所有键
检查值是否在数组中validValues.includes(value[key])检查值是否有效
返回校验结果return true / falsetrue=通过,false=失败
获取有效值数组const [validValues] = args.constraints获取装饰器参数

10.3 错误消息速查

操作代码说明
获取属性名args.property获取被校验的属性名
获取有效值数组args.constraints[0]获取有效值数组
数组转字符串validValues.join(', ')数组转字符串显示
返回错误消息return '错误消息'返回自定义错误消息

十一、扩展应用场景

11.1 其他自定义校验装饰器示例

typescript
// 示例一:校验手机号格式
@IsPhoneNumber()
phone: string;

// 示例二:校验身份证号格式
@IsIdCard()
idCard: string;

// 示例三:校验 JSON 字符串
@IsJsonString()
config: string;

// 示例四:校验日期范围
@IsDateRange({ min: '2020-01-01', max: '2030-12-31' })
date: Date;

// 示例五:校验密码强度
@IsStrongPassword()
password: string;

11.2 组合使用多个装饰器

typescript
// 组合使用多个校验装饰器
export class UserDto {
  @IsString()
  @MinLength(2)
  @MaxLength(20)
  name: string;

  @IsEmail()
  email: string;

  @IsPhoneNumber()
  phone: string;

  @IsValidValueInArr(['admin', 'user', 'guest'])
  role?: string;
}

11.3 自定义校验器工厂函数

typescript
// 创建校验器工厂函数
function createValueInArrValidator(validValues: string[]) {
  return function (validationOptions?: ValidationOptions) {
    return IsValidValueInArr(validValues, validationOptions);
  };
}

// 使用工厂函数
const IsSortOrder = createValueInArrValidator(['asc', 'desc']);

export class PaginationDto {
  @IsSortOrder()
  order?: Record<string, any>;
}

十二、学习要点总结

12.1 核心知识点

code
本文核心要点:
│
├── 两种校验方式
│   ├── 工具函数方案:简单但维护性差
│   └── 自定义装饰器方案:复杂但维护性好
│
├── 自定义装饰器实现
│   ├── 使用 registerDecorator 注册
│   ├── 实现 validate 方法
│   └── 实现 defaultMessage 方法
│
├── ValidationArguments 参数
│   ├── value:前端传递的对象值
│   ├── constraints:装饰器参数
│   └── property:属性名
│
├── 校验逻辑
│   ├── 遍历对象的每个键
│   ├── 检查值是否在有效值数组中
│   └── 返回 true 或 false
│
└── 最佳实践
    ├── 提供清晰的错误消息
    ├── 处理边界情况
    └── 添加文档注释

12.2 重要程度标注

知识点重要程度说明
自定义装饰器实现必须掌握核心功能
registerDecorator 使用必须掌握注册装饰器
validate 方法实现必须掌握校验逻辑
defaultMessage 方法必须掌握错误消息
工具函数方案重要替代方案
ValidationArguments了解深入理解

12.3 学习路径规划

code
学习路径规划:
│
├── 第一阶段:理解概念(1 天)
│   ├── 理解自定义装饰器原理
│   ├── 理解 ValidationArguments
│   └── 理解校验流程
│
├── 第二阶段:实践操作(2-3 天)
│   ├── 实现自定义装饰器
│   ├── 实现工具函数方案
│   └── 测试两种方案
│
└── 第三阶段:深入应用(持续)
    ├── 创建其他自定义装饰器
    ├── 组合使用多个装饰器
    └── 优化校验逻辑

十三、完整代码清单

13.1 自定义装饰器完整代码

typescript
// src/common/decorators/is-valid-value-in-arr.decorator.ts
import {
  registerDecorator,
  ValidationOptions,
  ValidationArguments,
} from 'class-validator';

/**
 * 自定义校验装饰器:校验动态键值对的值是否在指定数组中
 * 
 * @param validValues 有效值数组
 * @param validationOptions 校验选项
 * 
 * @example
 * @IsValidValueInArr(['asc', 'desc'])
 * order?: OrderType;
 */
export function IsValidValueInArr(
  validValues: string[],
  validationOptions?: ValidationOptions,
) {
  return function (object: any, propertyName: string) {
    registerDecorator({
      name: 'isValidValueInArr',
      target: object.constructor,
      propertyName: propertyName,
      constraints: [validValues],
      options: validationOptions,
      validator: {
        validate(value: any, args: ValidationArguments) {
          const [validValues] = args.constraints;
          for (const key in value) {
            if (!validValues.includes(value[key])) {
              return false;
            }
          }
          return true;
        },
        defaultMessage(args: ValidationArguments) {
          const [validValues] = args.constraints;
          return `动态属性 ${args.property} 的值必须在 [${validValues.join(', ')}] 中`;
        },
      },
    });
  };
}

13.2 工具函数完整代码

typescript
// src/utils/pagination.ts
import { NotAcceptableException } from '@nestjs/common';

/**
 * 将对象转换为数组形式
 * 用于 Prisma orderBy 参数
 */
export function transformObjToArr(
  originObj: Record<string, any>,
  defaultObj: Record<string, any> = { order: 'asc' },
) {
  const uniqueKeys = new Set([
    ...Object.keys(originObj || {}),
    ...Object.keys(defaultObj),
  ]);

  const arr = Array.from(uniqueKeys).map((key) => {
    const value = originObj?.[key] ?? defaultObj[key];

    if (value !== 'asc' && value !== 'desc') {
      throw new NotAcceptableException(
        `${key} 的值必须是 'asc' 或 'desc',当前值是:${value}`,
      );
    }

    return { [key]: value };
  });

  return arr;
}

13.3 PaginationDto 完整代码

typescript
// src/common/dto/pagination.dto.ts
import { IsNumber, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
import { IsValidValueInArr } from '../decorators/is-valid-value-in-arr.decorator';

/**
 * 分页查询通用参数
 */
export class PaginationDto {
  @IsNumber()
  @IsOptional()
  @Type(() => Number)
  page: number = 1;

  @IsNumber()
  @IsOptional()
  @Type(() => Number)
  size: number = 10;

  /**
   * 排序规则
   * 格式:{ [fieldName]: 'asc' | 'desc' }
   */
  @IsValidValueInArr(['asc', 'desc'])
  @IsOptional()
  order?: Record<string, any>;
}

13.4 CourseService 完整代码

typescript
// src/modules/course/course.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/prisma/prisma.service';
import { GetCoursesByTypeDto } from './dto/get-courses-by-type.dto';

@Injectable()
export class CourseService {
  constructor(private prisma: PrismaService) {}

  async getCoursesByType(dto: GetCoursesByTypeDto) {
    const skip = dto.page ? (dto.page - 1) * (dto.size || 10) : 0;
    const take = dto.size || 10;
    const orderBy = dto.order ? [dto.order] : [{ order: 'asc' as const }];

    return this.prisma.courseTypes.findMany({
      skip,
      take,
      orderBy,
      include: {
        tags: {
          include: {
            courses: {
              include: {
                course: {
                  include: {
                    author: true,
                  },
                },
              },
            },
          },
        },
      },
    });
  }
}

13.5 CourseController 完整代码

typescript
// src/modules/course/course.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { CourseService } from './course.service';
import { GetCoursesByTypeDto } from './dto/get-courses-by-type.dto';

@Controller('courses')
export class CourseController {
  constructor(private readonly courseService: CourseService) {}

  @Get('by-type')
  async getCoursesByType(@Query() dto: GetCoursesByTypeDto) {
    return this.courseService.getCoursesByType(dto);
  }
}

重要提示:自定义校验装饰器是 NestJS 数据校验的高级应用,掌握其实现原理和使用方法,对实际项目开发非常重要!特别是要理解 ValidationArguments 参数的含义,以及如何在 validate 方法中实现校验逻辑。推荐使用自定义装饰器方案,代码更加优雅、维护性更好!

扩展建议:可以尝试创建其他自定义装饰器,如 @IsPhoneNumber()@IsIdCard()@IsStrongPassword() 等,进一步掌握自定义装饰器的应用!