From 5bcb4853e56db9c5e07ca9120f1a649035635610 Mon Sep 17 00:00:00 2001 From: xidongdong-153 Date: Fri, 15 Dec 2023 11:19:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=A2=9E=E5=8A=A0=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E3=80=81=E8=BD=AF=E5=88=A0=E9=99=A4=E5=92=8C?= =?UTF-8?q?=E8=BD=AF=E5=88=A0=E9=99=A4=E6=81=A2=E5=A4=8D=20-=20=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E5=9F=BA=E7=A1=80=E7=9A=84=E8=BD=AF=E5=88=A0=E9=99=A4?= =?UTF-8?q?=20-=20=E5=AE=8C=E6=88=90=E6=A0=91=E5=BD=A2=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=9A=84=E8=BD=AF=E5=88=A0=E9=99=A4(children=E6=9C=AA=E5=A4=84?= =?UTF-8?q?=E7=90=86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/database6.db | Bin 73728 -> 77824 bytes back/database9.db | Bin 61440 -> 86016 bytes src/config/database.config.ts | 2 +- .../controllers/category.controller.ts | 28 ++++-- .../content/controllers/comment.controller.ts | 20 ++-- .../content/controllers/post.controller.ts | 17 +++- .../content/controllers/tag.controller.ts | 23 +++-- src/modules/content/dtos/category.dto.ts | 14 ++- src/modules/content/dtos/post.dto.ts | 5 + src/modules/content/dtos/tag.dto.ts | 6 ++ src/modules/content/entities/tag.entity.ts | 11 +- .../repositories/category.repository.ts | 94 ++++++++++++++++-- .../content/services/category.service.ts | 78 ++++++++++++--- .../content/services/comment.service.ts | 10 +- src/modules/content/services/post.service.ts | 50 +++++++++- src/modules/content/services/tag.service.ts | 75 ++++++++++++-- src/modules/database/constants.ts | 9 ++ .../restful/dtos/delete-with-trash.dto.ts | 18 ++++ src/modules/restful/dtos/delete.dto.ts | 19 ++++ src/modules/restful/dtos/index.ts | 3 + src/modules/restful/dtos/restore.dto.ts | 19 ++++ 21 files changed, 428 insertions(+), 73 deletions(-) create mode 100644 src/modules/restful/dtos/delete-with-trash.dto.ts create mode 100644 src/modules/restful/dtos/delete.dto.ts create mode 100644 src/modules/restful/dtos/index.ts create mode 100644 src/modules/restful/dtos/restore.dto.ts diff --git a/back/database6.db b/back/database6.db index 66a80e80664f4a91de433e800374e22873f2ccac..fd4158830cbc7e28dc665a390367d01a39b6aa6f 100644 GIT binary patch delta 703 zcmZoTz|!!5WrDQeLIwr~VIXD)VkRICoTy`LxR61wXb~@GDT4sxdj{V7ybU~Od8FBG z+5WIhWC>%w!0gR*n5%-Rj`2O`an3%*MU0CkI!aDlsb$mXW6LgXY021RT#}fSlUkCR zTaaIrSX3FGoS#>cnpYBEl9*nMU6gs^M}^66IAbRJvx%{5dfTyy$6HSJ=Mptat%%32 zBi<0ZxbWupjBV`lK)*cV=B1=o6c^@XmZZifmX_pangh-DRt1?oc><@bfN-N8 z(9x=@j7{=jM^9$t6JZ6plX>zr&LWU=7&SLnaCL}^@B)ow<@?LPe~y1De=@%z-(S8* zn*|ci@lAfDC(Ule${Gr^baH>a%4D5*PIdzp)=+66KQUira(p~Hn*lS3KRG*IhuxeB z$ON+I#v8F4FhZ2w0V?|$&%Kz%K!KS-fMGLxz)yZ2poJj72*hlgc@loe^D*<9G4Ltz zn(-%cJ8(N}Rus_V)^Duf;0TR24sBEnO-V5|NKG^{)ip~?PSQ0=wKURAGDp_!GD>12L= zmC3jJRaJ;GQ?OT+g>NUr@6Gl-M~(TI`Ar!3P55K^uk&;Ba|2!Zns@WxvwV!Y8qA@L m#F)ncQaIVMS7ma{d0oc9$%W@sHm^G$6fntvWiv~{AASI`DbFzg delta 384 zcmZp8z|wGlWrDQeJO%~^As}W3Vn!hLo2X-~KaWALXb~@$CxZavdj{V7ybU~Od89el zah9^%vi)J1$P&hUf!UksFjoar9pihZtci}&6IW_+7nh{w7UUNt7FEVip2?*+S(Z^~ zawZesUPMG=Y}uhZ{07 zWag!$RumWJWR|4HCzh7v1NqG+R_x-gu8ghOC5cHnsbG6HS8#QRiU2ji026;3e=PqE zzDJt{63+2We&iQ6nOk0EdW<~dip4Aj3e2qhOqO)29$lxyZP@~K1N-` z7wcPItlMQ|WME{hYhbBsWUOFlWMynVQG5yg~3Lbcux)IyRH zn{ph|IhJ}Ys81(n7LNN^{F#6O&Lvv%Uf zX;XqY#6L|msQ;l@>tO)EMPyt^hK~&K;iX7A47!G&6v%%O zng_xc!bs@7P%OAPDDiLeJJ|{LAlvTW>yP+K^lF-?m(ojdBJv+RLfyo!yM&G2z5?$S z=HZh}8#8ppC3vUM6Kvb2rnsmmQunvxkAxI;0WJrkxHWtVZ-@8dQ{k<9f*r+DXfYRE zEm5V*Hx1g(pI2Xf^F}*kny#)&nq{h*Y-px#AyZLoWLran1!u6Z(J~9p?L)(xoP6ub z8&<8jaaDYE@71erjwjD8o{X>UjVGIw;0KvNanKxal9$Fy{zZgJCspcfP;nKc$;7T? zWGqS7GMb5W1DVLxrnGDs$`|sHGg-_o+025-7Yq4hd`U8Y_rQQTe6L4@PS%Me8blmb zN@s{43`0{aQ<7ZMQEf%BbiIiPb0(45C8vlK>rSoh#3Pa$vTH1uM4iQ=1`ES+Gj`6f zC1kr9U6*Z?v2@c-TUHYb`b-wHOD5xsrk_fKMPXCnPVZD!cCgX)5(%zEr9bc{h1-40 zD;>c*XjWUmMz2kg2l2{ZeNX!?ZXuz|!TSKL!jIrv@OgLu?t=GK67kQ1m4C9~#xTnTYD2i739Ah)hSA&cJj%QfHM<1{5 zEsq~Qtc{m<)OB)Ac&34~O}N^?{Y|*iz)lS*=Dehn$db*RXd^i-DoD~rUCFs3a%7dLP#w*5yXK;P{7lH;@O9&M1zSc>avs%Yu9O=v5YnA03bRPA)mRWquoy2h|$T7|O_ zFP@G0Tr_sgg*c?a!Mbz7EZ!*orr!N|j$C63fvBZ zupb@^pRA>BaS4;99EB9E42koM<>o}iP&LtTNWj>-nll_-Q=E*!c?;(f2epN>9B$Lu zvYtk4uI=?I`4Z2`S9r)=cH|dAdjS`U0_cXv!q*5#g`UtGp(sh>D}#|z4KRV9T}RMm_jDi%p+-Br?}Ob$zu(~@cGh7TXxy|k{X43AY>AKDKezTS_^ z2QIBUmnhp4_f0J*pI%U&T(G;ic+be>0tZ$InGFy-M|*2z0<~=)KbjKYqkw@HfbT;K z?jr#dfY<$r_Ge=;w9-dD0GbQg>gbN~XO1Y-J)(7^baxtI>jT;F%3&-9(lv$P7bwK< zf3Tbz3ZD@ED=Z}CGbB_NwolM4H3uJqWw^Z({&F3}=CT8mHTML7Kfxd2_wZXd0l$JL z>TVgmO!qN>;=GCVCciiNyovE9nqfeo1s*3U^$LJL)T-gzMErI5LQO#*$FxyfLrgn0 z^(D)-F!QN;puX7&5%(7?Ke_qxZd>`h=v%v(${Hw`*u%fOMw$jZ(oG*fKOs8o7;A zJ&`r}tZmym(hsj#LESH5fu5I~gl8AH03V^^I1(N^-!PwU+s`-5|EGp|fgpSgxBw5} zYr@*lJE5zBl|X-h_kKx)f5)GX@bgzf?{fQlTnQ8Byb=OHRq-z2k3sSkO+DqkVffoU VwKojmX>S<*!k)f4;{HI#{{UI3AM*eJ delta 1785 zcmb`HUuauZ9LLYe{hQuDr=7AaWNC9*Vy*6`Np6z6ND9p4j&}QeGx=UVfe6z_0bAbiclvCwhtnEQ1IR)ozA=j@xtXC z&hOtjzw`Z^r8R14n^^I+tN;K|?RVMZk=3J0#|j9~elED%0Z;mYa7TEbq3K)HZ`1&} zN}l3cNx%D?J4xIoZeywW0bYV9@dr@AB4Y3$<4wGs@-v>HA$YM=)UvPZMg5KWxdmX>>=|B$8d+{ z%mI7S^e~*lm)Slm%59RBBV?iwOwZ=^vx{$uX^%H#)T3@p>F1o*&_MDK-jWm>S zr_0L`?9&nGQ1J4e=bUZz3M$+)|LBVbh zDI5l9PW)c@Q#dSK6lngEB?Z3atX`n(cV~BZzPUeQbFMY!>jEI0DizK3J#6;mJEevRj=ES zR7L4dC|!x9ZUpw1!2Xh)=t;)qxSrSK?qpt^BGB6s7cFsnh?~keU;f4G*b%V^OM6B zLB<1-NN=PfNT;2iaf39D&;tH&T*7DC5Z)Ufbv9+CCMYO;q=x>L=J&n>54HVhDr;x5 zt%Y@=0zeijSklrf2p38(P$Q5~Rg5}=*1gg<2wUY4-da9tJC=dn(=AKdyNPi5MhEG1 z!STDgMpms`s9dv_Yu4pb@amFd)dA9xWfyrFVSRm+dY)9P!T8>K$U&JvIe_k>Yba;6 z%)1fuoQqO@J_Xwuq#xHV-T(BfSZ#nVjPZ4FFNzmvUNu^2rb^0x0Hw1bP>cYc6j7PkgjX-W5kUR|=4bJ$ 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[] = []; +}