diff --git a/back/database4.db b/back/database4.db index c25f1b8..6f8106d 100644 Binary files a/back/database4.db and b/back/database4.db differ diff --git a/src/modules/content/controllers/post.controller.ts b/src/modules/content/controllers/post.controller.ts index 579a54c..465616a 100644 --- a/src/modules/content/controllers/post.controller.ts +++ b/src/modules/content/controllers/post.controller.ts @@ -8,34 +8,50 @@ import { Patch, Post, Query, + SerializeOptions, + UseInterceptors, ValidationPipe, } from '@nestjs/common'; +import { CreatePostDto, UpdatePostDto } from '@/modules/content/dtos'; import { PostService } from '@/modules/content/services'; +import { AppIntercepter } from '@/modules/core/providers'; import { PaginateOptions } from '@/modules/database/types'; /** * 文章控制器 * 负责处理与文章相关的请求,如获取文章列表、创建新文章等。 */ +@UseInterceptors(AppIntercepter) @Controller('posts') export class PostController { constructor(private postService: PostService) {} @Get() + @SerializeOptions({ groups: ['post-list'] }) async list( - @Query() + @Query( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + forbidUnknownValues: true, + validationError: { target: false }, + }), + ) options: PaginateOptions, ) { return this.postService.paginate(options); } @Get(':id') + @SerializeOptions({ groups: ['post-detail'] }) async detail(@Param('id', new ParseUUIDPipe()) id: string) { return this.postService.detail(id); } @Post() + @SerializeOptions({ groups: ['post-detail'] }) async store( @Body( new ValidationPipe({ @@ -46,12 +62,13 @@ export class PostController { groups: ['create'], }), ) - data: RecordAny, + data: CreatePostDto, ) { return this.postService.create(data); } @Patch() + @SerializeOptions({ groups: ['post-detail'] }) async update( @Body( new ValidationPipe({ @@ -62,12 +79,13 @@ export class PostController { groups: ['update'], }), ) - data: RecordAny, + data: UpdatePostDto, ) { return this.postService.update(data); } @Delete(':id') + @SerializeOptions({ groups: ['post-detail'] }) async delete(@Param('id', new ParseUUIDPipe()) id: string) { return this.postService.delete(id); } diff --git a/src/modules/content/dtos/create-post.dto.ts b/src/modules/content/dtos/create-post.dto.ts deleted file mode 100644 index 17aab68..0000000 --- a/src/modules/content/dtos/create-post.dto.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { IsNotEmpty, IsOptional, MaxLength } from 'class-validator'; - -@Injectable() -export class CreatePostDto { - @MaxLength(255, { - always: true, - message: '帖子标题长度最大为$constraint1', - }) - @IsNotEmpty({ groups: ['create'], message: '帖子标题必需填写' }) - @IsOptional({ groups: ['update'] }) - title: string; - - @IsNotEmpty({ groups: ['create'], message: '帖子内容必需填写' }) - @IsOptional({ groups: ['updatae'] }) - body: string; - - @MaxLength(500, { - always: true, - message: '帖子描述长度最大为$constraint1', - }) - @IsOptional({ always: true }) - summary: string; -} diff --git a/src/modules/content/dtos/index.ts b/src/modules/content/dtos/index.ts index e67ec36..f9efaaa 100644 --- a/src/modules/content/dtos/index.ts +++ b/src/modules/content/dtos/index.ts @@ -1,2 +1 @@ -export * from './create-post.dto'; -export * from './update-post.dto'; +export * from './post.dto'; diff --git a/src/modules/content/dtos/post.dto.ts b/src/modules/content/dtos/post.dto.ts new file mode 100644 index 0000000..372c38b --- /dev/null +++ b/src/modules/content/dtos/post.dto.ts @@ -0,0 +1,105 @@ +import { PartialType } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; + +import { + IsBoolean, + IsDateString, + IsDefined, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsUUID, + MaxLength, + Min, + ValidateIf, +} from 'class-validator'; + +import { isNil, toNumber } from 'lodash'; + +import { PostOrderType } from '@/modules/content/constants'; +import { toBoolean } from '@/modules/core/helpers'; +import { PaginateOptions } from '@/modules/database/types'; + +/** + * 文章分页查询验证 + */ +export class QueryPostDto implements PaginateOptions { + @Transform(({ value }) => toBoolean(value)) + @IsBoolean() + @IsOptional() + isPublished?: boolean; + + @IsEnum(PostOrderType, { + message: `排序规则必需是${Object.values(PostOrderType).join(',')}中的一项`, + }) + orderBy?: PostOrderType; + + @Transform(({ value }) => toNumber(value)) + @Min(1, { message: '当前页必需大于1' }) + @IsNumber() + @IsOptional() + page = 1; + + @Transform(({ value }) => toNumber(value)) + @Min(1, { message: '每页显示数据必须大于1' }) + @IsNumber() + @IsOptional() + limit: 10; +} + +/** + * 文章创建验证 + */ +export class CreatePostDto { + @MaxLength(255, { + always: true, + message: '文章标题长度最大为$constraint1', + }) + @IsNotEmpty({ + groups: ['create'], + message: '文章标题为必填', + }) + @IsOptional({ groups: ['update'] }) + title: string; + + @IsNotEmpty({ groups: ['create'], message: '文章内容为必填' }) + @IsOptional({ groups: ['update'] }) + body: string; + + @MaxLength(500, { + always: true, + message: '文章摘要长度最大为$constraint1', + }) + @IsOptional({ always: true }) + summary?: string; + + @IsDateString({ strict: true }, { always: true }) + @IsOptional({ always: true }) + @ValidateIf((value) => !isNil(value.publishedAt)) + @Transform(({ value }) => (value === 'null' ? null : value)) + publishedAt?: Date; + + @MaxLength(20, { + each: true, + always: true, + message: '每个关键字长度最大为$constraint1', + }) + @IsOptional({ always: true }) + keywords?: string[]; + + @Transform(({ value }) => toNumber(value)) + @Min(0, { always: true, message: '排序值必须大于0' }) + @IsNumber(undefined, { always: true }) + @IsOptional({ always: true }) + customOrder = 0; +} + +/** + * 文章更新验证 + */ +export class UpdatePostDto extends PartialType(CreatePostDto) { + @IsUUID(undefined, { groups: ['update'], message: '文章ID格式错误' }) + @IsDefined({ groups: ['update'], message: '文章ID必须指定' }) + id: string; +} diff --git a/src/modules/content/dtos/update-post.dto.ts b/src/modules/content/dtos/update-post.dto.ts deleted file mode 100644 index 5fc4d77..0000000 --- a/src/modules/content/dtos/update-post.dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { PartialType } from '@nestjs/swagger'; - -import { IsDefined, IsNumber } from 'class-validator'; - -import { CreatePostDto } from './create-post.dto'; - -@Injectable() -export class UpdatePostDto extends PartialType(CreatePostDto) { - @IsNumber(undefined, { groups: ['update'], message: '帖子ID格式错误' }) - @IsDefined({ groups: ['update'], message: '帖子ID必需指定' }) - id: number; -} diff --git a/src/modules/content/entities/post.entity.ts b/src/modules/content/entities/post.entity.ts index c93c0ac..550f265 100644 --- a/src/modules/content/entities/post.entity.ts +++ b/src/modules/content/entities/post.entity.ts @@ -1,3 +1,4 @@ +import { Exclude, Expose } from 'class-transformer'; import { BaseEntity, Column, @@ -9,35 +10,46 @@ import { import { PostBodyType } from '@/modules/content/constants'; +@Exclude() @Entity('content_posts') export class PostEntity extends BaseEntity { + @Expose() @PrimaryColumn({ type: 'varchar', generated: 'uuid', length: '36' }) id: string; + @Expose() @Column({ comment: '文章标题' }) title: string; + @Expose({ groups: ['post-detail'] }) @Column({ comment: '文章内容', type: 'text' }) body: string; + @Expose() @Column({ comment: '文章摘要', nullable: true }) summary: string; + @Expose() @Column({ comment: '关键字', type: 'simple-array', nullable: true }) keywords?: string[]; + @Expose() @Column({ comment: '文章类型', type: 'varchar', default: PostBodyType.MD }) type: PostBodyType; + @Expose() @Column({ comment: '发布时间', type: 'varchar', nullable: true }) publishedAt?: Date | null; + @Expose() @CreateDateColumn({ comment: '创建时间' }) createdAt: Date; + @Expose() @UpdateDateColumn({ comment: '更新时间' }) updatedAt: Date; + @Expose() @Column({ comment: '文章自定义排序', default: 0 }) customOrder: number; } diff --git a/src/modules/content/services/post.service.ts b/src/modules/content/services/post.service.ts index 3205bd5..30f8851 100644 --- a/src/modules/content/services/post.service.ts +++ b/src/modules/content/services/post.service.ts @@ -5,6 +5,7 @@ import { isFunction, isNil, omit } from 'lodash'; import { EntityNotFoundError, IsNull, Not, SelectQueryBuilder } from 'typeorm'; import { PostOrderType } from '@/modules/content/constants'; +import { CreatePostDto, UpdatePostDto } from '@/modules/content/dtos'; import { PostEntity } from '@/modules/content/entities'; import { PostRepository } from '@/modules/content/repositories'; @@ -43,7 +44,7 @@ export class PostService { * 创建文章 * @param data */ - async create(data: Record) { + async create(data: CreatePostDto) { const item = await this.repository.save(data); return this.detail(item.id); @@ -53,7 +54,7 @@ export class PostService { * 更新文章 * @param data */ - async update(data: Record) { + async update(data: UpdatePostDto) { await this.repository.update(data.id, omit(data, ['id'])); return this.detail(data.id); } diff --git a/src/modules/core/providers/app.interceptor.ts b/src/modules/core/providers/app.interceptor.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/core/providers/index.ts b/src/modules/core/providers/index.ts new file mode 100644 index 0000000..201f443 --- /dev/null +++ b/src/modules/core/providers/index.ts @@ -0,0 +1,40 @@ +import { + ClassSerializerContextOptions, + ClassSerializerInterceptor, + PlainLiteralObject, + StreamableFile, +} from '@nestjs/common'; +import { PinoLoggerOptions } from 'fastify/types/logger'; +import { isArray, isNil, isObject } from 'lodash'; + +export class AppIntercepter extends ClassSerializerInterceptor { + serialize( + response: PlainLiteralObject | PlainLiteralObject[], + options: ClassSerializerContextOptions, + ): PlainLiteralObject | PlainLiteralObject[] { + if ((!isObject(response) && !isArray(response)) || response instanceof StreamableFile) { + return response; + } + + // 数组处理 - 如果是数组则对数组每一项元素序列化 + if (isArray(response)) { + return (response as PlainLiteralObject[]).map((item) => + !isObject(item) ? item : this.transformToPlain(item, options), + ); + } + + // 分页处理 - 对items中的每一项进行序列化 + if ('meta' in response && 'items' in response) { + const items = !isNil(response.items) && isArray(response.items) ? response.items : []; + + return { + ...response, + items: (items as PinoLoggerOptions[]).map((item) => + !isObject(item) ? item : this.transformToPlain(item, options), + ), + }; + } + + return this.transformToPlain(response, options); + } +}