diff --git a/.gitignore b/.gitignore index 22f55ad..f3e2ffe 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,5 @@ lerna-debug.log* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json -!.vscode/extensions.json \ No newline at end of file +!.vscode/extensions.json +.vercel diff --git a/src/app.module.ts b/src/app.module.ts index b257476..7aa7b8d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -3,12 +3,33 @@ import { Module } from '@nestjs/common'; import { database } from '@/config'; import { ContentModule } from '@/modules/content/content.module'; import { CoreModule } from '@/modules/core/core.module'; +import { AppFilter, AppIntercepter, AppPipe } from '@/modules/core/providers'; import { DatabaseModule } from '@/modules/database/database.module'; import { WelcomeModule } from '@/modules/welcome/welcome.module'; +import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; @Module({ imports: [DatabaseModule.forRoot(database), ContentModule, WelcomeModule, CoreModule.forRoot()], controllers: [], - providers: [], + providers: [ + { + provide: APP_PIPE, + useValue: new AppPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + forbidUnknownValues: true, + validationError: { target: false }, + }), + }, + { + provide: APP_INTERCEPTOR, + useClass: AppIntercepter, + }, + { + provide: APP_FILTER, + useClass: AppFilter, + }, + ], }) export class AppModule {} diff --git a/src/modules/content/controllers/category.controller.ts b/src/modules/content/controllers/category.controller.ts index 8fb3bad..211f9e7 100644 --- a/src/modules/content/controllers/category.controller.ts +++ b/src/modules/content/controllers/category.controller.ts @@ -9,15 +9,11 @@ import { Post, Query, SerializeOptions, - UseInterceptors, - ValidationPipe, } from '@nestjs/common'; import { CreateCategoryDto, QueryCategoryDto, UpdateCategoryDto } from '@/modules/content/dtos'; import { CategoryService } from '@/modules/content/services'; -import { AppIntercepter } from '@/modules/core/providers'; -@UseInterceptors(AppIntercepter) @Controller('categories') export class CategoryController { constructor(protected service: CategoryService) {} @@ -31,15 +27,7 @@ export class CategoryController { @Get() @SerializeOptions({ groups: ['category-list'] }) async list( - @Query( - new ValidationPipe({ - transform: true, - whitelist: true, - forbidNonWhitelisted: true, - forbidUnknownValues: true, - validationError: { target: false }, - }), - ) + @Query() options: QueryCategoryDto, ) { return this.service.paginate(options); @@ -54,16 +42,7 @@ export class CategoryController { @Post() @SerializeOptions({ groups: ['category-detail'] }) async create( - @Body( - new ValidationPipe({ - transform: true, - whitelist: true, - forbidNonWhitelisted: true, - forbidUnknownValues: true, - validationError: { target: false }, - groups: ['create'], - }), - ) + @Body() data: CreateCategoryDto, ) { return this.service.create(data); @@ -72,16 +51,7 @@ export class CategoryController { @Patch() @SerializeOptions({ groups: ['category-detail'] }) async update( - @Body( - new ValidationPipe({ - transform: true, - whitelist: true, - forbidNonWhitelisted: true, - forbidUnknownValues: true, - validationError: { target: false }, - groups: ['update'], - }), - ) + @Body() data: UpdateCategoryDto, ) { return this.service.update(data); diff --git a/src/modules/content/controllers/comment.controller.ts b/src/modules/content/controllers/comment.controller.ts index 657a2f1..d3f7546 100644 --- a/src/modules/content/controllers/comment.controller.ts +++ b/src/modules/content/controllers/comment.controller.ts @@ -8,15 +8,11 @@ import { Post, Query, SerializeOptions, - UseInterceptors, - ValidationPipe, } from '@nestjs/common'; import { CreateCommentDto, QueryCommentDto, QueryCommentTreeDto } from '@/modules/content/dtos'; import { CommentService } from '@/modules/content/services'; -import { AppIntercepter } from '@/modules/core/providers'; -@UseInterceptors(AppIntercepter) @Controller('comments') export class CommentController { constructor(protected service: CommentService) {} @@ -24,15 +20,7 @@ export class CommentController { @Get('tree') @SerializeOptions({ groups: ['comment-tree'] }) async tree( - @Query( - new ValidationPipe({ - transform: true, - whitelist: true, - forbidNonWhitelisted: true, - forbidUnknownValues: true, - validationError: { target: false }, - }), - ) + @Query() query: QueryCommentTreeDto, ) { return this.service.findTrees(query); @@ -41,13 +29,7 @@ export class CommentController { @Get() @SerializeOptions({ groups: ['comment-list'] }) async list( - @Query( - new ValidationPipe({ - transform: true, - forbidUnknownValues: true, - validationError: { target: false }, - }), - ) + @Query() query: QueryCommentDto, ) { return this.service.paginate(query); @@ -56,15 +38,7 @@ export class CommentController { @Post() @SerializeOptions({ groups: ['comment-detail'] }) async store( - @Body( - new ValidationPipe({ - transform: true, - whitelist: true, - forbidNonWhitelisted: true, - forbidUnknownValues: true, - validationError: { target: false }, - }), - ) + @Body() data: CreateCommentDto, ) { return this.service.create(data); diff --git a/src/modules/content/controllers/post.controller.ts b/src/modules/content/controllers/post.controller.ts index c93c813..195e161 100644 --- a/src/modules/content/controllers/post.controller.ts +++ b/src/modules/content/controllers/post.controller.ts @@ -9,19 +9,16 @@ import { Post, Query, SerializeOptions, - UseInterceptors, - ValidationPipe, } from '@nestjs/common'; import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos'; import { PostService } from '@/modules/content/services'; -import { AppIntercepter } from '@/modules/core/providers'; /** * 文章控制器 * 负责处理与文章相关的请求,如获取文章列表、创建新文章等。 */ -@UseInterceptors(AppIntercepter) + @Controller('posts') export class PostController { constructor(private postService: PostService) {} @@ -29,15 +26,7 @@ export class PostController { @Get() @SerializeOptions({ groups: ['post-list'] }) async list( - @Query( - new ValidationPipe({ - transform: true, - whitelist: true, - forbidNonWhitelisted: true, - forbidUnknownValues: true, - validationError: { target: false }, - }), - ) + @Query() options: QueryPostDto, ) { return this.postService.paginate(options); @@ -52,15 +41,7 @@ export class PostController { @Post() @SerializeOptions({ groups: ['post-detail'] }) async store( - @Body( - new ValidationPipe({ - transform: true, - forbidNonWhitelisted: true, - forbidUnknownValues: true, - validationError: { target: false }, - groups: ['create'], - }), - ) + @Body() data: CreatePostDto, ) { return this.postService.create(data); @@ -69,15 +50,7 @@ export class PostController { @Patch() @SerializeOptions({ groups: ['post-detail'] }) async update( - @Body( - new ValidationPipe({ - transform: true, - forbidNonWhitelisted: true, - forbidUnknownValues: true, - validationError: { target: false }, - groups: ['update'], - }), - ) + @Body() data: UpdatePostDto, ) { return this.postService.update(data); diff --git a/src/modules/content/controllers/tag.controller.ts b/src/modules/content/controllers/tag.controller.ts index 4158430..d9be592 100644 --- a/src/modules/content/controllers/tag.controller.ts +++ b/src/modules/content/controllers/tag.controller.ts @@ -9,15 +9,11 @@ import { Post, Query, SerializeOptions, - UseInterceptors, - ValidationPipe, } from '@nestjs/common'; import { CreateTagDto, QueryCategoryDto, UpdateTagDto } from '@/modules/content/dtos'; import { TagService } from '@/modules/content/services'; -import { AppIntercepter } from '@/modules/core/providers'; -@UseInterceptors(AppIntercepter) @Controller('tags') export class TagController { constructor(protected service: TagService) {} @@ -25,15 +21,7 @@ export class TagController { @Get() @SerializeOptions({}) async list( - @Query( - new ValidationPipe({ - transform: true, - whitelist: true, - forbidNonWhitelisted: true, - forbidUnknownValues: true, - validationError: { target: false }, - }), - ) + @Query() options: QueryCategoryDto, ) { return this.service.paginate(options); @@ -51,16 +39,7 @@ export class TagController { @Post() @SerializeOptions({}) async store( - @Body( - new ValidationPipe({ - transform: true, - whitelist: true, - forbidNonWhitelisted: true, - forbidUnknownValues: true, - validationError: { target: false }, - groups: ['create'], - }), - ) + @Body() data: CreateTagDto, ) { return this.service.create(data); @@ -69,16 +48,7 @@ export class TagController { @Patch() @SerializeOptions({}) async update( - @Body( - new ValidationPipe({ - transform: true, - whitelist: true, - forbidNonWhitelisted: true, - forbidUnknownValues: true, - validationError: { target: false }, - groups: ['update'], - }), - ) + @Body() data: UpdateTagDto, ) { return this.service.update(data); diff --git a/src/modules/content/dtos/category.dto.ts b/src/modules/content/dtos/category.dto.ts index 3ad295e..e42a872 100644 --- a/src/modules/content/dtos/category.dto.ts +++ b/src/modules/content/dtos/category.dto.ts @@ -12,8 +12,10 @@ import { } from 'class-validator'; import { toNumber } from 'lodash'; +import { DtoValidation } from '@/modules/core/decorators'; import { PaginateOptions } from '@/modules/database/types'; +@DtoValidation({ type: 'query' }) export class QueryCategoryDto implements PaginateOptions { @Transform(({ value }) => toNumber(value)) @Min(1, { message: '当前页数必须大于1' }) @@ -31,6 +33,7 @@ export class QueryCategoryDto implements PaginateOptions { /** * 分类新增验证 */ +@DtoValidation({ groups: ['create'] }) export class CreateCategoryDto { @MaxLength(25, { always: true, @@ -56,6 +59,7 @@ export class CreateCategoryDto { /** * 分类更新验证 */ +@DtoValidation({ groups: ['update'] }) export class UpdateCategoryDto extends PartialType(CreateCategoryDto) { @IsUUID(undefined, { groups: ['update'], message: '分类ID格式不正确' }) @IsDefined({ groups: ['update'], message: '分类ID必须指定' }) diff --git a/src/modules/content/dtos/post.dto.ts b/src/modules/content/dtos/post.dto.ts index edf7994..562d472 100644 --- a/src/modules/content/dtos/post.dto.ts +++ b/src/modules/content/dtos/post.dto.ts @@ -18,12 +18,14 @@ import { import { isNil, toNumber } from 'lodash'; import { PostOrderType } from '@/modules/content/constants'; +import { DtoValidation } from '@/modules/core/decorators'; import { toBoolean } from '@/modules/core/helpers'; import { PaginateOptions } from '@/modules/database/types'; /** * 文章分页查询验证 */ +@DtoValidation({ type: 'query' }) export class QueryPostDto implements PaginateOptions { @Transform(({ value }) => toBoolean(value)) @IsBoolean() @@ -59,6 +61,7 @@ export class QueryPostDto implements PaginateOptions { /** * 文章创建验证 */ +@DtoValidation({ groups: ['create'] }) export class CreatePostDto { @MaxLength(255, { always: true, @@ -119,6 +122,7 @@ export class CreatePostDto { /** * 文章更新验证 */ +@DtoValidation({ groups: ['update'] }) export class UpdatePostDto extends PartialType(CreatePostDto) { @IsUUID(undefined, { groups: ['update'], message: '文章ID格式错误' }) @IsDefined({ groups: ['update'], message: '文章ID必须指定' }) diff --git a/src/modules/content/dtos/tag.dto.ts b/src/modules/content/dtos/tag.dto.ts index f7f48ba..a317a53 100644 --- a/src/modules/content/dtos/tag.dto.ts +++ b/src/modules/content/dtos/tag.dto.ts @@ -11,11 +11,13 @@ import { } from 'class-validator'; import { toNumber } from 'lodash'; +import { DtoValidation } from '@/modules/core/decorators'; import { PaginateOptions } from '@/modules/database/types'; /** * 标签分页查询验证 */ +@DtoValidation({ type: 'query' }) export class QueryTagsDto implements PaginateOptions { @Transform(({ value }) => toNumber(value)) @Min(1, { message: '当前页数必须大于1' }) @@ -33,6 +35,7 @@ export class QueryTagsDto implements PaginateOptions { /** * 标签新增验证 */ +@DtoValidation({ groups: ['create'] }) export class CreateTagDto { @MaxLength(25, { always: true, @@ -53,6 +56,7 @@ export class CreateTagDto { /** * 标签更新验证 */ +@DtoValidation({ groups: ['update'] }) export class UpdateTagDto extends PartialType(CreateTagDto) { @IsUUID(undefined, { groups: ['update'], message: '标签ID格式不正确' }) @IsDefined({ groups: ['update'], message: '标签ID必须指定' }) diff --git a/src/modules/core/constants.ts b/src/modules/core/constants.ts new file mode 100644 index 0000000..9de11eb --- /dev/null +++ b/src/modules/core/constants.ts @@ -0,0 +1,4 @@ +/** + * DTOValidation 装饰器选项 + */ +export const DTO_VALIDATION_OPTIONS = 'dto_validation_options'; diff --git a/src/modules/core/decorators/dto-validation.decorator.ts b/src/modules/core/decorators/dto-validation.decorator.ts new file mode 100644 index 0000000..1478f18 --- /dev/null +++ b/src/modules/core/decorators/dto-validation.decorator.ts @@ -0,0 +1,14 @@ +import { DTO_VALIDATION_OPTIONS } from '@/modules/core/constants'; +import { Paramtype, SetMetadata } from '@nestjs/common'; +import { ClassTransformOptions } from 'class-transformer'; +import { ValidatorOptions } from 'class-validator'; + +/** + * 用于配置通过全局验证管道验证数据的DTO类装饰器 + * @params options + */ +export const DtoValidation = ( + options?: ValidatorOptions & { transformOptions?: ClassTransformOptions } & { + type?: Paramtype; + }, +) => SetMetadata(DTO_VALIDATION_OPTIONS, options ?? {}); diff --git a/src/modules/core/decorators/index.ts b/src/modules/core/decorators/index.ts new file mode 100644 index 0000000..1dce40e --- /dev/null +++ b/src/modules/core/decorators/index.ts @@ -0,0 +1 @@ +export * from './dto-validation.decorator'; diff --git a/src/modules/core/providers/app.filter.ts b/src/modules/core/providers/app.filter.ts new file mode 100644 index 0000000..3e2fda2 --- /dev/null +++ b/src/modules/core/providers/app.filter.ts @@ -0,0 +1,52 @@ +import { ArgumentsHost, Catch, HttpException, HttpStatus, Type } from '@nestjs/common'; +import { BaseExceptionFilter } from '@nestjs/core'; +import { isObject } from 'lodash'; +import { EntityNotFoundError, EntityPropertyNotFoundError, QueryFailedError } from 'typeorm'; + +/** + * 全局过滤器,用于响应自定义异常 + */ +@Catch() +export class AppFilter extends BaseExceptionFilter { + protected resExceptions: Array<{ class: Type; status?: number } | Type> = [ + { class: EntityNotFoundError, status: HttpStatus.NOT_FOUND }, + { class: QueryFailedError, status: HttpStatus.BAD_REQUEST }, + { class: EntityPropertyNotFoundError, status: HttpStatus.BAD_REQUEST }, + ]; + + // eslint-disable-next-line consistent-return + catch(exception: T, host: ArgumentsHost) { + const applicationRef = + this.applicationRef || (this.httpAdapterHost && this.httpAdapterHost.httpAdapter)!; + // 是否在自定义的异常处理类列表中 + const resException = this.resExceptions.find((item) => + 'class' in item ? exception instanceof item.class : exception instanceof item, + ); + + // 如果不在自定义异常处理类列表也没有继承自HttpException + if (!resException && !(exception instanceof HttpException)) { + return this.handleUnknownError(exception, host, applicationRef); + } + let res: string | object = ''; + let status = HttpStatus.INTERNAL_SERVER_ERROR; + if (exception instanceof HttpException) { + res = exception.getResponse(); + status = exception.getStatus(); + } else if (resException) { + // 如果在自定义异常处理类列表中 + const e = exception as unknown as Error; + res = e.message; + if ('class' in resException && resException.status) { + status = resException.status; + } + } + const message = isObject(res) + ? res + : { + statusCode: status, + message: res, + }; + + applicationRef!.reply(host.getArgByIndex(1), message, status); + } +} diff --git a/src/modules/core/providers/app.pipe.ts b/src/modules/core/providers/app.pipe.ts new file mode 100644 index 0000000..8f8c042 --- /dev/null +++ b/src/modules/core/providers/app.pipe.ts @@ -0,0 +1,82 @@ +import { DTO_VALIDATION_OPTIONS } from '@/modules/core/constants'; +import { deepMerge } from '@/modules/core/helpers'; +import { + ArgumentMetadata, + BadRequestException, + Injectable, + Paramtype, + ValidationPipe, +} from '@nestjs/common'; +import { isObject, omit } from 'lodash'; + +/** + * 全局管道,用于处理DTO验证 + */ +@Injectable() +export class AppPipe extends ValidationPipe { + async transform(value: any, metadata: ArgumentMetadata) { + const { metatype, type } = metadata; + // 获取要验证的dto类 + const dto = metatype as any; + // 获取dto类的装饰器元数据中的自定义验证选项 + const options = Reflect.getMetadata(DTO_VALIDATION_OPTIONS, dto) || {}; + // 把当前已设置的选项解构到备份对象 + const originOptions = { ...this.validatorOptions }; + // 把当前已设置的class-transform选项解构到备份对象 + const originTransform = { ...this.transformOptions }; + // 把自定义的class-transform和type选项解构 + const { transformOptions, type: optionsType, ...customOptions } = options; + // 根据DTO类上设置的type来设置当前的DTO请求类型,默认为'body' + const requestType: Paramtype = optionsType ?? 'body'; + + // 如果被验证的DTO设置的请求类型与被验证的数据的请求类型不是同一种类型 跳过此管道 + if (requestType !== type) return value; + + // 合并当前transform选项和自定义选项 + if (transformOptions) { + this.transformOptions = deepMerge( + this.transformOptions, + transformOptions ?? {}, + 'replace', + ); + } + + // 合并当前验证选项和自定义选项 + this.validatorOptions = deepMerge(this.validatorOptions, customOptions ?? {}, 'replace'); + const toValidate = isObject(value) + ? Object.fromEntries( + Object.entries(value as RecordAny).map(([key, v]) => { + if (!isObject(v) || !('mimetype' in v)) return [key, v]; + return [key, omit(v, ['fields'])]; + }), + ) + : value; + + try { + // 序列化并验证dto对象 + let result = await super.transform(toValidate, metadata); + + // 如果dto类中存在transform静态方法,则返回调用进一步transform之后的结果 + if (typeof result.transform === 'function') { + result === (await result.transform(result)); + const { transform, ...data } = result; + result = data; + } + + // 重置验证选项 + this.validatorOptions = originOptions; + // 重置transform选项 + this.transformOptions = originTransform; + + return result; + } catch (error: any) { + // 重置验证选项 + this.validatorOptions = originOptions; + // 重置transform选项 + this.transformOptions = originTransform; + + if ('response' in error) throw new BadRequestException(error.response); + throw new BadRequestException(error); + } + } +} diff --git a/src/modules/core/providers/index.ts b/src/modules/core/providers/index.ts index 4110158..a0164d7 100644 --- a/src/modules/core/providers/index.ts +++ b/src/modules/core/providers/index.ts @@ -1 +1,3 @@ +export * from './app.filter'; export * from './app.interceptor'; +export * from './app.pipe';