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 工具函数参数说明
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| originObj | Record<string, any> | - | 前端传递的 order 对象 |
| defaultObj | Record<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 装饰器参数说明
| 参数 | 类型 | 说明 |
|---|---|---|
| validValues | string[] | 有效值数组,如 ['asc', 'desc'] |
| validationOptions | ValidationOptions | 校验选项,可自定义错误消息 |
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 接收 DTO4.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.json6.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 / false | true=通过,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() 等,进一步掌握自定义装饰器的应用!