From c1516571162ac300e97a687881bbbb7cfadf6cc6 Mon Sep 17 00:00:00 2001 From: xidongdong-153 Date: Tue, 12 Dec 2023 16:40:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E8=87=AA=E5=AE=9A=E4=B9=89=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E9=AA=8C=E8=AF=81=E7=BA=A6=E6=9D=9F=201.=E5=AD=A6?= =?UTF-8?q?=E4=B9=A0=E6=95=B0=E6=8D=AE=E8=87=AA=E5=AE=9A=E4=B9=89=E7=BA=A6?= =?UTF-8?q?=E6=9D=9F=202.=E7=BB=99=E8=87=AA=E5=AE=9A=E4=B9=89=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=BA=A6=E6=9D=9F=E5=AE=9E=E7=8E=B0=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/database6.db | Bin 73728 -> 73728 bytes package.json | 4 +- pnpm-lock.yaml | 6 + src/main.ts | 7 ++ src/modules/content/dtos/category.dto.ts | 12 ++ src/modules/content/dtos/comment.dto.ts | 8 ++ src/modules/content/dtos/post.dto.ts | 18 +++ src/modules/core/constraints/index.ts | 3 + .../core/constraints/match.constraint.ts | 41 +++++++ .../constraints/match.phone.constraint.ts | 48 ++++++++ .../core/constraints/password.constraint.ts | 64 +++++++++++ .../constraints/data.exist.constraint.ts | 94 ++++++++++++++++ src/modules/database/constraints/index.ts | 5 + .../constraints/tree.unique.constraint.ts | 90 +++++++++++++++ .../tree.unique.exist.constraint.ts | 106 ++++++++++++++++++ .../database/constraints/unique.constraint.ts | 83 ++++++++++++++ .../constraints/unique.exist.constraint.ts | 93 +++++++++++++++ src/modules/database/database.module.ts | 14 +++ 18 files changed, 695 insertions(+), 1 deletion(-) create mode 100644 src/modules/core/constraints/index.ts create mode 100644 src/modules/core/constraints/match.constraint.ts create mode 100644 src/modules/core/constraints/match.phone.constraint.ts create mode 100644 src/modules/core/constraints/password.constraint.ts create mode 100644 src/modules/database/constraints/data.exist.constraint.ts create mode 100644 src/modules/database/constraints/index.ts create mode 100644 src/modules/database/constraints/tree.unique.constraint.ts create mode 100644 src/modules/database/constraints/tree.unique.exist.constraint.ts create mode 100644 src/modules/database/constraints/unique.constraint.ts create mode 100644 src/modules/database/constraints/unique.exist.constraint.ts diff --git a/back/database6.db b/back/database6.db index c45dabe338a04f52a94a426f22abac1a16369c5c..66a80e80664f4a91de433e800374e22873f2ccac 100644 GIT binary patch delta 1198 zcma)+y=xRf7{+JGeI%OPoHi+p#z+DYnah0b%x;2}g}tz*P$4t3yJ%;Eh+=Vf5P}LZ z30p)IB7`U^A%{7FQ4p@O5yZ~eS>y{W{1=?tfQ3l1%ofA)@O$6q-RIp}D_3jf=1)0G z3w@XJOQ15f{4Q+3{2ORbb710EDe35cb-Jsg*O`ixxzhyzIFsIr_I512H9z-{O~BFz zkn0`;rE@(3s_wC7xptxZX8m5VGq}cr`as~h)DY!qq(UhWCyF8^nTw=yLlSvmOtjv8 zxUl=QvA4Rqy;^WgTq}Om~8(X&Zqu_Pf(~sA8`{>>rF(6NQ{l=X(E*= Qw4;?e>OaDJa(!gw4^paASpWb4 delta 393 zcmZoTz|wGlWr8$g??f4A)?Nm^q6HgM*7Gy&-mIALo^LX<{IcmQ_!)(`xtXOIi&7Jl zOB5V8ACtFE;Adc9VC6Mq;GfE`#kZ4BX|q6sHqT@Of!~|WdYc(p@|oE~C-3cBGFj}r zz+`@X70ygX_E2f#P+o?~KIc>>zv+`;GBN{6Y+A6HfAZdUDx0s**HQo*%5TEJe}mtI zKbC*}W`P4`{45&Gp^TFY&#AC7gIOJCj9I9+kno!!G8l7Xy5sCLCz$`EH { @@ -11,6 +13,11 @@ const bootstrap = async () => { app.setGlobalPrefix('api'); + // 使validator的约束可以使用nestjs的容器 + useContainer(app.select(AppModule), { + fallbackOnErrors: true, + }); + await app.listen(2333, () => { console.log('api: http://localhost:2333/api'); }); diff --git a/src/modules/content/dtos/category.dto.ts b/src/modules/content/dtos/category.dto.ts index e42a872..4a92415 100644 --- a/src/modules/content/dtos/category.dto.ts +++ b/src/modules/content/dtos/category.dto.ts @@ -12,7 +12,10 @@ import { } from 'class-validator'; import { toNumber } from 'lodash'; +import { CategoryEntity } from '@/modules/content/entities'; import { DtoValidation } from '@/modules/core/decorators'; +import { IsDataExist, IsTreeUnique, IsTreeUniqueExist } from '@/modules/database/constraints'; + import { PaginateOptions } from '@/modules/database/types'; @DtoValidation({ type: 'query' }) @@ -35,6 +38,14 @@ export class QueryCategoryDto implements PaginateOptions { */ @DtoValidation({ groups: ['create'] }) export class CreateCategoryDto { + @IsTreeUnique(CategoryEntity, { + groups: ['create'], + message: '分类名称重复', + }) + @IsTreeUniqueExist(CategoryEntity, { + groups: ['update'], + message: '名称重复', + }) @MaxLength(25, { always: true, message: '分类名称长度最大为$constraint1', @@ -43,6 +54,7 @@ export class CreateCategoryDto { @IsOptional({ groups: ['update'] }) name: string; + @IsDataExist(CategoryEntity, { always: true, message: '父分类不存在' }) @IsUUID(undefined, { always: true, message: '父分类ID格式不正确' }) @ValidateIf((value) => value.parent !== null && value.parent) @IsOptional({ always: true }) diff --git a/src/modules/content/dtos/comment.dto.ts b/src/modules/content/dtos/comment.dto.ts index b3f833e..4e070fa 100644 --- a/src/modules/content/dtos/comment.dto.ts +++ b/src/modules/content/dtos/comment.dto.ts @@ -13,12 +13,17 @@ import { import { toNumber } from 'lodash'; +import { CommentEntity, PostEntity } from '@/modules/content/entities'; +import { IsDataExist } from '@/modules/database/constraints'; import { PaginateOptions } from '@/modules/database/types'; /** * 评论分页查询验证 */ export class QueryCommentDto implements PaginateOptions { + @IsDataExist(PostEntity, { + message: '文章不存在', + }) @IsUUID(undefined, { message: 'ID格式错误' }) @IsOptional() post?: string; @@ -53,6 +58,9 @@ export class CreateCommentDto { @IsDefined({ message: 'ID必须指定' }) post: string; + @IsDataExist(CommentEntity, { + message: '父评论不存在', + }) @IsUUID(undefined, { message: 'ID格式错误' }) @ValidateIf((value) => value.parent !== null && value.parent) @IsOptional({ always: true }) diff --git a/src/modules/content/dtos/post.dto.ts b/src/modules/content/dtos/post.dto.ts index 562d472..1006ef3 100644 --- a/src/modules/content/dtos/post.dto.ts +++ b/src/modules/content/dtos/post.dto.ts @@ -18,8 +18,10 @@ import { import { isNil, toNumber } from 'lodash'; import { PostOrderType } from '@/modules/content/constants'; +import { CategoryEntity, TagEntity } from '@/modules/content/entities'; import { DtoValidation } from '@/modules/core/decorators'; import { toBoolean } from '@/modules/core/helpers'; +import { IsDataExist } from '@/modules/database/constraints'; import { PaginateOptions } from '@/modules/database/types'; /** @@ -49,10 +51,18 @@ export class QueryPostDto implements PaginateOptions { @IsOptional() limit: 10; + @IsDataExist(CategoryEntity, { + always: true, + message: '分类不存在', + }) @IsUUID(undefined, { message: '分类ID必须是UUID' }) @IsOptional() category?: string; + @IsDataExist(TagEntity, { + always: true, + message: '标签不存在', + }) @IsUUID(undefined, { message: '标签ID必须是UUID' }) @IsOptional() tag?: string; @@ -105,10 +115,18 @@ export class CreatePostDto { @IsOptional({ always: true }) customOrder = 0; + @IsDataExist(CategoryEntity, { + message: '分类不存在', + }) @IsUUID(undefined, { message: '分类ID必须是UUID', each: true, always: true }) @IsOptional({ groups: ['update'] }) category: string; + @IsDataExist(TagEntity, { + each: true, + always: true, + message: '标签不存在', + }) @IsUUID(undefined, { each: true, always: true, diff --git a/src/modules/core/constraints/index.ts b/src/modules/core/constraints/index.ts new file mode 100644 index 0000000..598ec80 --- /dev/null +++ b/src/modules/core/constraints/index.ts @@ -0,0 +1,3 @@ +export * from './match.constraint'; +export * from './match.phone.constraint'; +export * from './password.constraint'; diff --git a/src/modules/core/constraints/match.constraint.ts b/src/modules/core/constraints/match.constraint.ts new file mode 100644 index 0000000..099455a --- /dev/null +++ b/src/modules/core/constraints/match.constraint.ts @@ -0,0 +1,41 @@ +import { + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, + registerDecorator, +} from 'class-validator'; + +/** + * 判断两个字段的值是否相等的验证规则 + */ +@ValidatorConstraint({ name: 'isMatch' }) +export class MatchConstraint implements ValidatorConstraintInterface { + validate(value: any, args?: ValidationArguments) { + const [relatedProperty] = args.constraints; + const relatedValue = (args.object as any)[relatedProperty]; + return value === relatedValue; + } + + defaultMessage(args?: ValidationArguments) { + const [relatedProperty] = args.constraints; + return `${relatedProperty} and ${args.property} don't match`; + } +} + +/** + * 判断DTO中两个属性的值是否相等的验证规则 + * @param relatedProperty 用于对比的属性名称 + * @param validationOptions class-validator库的选项 + */ +export function IsMatch(relatedProperty: string, validationOptions?: ValidationOptions) { + return (object: RecordAny, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [relatedProperty], + validator: MatchConstraint, + }); + }; +} diff --git a/src/modules/core/constraints/match.phone.constraint.ts b/src/modules/core/constraints/match.phone.constraint.ts new file mode 100644 index 0000000..0a54049 --- /dev/null +++ b/src/modules/core/constraints/match.phone.constraint.ts @@ -0,0 +1,48 @@ +import { + ValidationArguments, + ValidationOptions, + isMobilePhone, + registerDecorator, +} from 'class-validator'; +import { IsMobilePhoneOptions, MobilePhoneLocale } from 'validator/lib/isMobilePhone'; + +/** + * 手机号验证规则,必须是"区域号.手机号"的形式 + */ +export function isMatchPhone( + value: any, + locale?: MobilePhoneLocale, + options?: IsMobilePhoneOptions, +): boolean { + if (!value) return false; + + const phoneArr: string[] = value.split('.'); + return isMobilePhone(phoneArr.join(''), locale, options); +} + +/** + * 手机号验证规则,必须是"区域号.手机号"的形式 + * @param locales 区域选项 + * @param options isMobilePhone约束选项 + * @param validationOptions class-validator库的选项 + */ +export function IsMatchPhone( + locales?: MobilePhoneLocale | MobilePhoneLocale[], + options?: IsMobilePhoneOptions, + validationOptions?: ValidationOptions, +) { + return (object: RecordAny, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [locales || 'any', options], + validator: { + validate: (value: any, args: ValidationArguments): boolean => + isMatchPhone(value, args.constraints[0], args.constraints[1]), + defaultMessage: (_args: ValidationArguments) => + '$property must be a phone number, eg: +86.12345678901', + }, + }); + }; +} diff --git a/src/modules/core/constraints/password.constraint.ts b/src/modules/core/constraints/password.constraint.ts new file mode 100644 index 0000000..823c205 --- /dev/null +++ b/src/modules/core/constraints/password.constraint.ts @@ -0,0 +1,64 @@ +import { + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, + registerDecorator, +} from 'class-validator'; + +type ModelType = 1 | 2 | 3 | 4 | 5; + +@ValidatorConstraint({ name: 'isPassword', async: false }) +export class IsPasswordConstraint implements ValidatorConstraintInterface { + validate(value: any, args?: ValidationArguments) { + const validateModel: ModelType = args.constraints[0] ?? 1; + + switch (validateModel) { + // 必需由大写或小写字母组成(默认) + case 1: + return /\d/.test(value) && /[a-zA-Z]/.test(value); + // 必需由小写字母组成 + case 2: + return /\d/.test(value) && /[a-z]/.test(value); + // 必须由大写字母组成 + case 3: + return /\d/.test(value) && /[A-Z]/.test(value); + // 必需包含数字,小写字母,大写字母 + case 4: + return ( + /\d/.test(value) && + /[a-z]/.test(value) && + /[A-Z]/.test(value) && + /[!@#$%^&]/.test(value) + ); + default: + return /\d/.test(value) && /[a-zA-Z]/.test(value); + } + } + + defaultMessage(_args?: ValidationArguments) { + return `($value) 's format error!`; + } +} + +/** + * 密码复杂度验证 + * 模式1: 必须由大写或小写字母组成(默认模式) + * 模式2: 必须由小写字母组成 + * 模式3: 必须由大写字母组成 + * 模式4: 必须包含数字,小写字母,大写字母 + * 模式5: 必须包含数字,小写字母,大写字母,特殊符号 + * @param model 验证模式 + * @param validationOptions + */ +export function IsPassword(model?: ModelType, validationOptions?: ValidationOptions) { + return (object: RecordAny, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [model], + validator: IsPasswordConstraint, + }); + }; +} diff --git a/src/modules/database/constraints/data.exist.constraint.ts b/src/modules/database/constraints/data.exist.constraint.ts new file mode 100644 index 0000000..350cdbd --- /dev/null +++ b/src/modules/database/constraints/data.exist.constraint.ts @@ -0,0 +1,94 @@ +import { Injectable } from '@nestjs/common'; +import { + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, + registerDecorator, +} from 'class-validator'; +import { DataSource, ObjectType, Repository } from 'typeorm'; + +type Condition = { + entity: ObjectType; + /** + * 用于查询的比对字段,默认id + */ + map?: string; +}; + +/** + * 查询某个字段的值是否存在数据表中存在 + */ +@ValidatorConstraint({ name: 'dataExist', async: true }) +@Injectable() +export class DataExistConstraint implements ValidatorConstraintInterface { + constructor(private dataSource: DataSource) {} + + async validate(value: string, args?: ValidationArguments) { + let repo: Repository; + if (!value) return true; + + // 默认对比字段是id + let map = 'id'; + // 通过传入的entity获取其repository + if ('entity' in args.constraints[0]) { + map = args.constraints[0].map ?? 'id'; + repo = this.dataSource.getRepository(args.constraints[0].entity); + } else { + repo = this.dataSource.getRepository(args.constraints[0]); + } + // 通过查询记录是否存在进行验证 + const item = await repo.findOne({ where: { [map]: value } }); + return !!item; + } + + defaultMessage(args?: ValidationArguments) { + if (!args.constraints[0]) { + return 'Model not been specified!'; + } + + return `All instance of ${args.constraints[0].name} must been exists in database!`; + } +} + +/** + * 模型存在性验证 + * @param entity + * @param validationOptions + */ +function IsDataExist( + entity: ObjectType, + validationOptions?: ValidationOptions, +): (object: RecordAny, propertyName: string) => void; + +/** + * 模型存在性验证 + * @param condition + * @param validationOptions + */ +function IsDataExist( + condition: Condition, + validationOptions?: ValidationOptions, +): (object: RecordAny, propertyName: string) => void; + +/** + * 模型存在性验证 + * @param condition + * @param validationOptions + */ +function IsDataExist( + condition: ObjectType | Condition, + validationOptions: ValidationOptions, +): (object: RecordAny, propertyName: string) => void { + return (object: RecordAny, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [condition], + validator: DataExistConstraint, + }); + }; +} + +export { IsDataExist }; diff --git a/src/modules/database/constraints/index.ts b/src/modules/database/constraints/index.ts new file mode 100644 index 0000000..5e17887 --- /dev/null +++ b/src/modules/database/constraints/index.ts @@ -0,0 +1,5 @@ +export * from './data.exist.constraint'; +export * from './tree.unique.constraint'; +export * from './tree.unique.exist.constraint'; +export * from './unique.constraint'; +export * from './unique.exist.constraint'; diff --git a/src/modules/database/constraints/tree.unique.constraint.ts b/src/modules/database/constraints/tree.unique.constraint.ts new file mode 100644 index 0000000..7d30121 --- /dev/null +++ b/src/modules/database/constraints/tree.unique.constraint.ts @@ -0,0 +1,90 @@ +import { Injectable } from '@nestjs/common'; +import { + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, + registerDecorator, +} from 'class-validator'; +import { isNil, merge } from 'lodash'; + +import { DataSource, ObjectType } from 'typeorm'; + +type Condition = { + entity: ObjectType; + parentKey?: string; + property?: string; +}; + +/** + * 验证树形模型下同父节点同级别某个字段的唯一性 + */ +@Injectable() +@ValidatorConstraint({ name: 'treeDataUnique', async: true }) +export class UniqueTreeConstraint implements ValidatorConstraintInterface { + constructor(private dataSource: DataSource) {} + + async validate(value: any, args: ValidationArguments) { + const config: Omit = { + parentKey: 'parent', + property: args.property, + }; + const condition = ('entity' in args.constraints[0] + ? merge(config, args.constraints[0]) + : { + ...config, + entity: args.constraints[0], + }) as unknown as Required; + // 需要查询的属性名,默认为当前验证的属性 + const argsObj = args.object as any; + if (!condition.entity) return false; + + try { + // 获取repository + const repo = this.dataSource.getTreeRepository(condition.entity); + + if (isNil(value)) return true; + const collection = await repo.find({ + where: { + parent: !argsObj[condition.parentKey] + ? null + : { id: argsObj[condition.parentKey] }, + }, + withDeleted: true, + }); + // 对比每个子分类的queryProperty值是否与当前验证的dto属性相同,如果有相同的则验证失败 + return collection.every((item) => item[condition.property] !== value); + } catch (err) { + return false; + } + } + + defaultMessage(args: ValidationArguments) { + const { entity, property } = args.constraints[0]; + const queryProperty = property ?? args.property; + if (!entity) { + return 'Model not been specified!'; + } + return `${queryProperty} of ${entity.name} must been unique with siblings element!`; + } +} + +/** + * 树形模型下同父节点同级别某个字段的唯一性验证 + * @param params + * @param validationOptions + */ +export function IsTreeUnique( + params: ObjectType | Condition, + validationOptions?: ValidationOptions, +) { + return (object: Record, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [params], + validator: UniqueTreeConstraint, + }); + }; +} diff --git a/src/modules/database/constraints/tree.unique.exist.constraint.ts b/src/modules/database/constraints/tree.unique.exist.constraint.ts new file mode 100644 index 0000000..d8926c9 --- /dev/null +++ b/src/modules/database/constraints/tree.unique.exist.constraint.ts @@ -0,0 +1,106 @@ +import { Injectable } from '@nestjs/common'; +import { + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, + registerDecorator, +} from 'class-validator'; +import { merge } from 'lodash'; + +import { DataSource, ObjectType } from 'typeorm'; + +type Condition = { + entity: ObjectType; + /** + * 默认忽略字段为id + */ + ignore?: string; + /** + * 查询条件字段,默认为指定的ignore + */ + findKey?: string; + /** + * 需要查询的属性名,默认为当前验证的属性 + */ + property?: string; +}; + +/** + * 在更新时验证树形数据同父节点同级别某个字段的唯一性,通过ignore指定忽略的字段 + */ +@Injectable() +@ValidatorConstraint({ name: 'treeDataUniqueExist', async: true }) +export class UniqueTreeExistConstraint implements ValidatorConstraintInterface { + constructor(private dataSource: DataSource) {} + + async validate(value: any, args: ValidationArguments) { + const config: Omit = { + ignore: 'id', + property: args.property, + }; + const condition = ('entity' in args.constraints[0] + ? merge(config, args.constraints[0]) + : { + ...config, + entity: args.constraints[0], + }) as unknown as Required; + if (!condition.findKey) { + condition.findKey = condition.ignore; + } + if (!condition.entity) return false; + // 在传入的dto数据中获取需要忽略的字段的值 + const ignoreValue = (args.object as any)[condition.ignore]; + // 查询条件字段的值 + const keyValue = (args.object as any)[condition.findKey]; + if (!ignoreValue || !keyValue) return false; + const repo = this.dataSource.getTreeRepository(condition.entity); + // 根据查询条件查询出当前验证的数据 + const item = await repo.findOne({ + where: { [condition.findKey]: keyValue }, + relations: ['parent'], + }); + // 没有此数据则验证失败 + if (!item) return false; + // 如果验证数据没有parent则把所有顶级分类作为验证数据否则就把同一个父分类下的子分类作为验证数据 + const rows: any[] = await repo.find({ + where: { + parent: !item.parent ? null : { id: item.parent.id }, + }, + withDeleted: true, + }); + // 在忽略本身数据后如果同级别其它数据与验证的queryProperty的值相同则验证失败 + return !rows.find( + (row) => row[condition.property] === value && row[condition.ignore] !== ignoreValue, + ); + } + + defaultMessage(args: ValidationArguments) { + const { entity, property } = args.constraints[0]; + const queryProperty = property ?? args.property; + if (!entity) { + return 'Model not been specified!'; + } + return `${queryProperty} of ${entity.name} must been unique with siblings element!`; + } +} + +/** + * 树形数据同父节点同级别某个字段的唯一性验证 + * @param params + * @param validationOptions + */ +export function IsTreeUniqueExist( + params: ObjectType | Condition, + validationOptions?: ValidationOptions, +) { + return (object: Record, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [params], + validator: UniqueTreeExistConstraint, + }); + }; +} diff --git a/src/modules/database/constraints/unique.constraint.ts b/src/modules/database/constraints/unique.constraint.ts new file mode 100644 index 0000000..331af3d --- /dev/null +++ b/src/modules/database/constraints/unique.constraint.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common'; +import { + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, + registerDecorator, +} from 'class-validator'; +import { isNil, merge } from 'lodash'; +import { DataSource, ObjectType } from 'typeorm'; + +type Condition = { + entity: ObjectType; + /** + * 如果没有指定字段则使用当前验证的属性作为查询依据 + */ + property?: string; +}; + +/** + * 验证某个字段的唯一性 + */ +@ValidatorConstraint({ name: 'dataUnique', async: true }) +@Injectable() +export class UniqueConstraint implements ValidatorConstraintInterface { + constructor(private dataSource: DataSource) {} + + async validate(value: any, args: ValidationArguments) { + // 获取要验证的模型和字段 + const config: Omit = { + property: args.property, + }; + const condition = ('entity' in args.constraints[0] + ? merge(config, args.constraints[0]) + : { + ...config, + entity: args.constraints[0], + }) as unknown as Required; + if (!condition.entity) return false; + try { + // 查询是否存在数据,如果已经存在则验证失败 + const repo = this.dataSource.getRepository(condition.entity); + return isNil( + await repo.findOne({ where: { [condition.property]: value }, withDeleted: true }), + ); + } catch (err) { + // 如果数据库操作异常则验证失败 + return false; + } + } + + defaultMessage(args: ValidationArguments) { + const { entity, property } = args.constraints[0]; + const queryProperty = property ?? args.property; + if (!(args.object as any).getManager) { + return 'getManager function not been found!'; + } + if (!entity) { + return 'Model not been specified!'; + } + return `${queryProperty} of ${entity.name} must been unique!`; + } +} + +/** + * 数据唯一性验证 + * @param params Entity类或验证条件对象 + * @param validationOptions + */ +export function IsUnique( + params: ObjectType | Condition, + validationOptions?: ValidationOptions, +) { + return (object: Record, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [params], + validator: UniqueConstraint, + }); + }; +} diff --git a/src/modules/database/constraints/unique.exist.constraint.ts b/src/modules/database/constraints/unique.exist.constraint.ts new file mode 100644 index 0000000..5c6887c --- /dev/null +++ b/src/modules/database/constraints/unique.exist.constraint.ts @@ -0,0 +1,93 @@ +import { Injectable } from '@nestjs/common'; +import { + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, + registerDecorator, +} from 'class-validator'; +import { isNil, merge } from 'lodash'; +import { DataSource, Not, ObjectType } from 'typeorm'; + +type Condition = { + entity: ObjectType; + /** + * 默认忽略字段为id + */ + ignore?: string; + /** + * 如果没有指定字段则使用当前验证的属性作为查询依据 + */ + property?: string; +}; + +/** + * 在更新时验证唯一性,通过指定ignore忽略忽略的字段 + */ +@ValidatorConstraint({ name: 'dataUniqueExist', async: true }) +@Injectable() +export class UniqueExistContraint implements ValidatorConstraintInterface { + constructor(private dataSource: DataSource) {} + + async validate(value: any, args: ValidationArguments) { + const config: Omit = { + ignore: 'id', + property: args.property, + }; + const condition = ('entity' in args.constraints[0] + ? merge(config, args.constraints[0]) + : { + ...config, + entity: args.constraints[0], + }) as unknown as Required; + if (!condition.entity) return false; + // 在传入的dto数据中获取需要忽略的字段的值 + const ignoreValue = (args.object as any)[condition.ignore]; + // 如果忽略字段不存在则验证失败 + if (ignoreValue === undefined) return false; + // 通过entity获取repository + const repo = this.dataSource.getRepository(condition.entity); + // 查询忽略字段之外的数据是否对queryProperty的值唯一 + return isNil( + await repo.findOne({ + where: { + [condition.property]: value, + [condition.ignore]: Not(ignoreValue), + }, + withDeleted: true, + }), + ); + } + + defaultMessage(args: ValidationArguments) { + const { entity, property } = args.constraints[0]; + const queryProperty = property ?? args.property; + if (!(args.object as any).getManager) { + return 'getManager function not been found!'; + } + if (!entity) { + return 'Model not been specified!'; + } + return `${queryProperty} of ${entity.name} must been unique!`; + } +} + +/** + * 更新数据时的唯一性验证 + * @param params Entity类或验证条件对象 + * @param validationOptions + */ +export function IsUniqueExist( + params: ObjectType | Condition, + validationOptions?: ValidationOptions, +) { + return (object: Record, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [params], + validator: UniqueExistContraint, + }); + }; +} diff --git a/src/modules/database/database.module.ts b/src/modules/database/database.module.ts index 843b031..2e9551a 100644 --- a/src/modules/database/database.module.ts +++ b/src/modules/database/database.module.ts @@ -4,6 +4,13 @@ import { TypeOrmModule, TypeOrmModuleOptions, getDataSourceToken } from '@nestjs import { DataSource, ObjectType } from 'typeorm'; import { CUSTOM_REPOSITORY_METADATA } from '@/modules/database/constants'; +import { + DataExistConstraint, + UniqueConstraint, + UniqueExistContraint, + UniqueTreeConstraint, +} from '@/modules/database/constraints'; +import { UniqueTreeExistConstraint } from '@/modules/database/constraints/tree.unique.exist.constraint'; @Module({}) export class DatabaseModule { @@ -12,6 +19,13 @@ export class DatabaseModule { global: true, module: DatabaseModule, imports: [TypeOrmModule.forRoot(configRegister())], + providers: [ + DataExistConstraint, + UniqueConstraint, + UniqueExistContraint, + UniqueTreeConstraint, + UniqueTreeExistConstraint, + ], }; }