diff --git a/src/modules/content/controllers/post.controller.ts b/src/modules/content/controllers/post.controller.ts index 56653a8..8d23a1f 100644 --- a/src/modules/content/controllers/post.controller.ts +++ b/src/modules/content/controllers/post.controller.ts @@ -14,6 +14,8 @@ import { import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos/post.dto'; import { PostService } from '@/modules/content/services/post.service'; +import { DeleteWithTrashDto, RestoreDto } from '../dtos/delete.with.trash.dto'; + @Controller('posts') export class PostController { constructor(private postService: PostService) {} @@ -51,9 +53,18 @@ export class PostController { return this.postService.update(data); } - @Delete(':id') + @Delete() @SerializeOptions({ groups: ['post-detail'] }) - async delete(@Param('id', new ParseUUIDPipe()) id: string) { - return this.postService.delete(id); + async delete(@Body() data: DeleteWithTrashDto) { + return this.postService.delete(data.ids, data.trash); + } + + @Patch('restore') + @SerializeOptions({ groups: ['post-detail'] }) + async restore( + @Body() + data: RestoreDto, + ) { + return this.postService.restore(data.ids); } } diff --git a/src/modules/content/dtos/delete.dto.ts b/src/modules/content/dtos/delete.dto.ts new file mode 100644 index 0000000..faf6583 --- /dev/null +++ b/src/modules/content/dtos/delete.dto.ts @@ -0,0 +1,10 @@ +import { IsUUID, IsDefined } from 'class-validator'; + +import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator'; + +@DtoValidation() +export class DeleteDto { + @IsUUID(undefined, { each: true, always: true, message: 'The ID format is incorrect' }) + @IsDefined({ each: true, message: 'The ID must be specified' }) + ids: string[]; +} diff --git a/src/modules/content/dtos/delete.with.trash.dto.ts b/src/modules/content/dtos/delete.with.trash.dto.ts new file mode 100644 index 0000000..c97a6a8 --- /dev/null +++ b/src/modules/content/dtos/delete.with.trash.dto.ts @@ -0,0 +1,19 @@ +import { Transform } from 'class-transformer'; + +import { IsBoolean, IsDefined, IsOptional, IsUUID } from 'class-validator'; +import { toBoolean } from 'validator'; + +import { DeleteDto } from './delete.dto'; + +export class DeleteWithTrashDto extends DeleteDto { + @Transform(({ value }) => toBoolean(value)) + @IsBoolean() + @IsOptional() + trash?: boolean; +} + +export class RestoreDto { + @IsUUID(undefined, { each: true, always: true, message: 'The ID format is incorrect' }) + @IsDefined({ each: true, message: 'The ID must be specified' }) + ids: string[]; +} diff --git a/src/modules/content/dtos/post.dto.ts b/src/modules/content/dtos/post.dto.ts index d22db96..11a5d2b 100644 --- a/src/modules/content/dtos/post.dto.ts +++ b/src/modules/content/dtos/post.dto.ts @@ -19,6 +19,7 @@ import { isNil, toNumber } from 'lodash'; import { PostOrder } from '@/modules/content/constants'; import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator'; import { toBoolean } from '@/modules/core/helpers'; +import { SelectTrashMode } from '@/modules/database/constants'; import { IsDataExist } from '@/modules/database/constraints/data.exist.constraint'; import { PaginateOptions } from '@/modules/database/types'; @@ -35,7 +36,7 @@ export class QueryPostDto implements PaginateOptions { message: `The sorting rule must be one of ${Object.values(PostOrder).join(',')}`, }) @IsOptional() - orderBy: PostOrder; + orderBy?: PostOrder; @Transform(({ value }) => toNumber(value)) @Min(1, { always: true, message: 'The current page must be greater than 1.' }) @@ -52,6 +53,10 @@ export class QueryPostDto implements PaginateOptions { @IsOptional() limit = 10; + @IsEnum(SelectTrashMode) + @IsOptional() + trashed?: SelectTrashMode; + @IsDataExist(CategoryEntity, { always: true, message: 'The category does not exist' }) @IsUUID(undefined, { message: 'The ID format is incorrect' }) @IsOptional() diff --git a/src/modules/content/entities/post.entity.ts b/src/modules/content/entities/post.entity.ts index 51936a8..d977de9 100644 --- a/src/modules/content/entities/post.entity.ts +++ b/src/modules/content/entities/post.entity.ts @@ -3,6 +3,7 @@ import { BaseEntity, Column, CreateDateColumn, + DeleteDateColumn, Entity, JoinTable, ManyToMany, @@ -64,6 +65,11 @@ export class PostEntity extends BaseEntity { @UpdateDateColumn({ comment: '更新时间', nullable: true }) updatedAt?: Date; + @Expose() + @Type(() => Date) + @DeleteDateColumn({ comment: '删除时间' }) + deleteAt: Date; + @Expose() commentCount: number; @@ -76,7 +82,7 @@ export class PostEntity extends BaseEntity { @Expose() @Type(() => TagEntity) - @ManyToMany(() => TagEntity, (tag) => tag.posts, { cascade: true }) + @ManyToMany(() => TagEntity, (tag) => tag.posts, { cascade: ['insert', 'update', 'remove'] }) @JoinTable() tags: Relation[]; diff --git a/src/modules/content/services/post.service.ts b/src/modules/content/services/post.service.ts index 1aef136..430ed0f 100644 --- a/src/modules/content/services/post.service.ts +++ b/src/modules/content/services/post.service.ts @@ -9,6 +9,7 @@ import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dt import { PostEntity } from '@/modules/content/entities/post.entity'; import { CategoryRepository } from '@/modules/content/repositories'; import { PostRepository } from '@/modules/content/repositories/post.repository'; +import { SelectTrashMode } from '@/modules/database/constants'; import { QueryHook } from '@/modules/database/types'; import { paginate } from '@/modules/database/utils'; @@ -88,9 +89,42 @@ export class PostService { return this.detail(data.id); } - async delete(id: string) { - const item = await this.repository.findOneByOrFail({ id }); - return this.repository.remove(item); + async delete(ids: string[], trash?: boolean) { + const items = await this.repository + .buildBaseQB() + .where('post.id IN (:...ids)', { ids }) + .withDeleted() + .getMany(); + let result: PostEntity[] = []; + if (trash) { + const directs = items.filter((item) => !isNil(item.deleteAt)); + const softs = items.filter((item) => isNil(item.deleteAt)); + result = [ + ...(await this.repository.remove(directs)), + ...(await this.repository.softRemove(softs)), + ]; + } else { + result = await this.repository.remove(items); + } + return result; + } + + async restore(ids: string[]) { + const items = await this.repository + .buildBaseQB() + .where('post.id IN (:...ids)', { ids }) + .withDeleted() + .getMany(); + const trasheds = items.filter((item) => !isNil(item.deleteAt)); + const trashedIds = trasheds.map((item) => item.id); + if (trashedIds.length < 1) { + return []; + } + await this.repository.restore(trashedIds); + const qb = await this.buildListQuery(this.repository.buildBaseQB(), {}, async (qbuilder) => + qbuilder.andWhereInIds(trashedIds), + ); + return qb.getMany(); } protected async buildListQuery( @@ -98,12 +132,18 @@ export class PostService { options: FindParams, callback?: QueryHook, ) { - const { orderBy, isPublished, category, tag } = options; + const { orderBy, isPublished, category, tag, trashed } = options; if (typeof isPublished === 'boolean') { isPublished ? qb.where({ publishedAt: Not(IsNull()) }) : qb.where({ publishedAt: IsNull() }); } + if (trashed === SelectTrashMode.ALL || trashed === SelectTrashMode.ONLY) { + qb.withDeleted(); + if (trashed === SelectTrashMode.ONLY) { + qb.where('post.deletedAt is not null'); + } + } this.queryOrderBy(qb, orderBy); if (category) { await this.queryByCategory(category, qb); diff --git a/src/modules/database/constants.ts b/src/modules/database/constants.ts index b3495e8..395d6a1 100644 --- a/src/modules/database/constants.ts +++ b/src/modules/database/constants.ts @@ -1 +1,10 @@ export const CUSTOM_REPOSITORY_METADATA = 'CUSTOM_REPOSITORY_METADATA'; + +export enum SelectTrashMode { + // ALL: 包含已软删除和未软删除的数据(同时查询正常数据和回收站中的数据) + ALL = 'all', + // ONLY: 只包含软删除的数据 (只查询回收站中的数据) + ONLY = 'only', + // NONE: 只包含未软删除的数据 (只查询正常数据) + NONE = 'none', +}