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',
// 以下为sqlite配置
type: 'better-sqlite3',
database: resolve(__dirname, '../../back/database6.db'),
database: resolve(__dirname, '../../back/database9.db'),
synchronize: true,
autoLoadEntities: true,
});

View File

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

View File

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

View File

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

View File

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

View File

@ -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()

View File

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

View File

@ -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()

View File

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

View File

@ -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<CategoryEntity> {
*
* @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<CategoryEntity> {
*
* @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<CategoryEntity> {
* @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<CategoryEntity> {
* @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<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 { 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();
}
}

View File

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

View File

@ -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<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') {
isPublished
? qb.where({

View File

@ -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, 'limit' | 'page'>]: 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<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
*/
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[] = [];
}