feat: 数据验证和数据序列化处理

- 增加了DTO和验证
 - 服务类和控制器加入了dto
 - 增加序列化拦截器,对数组和分页特殊处理
 - 实体中分配了序列化装饰器
 - 控制器引入拦截器
This commit is contained in:
3R-喜东东 2023-11-22 18:50:56 +08:00
parent db1fcf05bc
commit ef90d0550b
10 changed files with 182 additions and 45 deletions

Binary file not shown.

View File

@ -8,34 +8,50 @@ import {
Patch, Patch,
Post, Post,
Query, Query,
SerializeOptions,
UseInterceptors,
ValidationPipe, ValidationPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { CreatePostDto, UpdatePostDto } from '@/modules/content/dtos';
import { PostService } from '@/modules/content/services'; import { PostService } from '@/modules/content/services';
import { AppIntercepter } from '@/modules/core/providers';
import { PaginateOptions } from '@/modules/database/types'; import { PaginateOptions } from '@/modules/database/types';
/** /**
* *
* *
*/ */
@UseInterceptors(AppIntercepter)
@Controller('posts') @Controller('posts')
export class PostController { export class PostController {
constructor(private postService: PostService) {} constructor(private postService: PostService) {}
@Get() @Get()
@SerializeOptions({ groups: ['post-list'] })
async list( async list(
@Query() @Query(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
validationError: { target: false },
}),
)
options: PaginateOptions, options: PaginateOptions,
) { ) {
return this.postService.paginate(options); return this.postService.paginate(options);
} }
@Get(':id') @Get(':id')
@SerializeOptions({ groups: ['post-detail'] })
async detail(@Param('id', new ParseUUIDPipe()) id: string) { async detail(@Param('id', new ParseUUIDPipe()) id: string) {
return this.postService.detail(id); return this.postService.detail(id);
} }
@Post() @Post()
@SerializeOptions({ groups: ['post-detail'] })
async store( async store(
@Body( @Body(
new ValidationPipe({ new ValidationPipe({
@ -46,12 +62,13 @@ export class PostController {
groups: ['create'], groups: ['create'],
}), }),
) )
data: RecordAny, data: CreatePostDto,
) { ) {
return this.postService.create(data); return this.postService.create(data);
} }
@Patch() @Patch()
@SerializeOptions({ groups: ['post-detail'] })
async update( async update(
@Body( @Body(
new ValidationPipe({ new ValidationPipe({
@ -62,12 +79,13 @@ export class PostController {
groups: ['update'], groups: ['update'],
}), }),
) )
data: RecordAny, data: UpdatePostDto,
) { ) {
return this.postService.update(data); return this.postService.update(data);
} }
@Delete(':id') @Delete(':id')
@SerializeOptions({ groups: ['post-detail'] })
async delete(@Param('id', new ParseUUIDPipe()) id: string) { async delete(@Param('id', new ParseUUIDPipe()) id: string) {
return this.postService.delete(id); return this.postService.delete(id);
} }

View File

@ -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;
}

View File

@ -1,2 +1 @@
export * from './create-post.dto'; export * from './post.dto';
export * from './update-post.dto';

View File

@ -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;
}

View File

@ -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;
}

View File

@ -1,3 +1,4 @@
import { Exclude, Expose } from 'class-transformer';
import { import {
BaseEntity, BaseEntity,
Column, Column,
@ -9,35 +10,46 @@ import {
import { PostBodyType } from '@/modules/content/constants'; import { PostBodyType } from '@/modules/content/constants';
@Exclude()
@Entity('content_posts') @Entity('content_posts')
export class PostEntity extends BaseEntity { export class PostEntity extends BaseEntity {
@Expose()
@PrimaryColumn({ type: 'varchar', generated: 'uuid', length: '36' }) @PrimaryColumn({ type: 'varchar', generated: 'uuid', length: '36' })
id: string; id: string;
@Expose()
@Column({ comment: '文章标题' }) @Column({ comment: '文章标题' })
title: string; title: string;
@Expose({ groups: ['post-detail'] })
@Column({ comment: '文章内容', type: 'text' }) @Column({ comment: '文章内容', type: 'text' })
body: string; body: string;
@Expose()
@Column({ comment: '文章摘要', nullable: true }) @Column({ comment: '文章摘要', nullable: true })
summary: string; summary: string;
@Expose()
@Column({ comment: '关键字', type: 'simple-array', nullable: true }) @Column({ comment: '关键字', type: 'simple-array', nullable: true })
keywords?: string[]; keywords?: string[];
@Expose()
@Column({ comment: '文章类型', type: 'varchar', default: PostBodyType.MD }) @Column({ comment: '文章类型', type: 'varchar', default: PostBodyType.MD })
type: PostBodyType; type: PostBodyType;
@Expose()
@Column({ comment: '发布时间', type: 'varchar', nullable: true }) @Column({ comment: '发布时间', type: 'varchar', nullable: true })
publishedAt?: Date | null; publishedAt?: Date | null;
@Expose()
@CreateDateColumn({ comment: '创建时间' }) @CreateDateColumn({ comment: '创建时间' })
createdAt: Date; createdAt: Date;
@Expose()
@UpdateDateColumn({ comment: '更新时间' }) @UpdateDateColumn({ comment: '更新时间' })
updatedAt: Date; updatedAt: Date;
@Expose()
@Column({ comment: '文章自定义排序', default: 0 }) @Column({ comment: '文章自定义排序', default: 0 })
customOrder: number; customOrder: number;
} }

View File

@ -5,6 +5,7 @@ import { isFunction, isNil, omit } from 'lodash';
import { EntityNotFoundError, IsNull, Not, SelectQueryBuilder } from 'typeorm'; import { EntityNotFoundError, IsNull, Not, SelectQueryBuilder } from 'typeorm';
import { PostOrderType } from '@/modules/content/constants'; import { PostOrderType } from '@/modules/content/constants';
import { CreatePostDto, UpdatePostDto } from '@/modules/content/dtos';
import { PostEntity } from '@/modules/content/entities'; import { PostEntity } from '@/modules/content/entities';
import { PostRepository } from '@/modules/content/repositories'; import { PostRepository } from '@/modules/content/repositories';
@ -43,7 +44,7 @@ export class PostService {
* *
* @param data * @param data
*/ */
async create(data: Record<string, any>) { async create(data: CreatePostDto) {
const item = await this.repository.save(data); const item = await this.repository.save(data);
return this.detail(item.id); return this.detail(item.id);
@ -53,7 +54,7 @@ export class PostService {
* *
* @param data * @param data
*/ */
async update(data: Record<string, any>) { async update(data: UpdatePostDto) {
await this.repository.update(data.id, omit(data, ['id'])); await this.repository.update(data.id, omit(data, ['id']));
return this.detail(data.id); return this.detail(data.id);
} }

View File

@ -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);
}
}