feat:增加批量删除、软删除和软删除恢复

- 完成基础的软删除
- 完成树形数据的软删除(children未处理)
This commit is contained in:
3R-喜东东 2023-12-15 11:19:42 +08:00
parent c151657116
commit 5bcb4853e5
21 changed files with 428 additions and 73 deletions

Binary file not shown.

Binary file not shown.

View File

@ -17,7 +17,7 @@ export const database = (): TypeOrmModuleOptions => ({
// database: 'ink_apps', // database: 'ink_apps',
// 以下为sqlite配置 // 以下为sqlite配置
type: 'better-sqlite3', type: 'better-sqlite3',
database: resolve(__dirname, '../../back/database6.db'), database: resolve(__dirname, '../../back/database9.db'),
synchronize: true, synchronize: true,
autoLoadEntities: true, autoLoadEntities: true,
}); });

View File

@ -11,8 +11,14 @@ import {
SerializeOptions, SerializeOptions,
} from '@nestjs/common'; } 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 { CategoryService } from '@/modules/content/services';
import { DeleteWithTrashDto, RestoreDto } from '@/modules/restful/dtos';
@Controller('categories') @Controller('categories')
export class CategoryController { export class CategoryController {
@ -20,8 +26,8 @@ export class CategoryController {
@Get('tree') @Get('tree')
@SerializeOptions({ groups: ['category-tree'] }) @SerializeOptions({ groups: ['category-tree'] })
async tree() { async tree(@Query() options: QueryCategoryTreeDto) {
return this.service.findTress(); return this.service.findTrees(options);
} }
@Get() @Get()
@ -57,9 +63,19 @@ export class CategoryController {
return this.service.update(data); return this.service.update(data);
} }
@Delete(':id') @Delete()
@SerializeOptions({ groups: ['category-detail'] }) @SerializeOptions({ groups: ['category-detail'] })
async delete(@Param('id', new ParseUUIDPipe()) id: string) { async delete(@Body() data: DeleteWithTrashDto) {
return this.service.delete(id); 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);
} }
} }

View File

@ -1,17 +1,8 @@
import { import { Body, Controller, Delete, Get, Post, Query, SerializeOptions } from '@nestjs/common';
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
SerializeOptions,
} from '@nestjs/common';
import { CreateCommentDto, QueryCommentDto, QueryCommentTreeDto } from '@/modules/content/dtos'; import { CreateCommentDto, QueryCommentDto, QueryCommentTreeDto } from '@/modules/content/dtos';
import { CommentService } from '@/modules/content/services'; import { CommentService } from '@/modules/content/services';
import { DeleteDto } from '@/modules/restful/dtos';
@Controller('comments') @Controller('comments')
export class CommentController { export class CommentController {
@ -44,9 +35,10 @@ export class CommentController {
return this.service.create(data); return this.service.create(data);
} }
@Delete(':id') @Delete()
@SerializeOptions({ groups: ['comment-detail'] }) @SerializeOptions({ groups: ['comment-detail'] })
async delete(@Param('id', new ParseUUIDPipe()) id: string) { async delete(@Body() data: DeleteDto) {
return this.service.delete(id); const { ids } = data;
return this.service.delete(ids);
} }
} }

View File

@ -13,6 +13,7 @@ import {
import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos'; import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos';
import { PostService } from '@/modules/content/services'; 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); return this.postService.update(data);
} }
@Delete(':id') @Delete()
@SerializeOptions({ groups: ['post-detail'] }) @SerializeOptions({ groups: ['post-detail'] })
async delete(@Param('id', new ParseUUIDPipe()) id: string) { async delete(@Body() data: DeleteWithTrashDto) {
return this.postService.delete(id); 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);
} }
} }

View File

