diff --git a/back/database6.db b/back/database6.db index 66a80e8..fd41588 100644 Binary files a/back/database6.db and b/back/database6.db differ diff --git a/back/database9.db b/back/database9.db index 3b6fa5e..4c95885 100644 Binary files a/back/database9.db and b/back/database9.db differ diff --git a/src/config/database.config.ts b/src/config/database.config.ts index 94916ee..d6635ca 100644 --- a/src/config/database.config.ts +++ b/src/config/database.config.ts @@ -17,7 +17,7 @@ export const database = (): TypeOrmModuleOptions => ({ // database: 'ink_apps', // 以下为sqlite配置 type: 'better-sqlite3', - database: resolve(__dirname, '../../back/database6.db'), + database: resolve(__dirname, '../../back/database9.db'), synchronize: true, autoLoadEntities: true, }); diff --git a/src/modules/content/controllers/category.controller.ts b/src/modules/content/controllers/category.controller.ts index 211f9e7..e36186d 100644 --- a/src/modules/content/controllers/category.controller.ts +++ b/src/modules/content/controllers/category.controller.ts @@ -11,8 +11,14 @@ import { SerializeOptions, } from '@nestjs/common'; -import { CreateCategoryDto, QueryCategoryDto, UpdateCategoryDto } from '@/modules/content/dtos'; +import { + CreateCategoryDto, + QueryCategoryDto, + QueryCategoryTreeDto, + UpdateCategoryDto, +} from '@/modules/content/dtos'; import { CategoryService } from '@/modules/content/services'; +import { DeleteWithTrashDto, RestoreDto } from '@/modules/restful/dtos'; @Controller('categories') export class CategoryController { @@ -20,8 +26,8 @@ export class CategoryController { @Get('tree') @SerializeOptions({ groups: ['category-tree'] }) - async tree() { - return this.service.findTress(); + async tree(@Query() options: QueryCategoryTreeDto) { + return this.service.findTrees(options); } @Get() @@ -57,9 +63,19 @@ export class CategoryController { return this.service.update(data); } - @Delete(':id') + @Delete() @SerializeOptions({ groups: ['category-detail'] }) - async delete(@Param('id', new ParseUUIDPipe()) id: string) { - return this.service.delete(id); + async delete(@Body() data: DeleteWithTrashDto) { + const { ids, trash } = data; + + return this.service.delete(ids, trash); + } + + @Patch('restore') + @SerializeOptions({ groups: ['category-detail'] }) + async restore(@Body() data: RestoreDto) { + const { ids } = data; + + return this.service.restore(ids); } } diff --git a/src/modules/content/controllers/comment.controller.ts b/src/modules/content/controllers/comment.controller.ts index d3f7546..5686b06 100644 --- a/src/modules/content/controllers/comment.controller.ts +++ b/src/modules/content/controllers/comment.controller.ts @@ -1,17 +1,8 @@ -import { - Body, - Controller, - Delete, - Get, - Param, - ParseUUIDPipe, - Post, - Query, - SerializeOptions, -} from '@nestjs/common'; +import { Body, Controller, Delete, Get, Post, Query, SerializeOptions } from '@nestjs/common'; import { CreateCommentDto, QueryCommentDto, QueryCommentTreeDto } from '@/modules/content/dtos'; import { CommentService } from '@/modules/content/services'; +import { DeleteDto } from '@/modules/restful/dtos'; @Controller('comments') export class CommentController { @@ -44,9 +35,10 @@ export class CommentController { return this.service.create(data); } - @Delete(':id') + @Delete() @SerializeOptions({ groups: ['comment-detail'] }) - async delete(@Param('id', new ParseUUIDPipe()) id: string) { - return this.service.delete(id); + async delete(@Body() data: DeleteDto) { + const { ids } = data; + return this.service.delete(ids); } } diff --git a/src/modules/content/controllers/post.controller.ts b/src/modules/content/controllers/post.controller.ts index 195e161..c5ede83 100644 --- a/src/modules/content/controllers/post.controller.ts +++ b/src/modules/content/controllers/post.controller.ts @@ -13,6 +13,7 @@ import { import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos'; import { PostService } from '@/modules/content/services'; +import { DeleteDto, DeleteWithTrashDto } from '@/modules/restful/dtos'; /** * 文章控制器 @@ -56,9 +57,19 @@ 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) { + const { ids, trash } = data; + + return this.postService.delete(ids, trash); + } + + @Patch('restore') + @SerializeOptions({ groups: ['post-detail'] }) + async restore(@Body() data: DeleteDto) { + const { ids } = data; + + return this.postService.restore(ids); } } diff --git a/src/modules/content/controllers/tag.controller.ts b/src/modules/content/controllers/tag.controller.ts index d9be592..d85c4a2 100644 --- a/src/modules/content/controllers/tag.controller.ts +++ b/src/modules/content/controllers/tag.controller.ts @@ -11,8 +11,9 @@ import { SerializeOptions, } from '@nestjs/common'; -import { CreateTagDto, QueryCategoryDto, UpdateTagDto } from '@/modules/content/dtos'; +import { CreateTagDto, QueryTagsDto, UpdateTagDto } from '@/modules/content/dtos'; import { TagService } from '@/modules/content/services'; +import { DeleteDto, DeleteWithTrashDto } from '@/modules/restful/dtos'; @Controller('tags') export class TagController { @@ -22,7 +23,7 @@ export class TagController { @SerializeOptions({}) async list( @Query() - options: QueryCategoryDto, + options: QueryTagsDto, ) { return this.service.paginate(options); } @@ -54,9 +55,19 @@ export class TagController { return this.service.update(data); } - @Delete(':id') - @SerializeOptions({}) - async delete(@Param('id', new ParseUUIDPipe()) id: string) { - return this.service.delete(id); + @Delete() + @SerializeOptions({ groups: ['post-list'] }) + async delete(@Body() data: DeleteWithTrashDto) { + const { ids, trash } = data; + + return this.service.delete(ids, trash); + } + + @Patch('restore') + @SerializeOptions({ groups: ['post-list'] }) + async restore(@Body() data: DeleteDto) { + const { ids } = data; + + return this.service.restore(ids); } } diff --git a/src/modules/content/dtos/category.dto.ts b/src/modules/content/dtos/category.dto.ts index 4a92415..29cef9b 100644 --- a/src/modules/content/dtos/category.dto.ts +++ b/src/modules/content/dtos/category.dto.ts @@ -2,6 +2,7 @@ import { PartialType } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsDefined, + IsEnum, IsNotEmpty, IsNumber, IsOptional, @@ -14,12 +15,23 @@ import { toNumber } from 'lodash'; import { CategoryEntity } from '@/modules/content/entities'; import { DtoValidation } from '@/modules/core/decorators'; +import { SelectTrashMode } from '@/modules/database/constants'; import { IsDataExist, IsTreeUnique, IsTreeUniqueExist } from '@/modules/database/constraints'; import { PaginateOptions } from '@/modules/database/types'; +/** + * 树形分类查询验证 + */ @DtoValidation({ type: 'query' }) -export class QueryCategoryDto implements PaginateOptions { +export class QueryCategoryTreeDto { + @IsEnum(SelectTrashMode) + @IsOptional() + trashed?: SelectTrashMode; +} + +@DtoValidation({ type: 'query' }) +export class QueryCategoryDto extends QueryCategoryTreeDto implements PaginateOptions { @Transform(({ value }) => toNumber(value)) @Min(1, { message: '当前页数必须大于1' }) @IsNumber() diff --git a/src/modules/content/dtos/post.dto.ts b/src/modules/content/dtos/post.dto.ts index 1006ef3..d31d693 100644 --- a/src/modules/content/dtos/post.dto.ts +++ b/src/modules/content/dtos/post.dto.ts @@ -21,6 +21,7 @@ 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 { SelectTrashMode } from '@/modules/database/constants'; import { IsDataExist } from '@/modules/database/constraints'; import { PaginateOptions } from '@/modules/database/types'; @@ -66,6 +67,10 @@ export class QueryPostDto implements PaginateOptions { @IsUUID(undefined, { message: '标签ID必须是UUID' }) @IsOptional() tag?: string; + + @IsEnum(SelectTrashMode) + @IsOptional() + trashed?: SelectTrashMode; } /** diff --git a/src/modules/content/dtos/tag.dto.ts b/src/modules/content/dtos/tag.dto.ts index a317a53..6d0e63b 100644 --- a/src/modules/content/dtos/tag.dto.ts +++ b/src/modules/content/dtos/tag.dto.ts @@ -2,6 +2,7 @@ import { PartialType } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsDefined, + IsEnum, IsNotEmpty, IsNumber, IsOptional, @@ -12,6 +13,7 @@ import { import { toNumber } from 'lodash'; import { DtoValidation } from '@/modules/core/decorators'; +import { SelectTrashMode } from '@/modules/database/constants'; import { PaginateOptions } from '@/modules/database/types'; /** @@ -19,6 +21,10 @@ import { PaginateOptions } from '@/modules/database/types'; */ @DtoValidation({ type: 'query' }) export class QueryTagsDto implements PaginateOptions { + @IsEnum(SelectTrashMode) + @IsOptional() + trashed?: SelectTrashMode; + @Transform(({ value }) => toNumber(value)) @Min(1, { message: '当前页数必须大于1' }) @IsNumber() diff --git a/src/modules/content/entities/tag.entity.ts b/src/modules/content/entities/tag.entity.ts index 2498f1c..b486c24 100644 --- a/src/modules/content/entities/tag.entity.ts +++ b/src/modules/content/entities/tag.entity.ts @@ -1,5 +1,5 @@ -import { Exclude, Expose } from 'class-transformer'; -import { Column, Entity, ManyToMany, PrimaryColumn, Relation } from 'typeorm'; +import { Exclude, Expose, Type } from 'class-transformer'; +import { Column, DeleteDateColumn, Entity, ManyToMany, PrimaryColumn, Relation } from 'typeorm'; import { PostEntity } from '@/modules/content/entities/post.entity'; @@ -26,4 +26,11 @@ export class TagEntity { */ @Expose() postCount: number; + + @Expose() + @Type(() => Date) + @DeleteDateColumn({ + comment: '删除时间', + }) + deletedAt: Date; } diff --git a/src/modules/content/repositories/category.repository.ts b/src/modules/content/repositories/category.repository.ts index 41649fc..0f8b6a9 100644 --- a/src/modules/content/repositories/category.repository.ts +++ b/src/modules/content/repositories/category.repository.ts @@ -1,4 +1,4 @@ -import { unset } from 'lodash'; +import { pick, unset } from 'lodash'; import { FindOptionsUtils, FindTreeOptions, TreeRepository } from 'typeorm'; import { CategoryEntity } from '@/modules/content/entities'; @@ -17,7 +17,12 @@ export class CategoryRepository extends TreeRepository { * 树形结构查询 * @param options */ - async findTrees(options?: FindTreeOptions) { + async findTrees( + options?: FindTreeOptions & { + onlyTrashed?: boolean; + withTrashed?: boolean; + }, + ) { const roots = await this.findRoots(options); await Promise.all(roots.map((root) => this.findDescendantsTree(root, options))); return roots; @@ -27,19 +32,28 @@ export class CategoryRepository extends TreeRepository { * 查询顶级分类 * @param options */ - findRoots(options?: FindTreeOptions) { + findRoots( + options?: FindTreeOptions & { + onlyTrashed?: boolean; + withTrashed?: boolean; + }, + ) { const escapeAlias = (alias: string) => this.manager.connection.driver.escape(alias); const escapeColumn = (column: string) => this.manager.connection.driver.escape(column); const joinColumn = this.metadata.treeParentRelation!.joinColumns[0]; const parentPropertyName = joinColumn.givenDatabaseName || joinColumn.databaseName; - const qb = this.buildBaseQB().orderBy('category.customOrder', 'ASC'); - FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options); + qb.where(`${escapeAlias('category')}.${escapeColumn(parentPropertyName)} IS NULL`); - return qb - .where(`${escapeAlias('category')}.${escapeColumn(parentPropertyName)} IS NULL`) - .getMany(); + FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, pick(options, ['relations', 'depth'])); + + if (options?.withTrashed) { + qb.withDeleted(); + if (options?.onlyTrashed) qb.where(`category.deletedAt IS NOT NULL`); + } + + return qb.getMany(); } /** @@ -47,11 +61,22 @@ export class CategoryRepository extends TreeRepository { * @param entity * @param options */ - findDescendants(entity: CategoryEntity, options?: FindTreeOptions) { + findDescendants( + entity: CategoryEntity, + options?: FindTreeOptions & { + onlyTrashed?: boolean; + withTrashed?: boolean; + }, + ) { const qb = this.createDescendantsQueryBuilder('category', 'treeClosure', entity); FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options); qb.orderBy('category.customOrder', 'ASC'); + if (options?.withTrashed) { + qb.withDeleted(); + if (options?.onlyTrashed) qb.where(`category.deletedAt IS NOT NULL`); + } + return qb.getMany(); } @@ -60,11 +85,22 @@ export class CategoryRepository extends TreeRepository { * @param entity * @param options */ - findAncestors(entity: CategoryEntity, options?: FindTreeOptions) { + findAncestors( + entity: CategoryEntity, + options?: FindTreeOptions & { + onlyTrashed?: boolean; + withTrashed?: boolean; + }, + ) { const qb = this.createAncestorsQueryBuilder('category', 'treeClosure', entity); FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options); qb.orderBy('category.customOrder', 'ASC'); + if (options?.withTrashed) { + qb.withDeleted(); + if (options?.onlyTrashed) qb.where(`category.deletedAt IS NOT NULL`); + } + return qb.getMany(); } @@ -88,4 +124,42 @@ export class CategoryRepository extends TreeRepository { return data as CategoryEntity[]; } + + /** + * 统计后代元素数量 + * @param entity + * @param options + */ + async countDescendants( + entity: CategoryEntity, + options?: { withTrashed?: boolean; onlyTrashed?: boolean }, + ) { + const qb = this.createDescendantsQueryBuilder('category', 'treeClosure', entity); + + if (options?.withTrashed) { + qb.withDeleted(); + if (options?.onlyTrashed) qb.where(`category.deleteAt IS NOT NULL`); + } + + return qb.getCount(); + } + + /** + * 统计后代元素数量 + * @param entity + * @param options + */ + async countAncestors( + entity: CategoryEntity, + options?: { withTrashed?: boolean; onlyTrashed?: boolean }, + ) { + const qb = this.createAncestorsQueryBuilder('category', 'treeClosure', entity); + + if (options?.withTrashed) { + qb.withDeleted(); + if (options?.onlyTrashed) qb.where(`category.deleteAt IS NOT NULL`); + } + + return qb.getCount(); + } } diff --git a/src/modules/content/services/category.service.ts b/src/modules/content/services/category.service.ts index 9e32fd0..121ef68 100644 --- a/src/modules/content/services/category.service.ts +++ b/src/modules/content/services/category.service.ts @@ -1,11 +1,17 @@ import { Injectable } from '@nestjs/common'; import { isNil, omit } from 'lodash'; -import { EntityNotFoundError } from 'typeorm'; +import { EntityNotFoundError, In } from 'typeorm'; -import { CreateCategoryDto, QueryCategoryDto, UpdateCategoryDto } from '@/modules/content/dtos'; +import { + CreateCategoryDto, + QueryCategoryDto, + QueryCategoryTreeDto, + UpdateCategoryDto, +} from '@/modules/content/dtos'; import { CategoryEntity } from '@/modules/content/entities'; import { CategoryRepository } from '@/modules/content/repositories'; +import { SelectTrashMode } from '@/modules/database/constants'; import { treePaginate } from '@/modules/database/helpers'; /** @@ -17,9 +23,15 @@ export class CategoryService { /** * 查询分类树 + * @param options */ - async findTress() { - return this.repository.findTrees(); + async findTrees(options: QueryCategoryTreeDto) { + const { trashed = SelectTrashMode.NONE } = options; + + return this.repository.findTrees({ + withTrashed: trashed === SelectTrashMode.ALL || trashed === SelectTrashMode.ONLY, + onlyTrashed: trashed === SelectTrashMode.ONLY, + }); } /** @@ -27,7 +39,12 @@ export class CategoryService { * @param options 分页选项 */ async paginate(options: QueryCategoryDto) { - const tree = await this.repository.findTrees(); + const { trashed = SelectTrashMode.NONE } = options; + + const tree = await this.repository.findTrees({ + withTrashed: trashed === SelectTrashMode.ALL || trashed === SelectTrashMode.ONLY, + onlyTrashed: trashed === SelectTrashMode.ONLY, + }); const data = await this.repository.toFlatTrees(tree); return treePaginate(options, data); @@ -83,23 +100,36 @@ export class CategoryService { * 删除分类 * @param id */ - async delete(id: string) { - const item = await this.repository.findOneOrFail({ - where: { id }, + async delete(ids: string[], trash?: boolean) { + const items = await this.repository.find({ + where: { id: In(ids) }, + withDeleted: true, relations: ['parent', 'children'], }); // 把子分类提升一级 - if (!isNil(item.children) && item.children.length > 0) { - const nchildren = [...item.children].map((c) => { - c.parent = item.parent; - return item; - }); + for (const item of items) { + if (!isNil(item.children) && item.children.length > 0) { + const nchildren = [...item.children].map((c) => { + c.parent = item.parent; + return c; + }); - await this.repository.save(nchildren, { reload: true }); + await this.repository.save(nchildren); + } } - return this.repository.remove(item); + if (trash) { + const directs = items.filter((item) => !isNil(item.deletedAt)); + const sorts = items.filter((item) => isNil(item.deletedAt)); + + return [ + ...(await this.repository.remove(directs)), + ...(await this.repository.softRemove(sorts)), + ]; + } + + return this.repository.remove(items); } /** @@ -121,4 +151,22 @@ export class CategoryService { } return parent; } + + /** + * 恢复分类 + * @param ids + */ + async restore(ids: string[]) { + const items = await this.repository.find({ + where: { id: In(ids) } as any, + withDeleted: true, + }); + + const trasheds = items.filter((item) => !isNil(item)).map((item) => item.id); + if (trasheds.length < 1) return []; + await this.repository.restore(trasheds); + const qb = this.repository.buildBaseQB(); + qb.andWhereInIds(trasheds); + return qb.getMany(); + } } diff --git a/src/modules/content/services/comment.service.ts b/src/modules/content/services/comment.service.ts index e80e974..725820c 100644 --- a/src/modules/content/services/comment.service.ts +++ b/src/modules/content/services/comment.service.ts @@ -2,7 +2,7 @@ import { ForbiddenException, Injectable } from '@nestjs/common'; import { isNil } from 'lodash'; -import { EntityNotFoundError, SelectQueryBuilder } from 'typeorm'; +import { EntityNotFoundError, In, SelectQueryBuilder } from 'typeorm'; import { CreateCommentDto, QueryCommentDto, QueryCommentTreeDto } from '@/modules/content/dtos'; import { CommentEntity } from '@/modules/content/entities'; @@ -82,11 +82,11 @@ export class CommentService { /** * 删除评论 - * @param id + * @param ids */ - async delete(id: string) { - const comment = await this.repository.findOneOrFail({ where: { id: id ?? null } }); - return this.repository.remove(comment); + async delete(ids: string[]) { + const comments = await this.repository.find({ where: { id: In(ids) } }); + return this.repository.remove(comments); } /** diff --git a/src/modules/content/services/post.service.ts b/src/modules/content/services/post.service.ts index 39524d5..e38f2c8 100644 --- a/src/modules/content/services/post.service.ts +++ b/src/modules/content/services/post.service.ts @@ -10,6 +10,7 @@ import { PostEntity } from '@/modules/content/entities'; import { CategoryRepository, PostRepository, TagRepository } from '@/modules/content/repositories'; import { CategoryService } from '@/modules/content/services/category.service'; +import { SelectTrashMode } from '@/modules/database/constants'; import { paginate } from '@/modules/database/helpers'; import { QueryHook } from '@/modules/database/types'; @@ -108,9 +109,45 @@ export class PostService { * 删除文章 * @param 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.find({ + where: { id: In(ids) } as any, + withDeleted: true, + }); + + if (trash) { + // 对已软删除的数据再次删除时直接通过remove方法从数据库中清除 + const directs = items.filter((item) => !isNil(item.deletedAt)); + const softs = items.filter((item) => isNil(item.deletedAt)); + + return [ + ...(await this.repository.remove(directs)), + ...(await this.repository.softRemove(softs)), + ]; + } + + return this.repository.remove(items); + } + + /** + * 恢复文章 + * @param ids + */ + async restore(ids: string[]) { + const items = await this.repository.find({ + where: { id: In(ids) } as any, + withDeleted: true, + }); + // 过滤掉不在回收站中的数据 + const trasheds = items.filter((item) => !isNil(item)).map((item) => item.id); + + if (trasheds.length < 1) return []; + + await this.repository.restore(trasheds); + const qb = await this.buildListQuery(this.repository.buildBaseQB(), {}, async (qbuilder) => + qbuilder.andWhereInIds(trasheds), + ); + return qb.getMany(); } /** @@ -124,7 +161,12 @@ export class PostService { options: FindParams, callback?: QueryHook, ) { - const { category, tag, orderBy, isPublished } = options; + const { category, tag, orderBy, isPublished, trashed = SelectTrashMode.NONE } = options; + + if (trashed === SelectTrashMode.ALL || trashed === SelectTrashMode.ONLY) { + qb.withDeleted(); + if (trashed === SelectTrashMode.ONLY) qb.where(`post.deletedAt is not null`); + } if (typeof isPublished === 'boolean') { isPublished ? qb.where({ diff --git a/src/modules/content/services/tag.service.ts b/src/modules/content/services/tag.service.ts index e101e1c..96f6b5a 100644 --- a/src/modules/content/services/tag.service.ts +++ b/src/modules/content/services/tag.service.ts @@ -1,10 +1,19 @@ import { Injectable } from '@nestjs/common'; -import { omit } from 'lodash'; +import { isNil, omit } from 'lodash'; + +import { In, SelectQueryBuilder } from 'typeorm'; import { CreateTagDto, QueryTagsDto, UpdateTagDto } from '@/modules/content/dtos'; +import { TagEntity } from '@/modules/content/entities'; import { TagRepository } from '@/modules/content/repositories'; +import { SelectTrashMode } from '@/modules/database/constants'; import { paginate } from '@/modules/database/helpers'; +import { QueryHook } from '@/modules/database/types'; + +type FindParams = { + [key in keyof Omit]: QueryTagsDto[key]; +}; /** * 标签数据操作 @@ -19,7 +28,7 @@ export class TagService { * @param callback 添加额外的查询 */ async paginate(options: QueryTagsDto) { - const qb = this.repository.buildBaseQB(); + const qb = await this.buildListQuery(this.repository.buildBaseQB(), options); return paginate(qb, options); } @@ -54,10 +63,64 @@ export class TagService { /** * 删除标签 - * @param id + * @param ids */ - 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.find({ + where: { id: In(ids) } as any, + withDeleted: true, + }); + + if (trash) { + const directs = items.filter((item) => !isNil(item.deletedAt)); + const sorts = items.filter((item) => isNil(item.deletedAt)); + + return [ + ...(await this.repository.remove(directs)), + ...(await this.repository.softRemove(sorts)), + ]; + } + + return this.repository.remove(items); + } + + /** + * 软删除标签 + * @param ids + */ + async restore(ids: string[]) { + const items = await this.repository.find({ + where: { id: In(ids) } as any, + withDeleted: true, + }); + + // 过滤掉不在回收站的标签 + const trashed = items.filter((item) => !isNil(item.deletedAt)).map((item) => item.id); + if (trashed.length < 1) return trashed; + await this.repository.restore(trashed); + const qb = this.repository.buildBaseQB().where({ id: In(trashed) }); + return qb.getMany(); + } + + /** + * 构建标签列表查询器(需要查到软删除) + * @param qb + * @param options + * @param callback + */ + protected async buildListQuery( + qb: SelectQueryBuilder, + options: FindParams, + callback?: QueryHook, + ) { + const { trashed } = options; + + if (trashed === SelectTrashMode.ALL || trashed === SelectTrashMode.ONLY) { + qb.withDeleted(); + if (trashed === SelectTrashMode.ONLY) qb.where(`tag.deletedAt IS NOT NULL`); + } + + if (callback) return callback(qb); + return qb; } } diff --git a/src/modules/database/constants.ts b/src/modules/database/constants.ts index 2711973..72d8716 100644 --- a/src/modules/database/constants.ts +++ b/src/modules/database/constants.ts @@ -2,3 +2,12 @@ * 自定义 Repository 元数据 */ export const CUSTOM_REPOSITORY_METADATA = 'CUSTOM_REPOSITORY_METADATA'; + +/** + * 软删除数据查询类型 + */ +export enum SelectTrashMode { + ALL = 'all', + ONLY = 'only', + NONE = 'none', +} diff --git a/src/modules/restful/dtos/delete-with-trash.dto.ts b/src/modules/restful/dtos/delete-with-trash.dto.ts new file mode 100644 index 0000000..14e2552 --- /dev/null +++ b/src/modules/restful/dtos/delete-with-trash.dto.ts @@ -0,0 +1,18 @@ +import { Transform } from 'class-transformer'; + +import { IsBoolean, IsOptional } from 'class-validator'; + +import { DtoValidation } from '@/modules/core/decorators'; +import { toBoolean } from '@/modules/core/helpers'; +import { DeleteDto } from '@/modules/restful/dtos/delete.dto'; + +/** + * 带软删除的批量删除验证 + */ +@DtoValidation() +export class DeleteWithTrashDto extends DeleteDto { + @Transform(({ value }) => toBoolean(value)) + @IsBoolean() + @IsOptional() + trash?: boolean; +} diff --git a/src/modules/restful/dtos/delete.dto.ts b/src/modules/restful/dtos/delete.dto.ts new file mode 100644 index 0000000..6029d4a --- /dev/null +++ b/src/modules/restful/dtos/delete.dto.ts @@ -0,0 +1,19 @@ +import { IsDefined, IsUUID } from 'class-validator'; + +import { DtoValidation } from '@/modules/core/decorators'; + +/** + * 批量删除验证 + */ +@DtoValidation() +export class DeleteDto { + @IsUUID(undefined, { + each: true, + message: 'ID格式错误', + }) + @IsDefined({ + each: true, + message: 'ID必须指定', + }) + ids: string[] = []; +} diff --git a/src/modules/restful/dtos/index.ts b/src/modules/restful/dtos/index.ts new file mode 100644 index 0000000..dac50ef --- /dev/null +++ b/src/modules/restful/dtos/index.ts @@ -0,0 +1,3 @@ +export * from './delete-with-trash.dto'; +export * from './delete.dto'; +export * from './restore.dto'; diff --git a/src/modules/restful/dtos/restore.dto.ts b/src/modules/restful/dtos/restore.dto.ts new file mode 100644 index 0000000..3a7adde --- /dev/null +++ b/src/modules/restful/dtos/restore.dto.ts @@ -0,0 +1,19 @@ +import { IsDefined, IsUUID } from 'class-validator'; + +import { DtoValidation } from '@/modules/core/decorators'; + +/** + * 批量恢复验证 + */ +@DtoValidation() +export class RestoreDto { + @IsUUID(undefined, { + each: true, + message: 'ID格式错误', + }) + @IsDefined({ + each: true, + message: 'ID必须指定', + }) + ids: string[] = []; +}