@ -11,8 +11,9 @@ import {
SerializeOptions, SerializeOptions,
} from '@nestjs/common'; } 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 { TagService } from '@/modules/content/services';
import { DeleteDto, DeleteWithTrashDto } from '@/modules/restful/dtos';
@Controller('tags') @Controller('tags')
export class TagController { export class TagController {
@ -22,7 +23,7 @@ export class TagController {
@SerializeOptions({}) @SerializeOptions({})
async list( async list(
@Query() @Query()
options: QueryCategoryDto, options: QueryTagsDto,
) { ) {
return this.service.paginate(options); return this.service.paginate(options);
} }
@ -54,9 +55,19 @@ export class TagController {
return this.service.update(data); return this.service.update(data);
} }
@Delete(':id') @Delete()
@SerializeOptions({}) @SerializeOptions({ groups: ['post-list'] })
async delete(@Param('id', new ParseUUIDPipe()) id: string) { async delete(@Body() data: DeleteWithTrashDto) {
return this.service.delete(id); 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);
} }
} }

View File

@ -2,6 +2,7 @@ import { PartialType } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
IsDefined, IsDefined,
IsEnum,
IsNotEmpty, IsNotEmpty,
IsNumber, IsNumber,
IsOptional, IsOptional,
@ -14,12 +15,23 @@ import { toNumber } from 'lodash';
import { CategoryEntity } from '@/modules/content/entities'; import { CategoryEntity } from '@/modules/content/entities';
import { DtoValidation } from '@/modules/core/decorators'; import { DtoValidation } from '@/modules/core/decorators';
import { SelectTrashMode } from '@/modules/database/constants';
import { IsDataExist, IsTreeUnique, IsTreeUniqueExist } from '@/modules/database/constraints'; import { IsDataExist, IsTreeUnique, IsTreeUniqueExist } from '@/modules/database/constraints';
import { PaginateOptions } from '@/modules/database/types'; import { PaginateOptions } from '@/modules/database/types';
/**
*
*/
@DtoValidation({ type: 'query' }) @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)) @Transform(({ value }) => toNumber(value))
@Min(1, { message: '当前页数必须大于1' }) @Min(1, { message: '当前页数必须大于1' })
@IsNumber() @IsNumber()

View File

@ -21,6 +21,7 @@ import { PostOrderType } from '@/modules/content/constants';
import { CategoryEntity, TagEntity } from '@/modules/content/entities'; import { CategoryEntity, TagEntity } from '@/modules/content/entities';
import { DtoValidation } from '@/modules/core/decorators'; import { DtoValidation } from '@/modules/core/decorators';
import { toBoolean } from '@/modules/core/helpers'; import { toBoolean } from '@/modules/core/helpers';
import { SelectTrashMode } from '@/modules/database/constants';
import { IsDataExist } from '@/modules/database/constraints'; import { IsDataExist } from '@/modules/database/constraints';
import { PaginateOptions } from '@/modules/database/types'; import { PaginateOptions } from '@/modules/database/types';
@ -66,6 +67,10 @@ export class QueryPostDto implements PaginateOptions {
@IsUUID(undefined, { message: '标签ID必须是UUID' }) @IsUUID(undefined, { message: '标签ID必须是UUID' })
@IsOptional() @IsOptional()
tag?: string; tag?: string;
@IsEnum(SelectTrashMode)
@IsOptional()
trashed?: SelectTrashMode;
} }
/** /**

View File

@ -2,6 +2,7 @@ import { PartialType } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
IsDefined, IsDefined,
IsEnum,
IsNotEmpty, IsNotEmpty,
IsNumber, IsNumber,
IsOptional, IsOptional,
@ -12,6 +13,7 @@ import {
import { toNumber } from 'lodash'; import { toNumber } from 'lodash';
import { DtoValidation } from '@/modules/core/decorators'; import { DtoValidation } from '@/modules/core/decorators';
import { SelectTrashMode } from '@/modules/database/constants';
import { PaginateOptions } from '@/modules/database/types'; import { PaginateOptions } from '@/modules/database/types';
/** /**
@ -19,6 +21,10 @@ import { PaginateOptions } from '@/modules/database/types';
*/ */
@DtoValidation({ type: 'query' }) @DtoValidation({ type: 'query' })
export class QueryTagsDto implements PaginateOptions { export class QueryTagsDto implements PaginateOptions {
@IsEnum(SelectTrashMode)
@IsOptional()
trashed?: SelectTrashMode;
@Transform(({ value }) => toNumber(value)) @Transform(({ value }) => toNumber(value))
@Min(1, { message: '当前页数必须大于1' }) @Min(1, { message: '当前页数必须大于1' })
@IsNumber() @IsNumber()

View File

@ -1,5 +1,5 @@
import { Exclude, Expose } from 'class-transformer'; import { Exclude, Expose, Type } from 'class-transformer';
import { Column, Entity, ManyToMany, PrimaryColumn, Relation } from 'typeorm'; import { Column, DeleteDateColumn, Entity, ManyToMany, PrimaryColumn, Relation } from 'typeorm';
import { PostEntity } from '@/modules/content/entities/post.entity'; import { PostEntity } from '@/modules/content/entities/post.entity';
@ -26,4 +26,11 @@ export class TagEntity {
*/ */
@Expose() @Expose()
postCount: number; postCount: number;
@Expose()
@Type(() => Date)
@DeleteDateColumn({
comment: '删除时间',
})
deletedAt: Date;
} }

View File

@ -1,4 +1,4 @@
import { unset } from 'lodash'; import { pick, unset } from 'lodash';
import { FindOptionsUtils, FindTreeOptions, TreeRepository } from 'typeorm'; import { FindOptionsUtils, FindTreeOptions, TreeRepository } from 'typeorm';
import { CategoryEntity } from '@/modules/content/entities'; import { CategoryEntity } from '@/modules/content/entities';
@ -17,7 +17,12 @@ export class CategoryRepository extends TreeRepository<CategoryEntity> {
* *
* @param options * @param options
*/ */
async findTrees(options?: FindTreeOptions) { async findTrees(
options?: FindTreeOptions & {
onlyTrashed?: boolean;
withTrashed?: boolean;
},
) {
const roots = await this.findRoots(options); const roots = await this.findRoots(options);
await Promise.all(roots.map((root) => this.findDescendantsTree(root, options))); await Promise.all(roots.map((root) => this.findDescendantsTree(root, options)));
return roots; return roots;
@ -27,19 +32,28 @@ export class CategoryRepository extends TreeRepository<CategoryEntity> {
* *
* @param options * @param options
*/ */
findRoots(options?: FindTreeOptions) { findRoots(
options?: FindTreeOptions & {
onlyTrashed?: boolean;
withTrashed?: boolean;
},
) {
const escapeAlias = (alias: string) => this.manager.connection.driver.escape(alias); const escapeAlias = (alias: string) => this.manager.connection.driver.escape(alias);
const escapeColumn = (column: string) => this.manager.connection.driver.escape(column); const escapeColumn = (column: string) => this.manager.connection.driver.escape(column);
const joinColumn = this.metadata.treeParentRelation!.joinColumns[0]; const joinColumn = this.metadata.treeParentRelation!.joinColumns[0];
const parentPropertyName = joinColumn.givenDatabaseName || joinColumn.databaseName; const parentPropertyName = joinColumn.givenDatabaseName || joinColumn.databaseName;
const qb = this.buildBaseQB().orderBy('category.customOrder', 'ASC'); const qb = this.buildBaseQB().orderBy('category.customOrder', 'ASC');
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options); qb.where(`${escapeAlias('category')}.${escapeColumn(parentPropertyName)} IS NULL`);
return qb FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, pick(options, ['relations', 'depth']));
.where(`${escapeAlias('category')}.${escapeColumn(parentPropertyName)} IS NULL`)
.getMany(); 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<CategoryEntity> {
* @param entity * @param entity
* @param options * @param options
*/ */
findDescendants(entity: CategoryEntity, options?: FindTreeOptions) { findDescendants(
entity: CategoryEntity,
options?: FindTreeOptions & {
onlyTrashed?: boolean;
withTrashed?: boolean;
},
) {
const qb = this.createDescendantsQueryBuilder('category', 'treeClosure', entity); const qb = this.createDescendantsQueryBuilder('category', 'treeClosure', entity);
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options); FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options);
qb.orderBy('category.customOrder', 'ASC'); qb.orderBy('category.customOrder', 'ASC');
if (options?.withTrashed) {
qb.withDeleted();
if (options?.onlyTrashed) qb.where(`category.deletedAt IS NOT NULL`);
}
return qb.getMany(); return qb.getMany();
} }
@ -60,11 +85,22 @@ export class CategoryRepository extends TreeRepository<CategoryEntity> {
* @param entity * @param entity
* @param options * @param options
*/ */
findAncestors(entity: CategoryEntity, options?: FindTreeOptions) { findAncestors(
entity: CategoryEntity,
options?: FindTreeOptions & {
onlyTrashed?: boolean;
withTrashed?: boolean;
},
) {
const qb = this.createAncestorsQueryBuilder('category', 'treeClosure', entity); const qb = this.createAncestorsQueryBuilder('category', 'treeClosure', entity);
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options); FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options);
qb.orderBy('category.customOrder', 'ASC'); qb.orderBy('category.customOrder', 'ASC');
if (options?.withTrashed) {
qb.withDeleted();
if (options?.onlyTrashed) qb.where(`category.deletedAt IS NOT NULL`);
}
return qb.getMany(); return qb.getMany();
} }
@ -88,4 +124,42 @@ export class CategoryRepository extends TreeRepository<CategoryEntity> {
return data as CategoryEntity[]; 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();
}
} }

View File

@ -1,11 +1,17 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { isNil, omit } from 'lodash'; 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 { CategoryEntity } from '@/modules/content/entities';
import { CategoryRepository } from '@/modules/content/repositories'; import { CategoryRepository } from '@/modules/content/repositories';
import { SelectTrashMode } from '@/modules/database/constants';
import { treePaginate } from '@/modules/database/helpers'; import { treePaginate } from '@/modules/database/helpers';
/** /**
@ -17,9 +23,15 @@ export class CategoryService {
/** /**
* *
* @param options
*/ */
async findTress() { async findTrees(options: QueryCategoryTreeDto) {
return this.repository.findTrees(); 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 * @param options
*/ */
async paginate(options: QueryCategoryDto) { 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); const data = await this.repository.toFlatTrees(tree);
return treePaginate(options, data); return treePaginate(options, data);
@ -83,23 +100,36 @@ export class CategoryService {
* *
* @param id * @param id
*/ */
async delete(id: string) { async delete(ids: string[], trash?: boolean) {
const item = await this.repository.findOneOrFail({ const items = await this.repository.find({
where: { id }, where: { id: In(ids) },
withDeleted: true,
relations: ['parent', 'children'], relations: ['parent', 'children'],
}); });
// 把子分类提升一级 // 把子分类提升一级
for (const item of items) {
if (!isNil(item.children) && item.children.length > 0) { if (!isNil(item.children) && item.children.length > 0) {
const nchildren = [...item.children].map((c) => { const nchildren = [...item.children].map((c) => {
c.parent = item.parent; c.parent = item.parent;
return item; 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; 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();
}
} }

View File

@ -2,7 +2,7 @@ import { ForbiddenException, Injectable } from '@nestjs/common';
import { isNil } from 'lodash'; import { isNil } from 'lodash';
import { EntityNotFoundError, SelectQueryBuilder } from 'typeorm'; import { EntityNotFoundError, In, SelectQueryBuilder } from 'typeorm';
import { CreateCommentDto, QueryCommentDto, QueryCommentTreeDto } from '@/modules/content/dtos'; import { CreateCommentDto, QueryCommentDto, QueryCommentTreeDto } from '@/modules/content/dtos';
import { CommentEntity } from '@/modules/content/entities'; import { CommentEntity } from '@/modules/content/entities';
@ -82,11 +82,11 @@ export class CommentService {
/** /**
* *
* @param id * @param ids
*/ */
async delete(id: string) { async delete(ids: string[]) {
const comment = await this.repository.findOneOrFail({ where: { id: id ?? null } }); const comments = await this.repository.find({ where: { id: In(ids) } });
return this.repository.remove(comment); return this.repository.remove(comments);
} }
/** /**

View File

@ -10,6 +10,7 @@ import { PostEntity } from '@/modules/content/entities';
import { CategoryRepository, PostRepository, TagRepository } from '@/modules/content/repositories'; import { CategoryRepository, PostRepository, TagRepository } from '@/modules/content/repositories';
import { CategoryService } from '@/modules/content/services/category.service'; import { CategoryService } from '@/modules/content/services/category.service';
import { SelectTrashMode } from '@/modules/database/constants';
import { paginate } from '@/modules/database/helpers'; import { paginate } from '@/modules/database/helpers';
import { QueryHook } from '@/modules/database/types'; import { QueryHook } from '@/modules/database/types';
@ -108,9 +109,45 @@ export class PostService {
* *
* @param id * @param id
*/ */
async delete(id: string) { async delete(ids: string[], trash?: boolean) {
const item = await this.repository.findOneByOrFail({ id }); const items = await this.repository.find({
return this.repository.remove(item); 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, options: FindParams,
callback?: QueryHook<PostEntity>, callback?: QueryHook<PostEntity>,
) { ) {
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') { if (typeof isPublished === 'boolean') {
isPublished isPublished
? qb.where({ ? qb.where({

View File

@ -1,10 +1,19 @@
import { Injectable } from '@nestjs/common'; 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 { CreateTagDto, QueryTagsDto, UpdateTagDto } from '@/modules/content/dtos';
import { TagEntity } from '@/modules/content/entities';
import { TagRepository } from '@/modules/content/repositories'; import { TagRepository } from '@/modules/content/repositories';
import { SelectTrashMode } from '@/modules/database/constants';
import { paginate } from '@/modules/database/helpers'; import { paginate } from '@/modules/database/helpers';
import { QueryHook } from '@/modules/database/types';
type FindParams = {
[key in keyof Omit<QueryTagsDto, 'limit' | 'page'>]: QueryTagsDto[key];
};
/** /**
* *
@ -19,7 +28,7 @@ export class TagService {
* @param callback * @param callback
*/ */
async paginate(options: QueryTagsDto) { async paginate(options: QueryTagsDto) {
const qb = this.repository.buildBaseQB(); const qb = await this.buildListQuery(this.repository.buildBaseQB(), options);
return paginate(qb, options); return paginate(qb, options);
} }
@ -54,10 +63,64 @@ export class TagService {
/** /**
* *
* @param id * @param ids
*/ */
async delete(id: string) { async delete(ids: string[], trash: boolean) {
const item = await this.repository.findOneByOrFail({ id }); const items = await this.repository.find({
return this.repository.remove(item); 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<TagEntity>,
options: FindParams,
callback?: QueryHook<TagEntity>,
) {
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;
} }
} }

View File

@ -2,3 +2,12 @@
* Repository * Repository
*/ */
export const CUSTOM_REPOSITORY_METADATA = 'CUSTOM_REPOSITORY_METADATA'; export const CUSTOM_REPOSITORY_METADATA = 'CUSTOM_REPOSITORY_METADATA';
/**
*
*/
export enum SelectTrashMode {
ALL = 'all',
ONLY = 'only',
NONE = 'none',
}

View File

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

View File

@ -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[] = [];
}

View File

@ -0,0 +1,3 @@
export * from './delete-with-trash.dto';
export * from './delete.dto';
export * from './restore.dto';

View File

@ -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[] = [];
}