From 2c70ce4cdfb184b7620529093a08aef636a4b4c3 Mon Sep 17 00:00:00 2001 From: liuyi Date: Mon, 30 Jun 2025 17:43:12 +0800 Subject: [PATCH] add rbac module --- src/config/api.config.ts | 32 ++-- src/modules/content/content.module.ts | 5 + .../controllers/category.controller.ts | 45 +----- .../content/controllers/comment.controller.ts | 57 ++++++- .../manager/category.controller.ts | 98 ++++++++++++ .../controllers/manager/comment.controller.ts | 53 +++++++ .../content/controllers/manager/index.ts | 4 + .../controllers/manager/post.controller.ts | 130 ++++++++++++++++ .../controllers/manager/tag.controller.ts | 99 ++++++++++++ .../content/controllers/post.controller.ts | 141 ++++++++++++++++-- .../content/controllers/tag.controller.ts | 53 ++----- src/modules/content/dtos/post.dto.ts | 101 ++++++++++++- src/modules/content/routes.ts | 44 ++++++ .../content/services/comment.service.ts | 6 +- src/modules/content/services/post.service.ts | 33 +++- src/modules/rbac/routes.ts | 8 +- src/modules/rbac/utils.ts | 2 +- 17 files changed, 797 insertions(+), 114 deletions(-) create mode 100644 src/modules/content/controllers/manager/category.controller.ts create mode 100644 src/modules/content/controllers/manager/comment.controller.ts create mode 100644 src/modules/content/controllers/manager/index.ts create mode 100644 src/modules/content/controllers/manager/post.controller.ts create mode 100644 src/modules/content/controllers/manager/tag.controller.ts create mode 100644 src/modules/content/routes.ts diff --git a/src/config/api.config.ts b/src/config/api.config.ts index 00d3fba..f29a157 100644 --- a/src/config/api.config.ts +++ b/src/config/api.config.ts @@ -1,11 +1,14 @@ import { Configure } from '@/modules/config/configure'; import { ConfigureFactory } from '@/modules/config/types'; -import * as contentControllers from '@/modules/content/controllers'; +import { createContentApi } from '@/modules/content/routes'; +import { createRbacApi } from '@/modules/rbac/routes'; import { ApiConfig, VersionOption } from '@/modules/restful/types'; import { createUserApi } from '@/modules/user/routes'; export const v1 = async (configure: Configure): Promise => { + const contentApi = createContentApi(); const userApi = createUserApi(); + const rbacApi = createRbacApi(); return { routes: [ { @@ -14,21 +17,26 @@ export const v1 = async (configure: Configure): Promise => { controllers: [], doc: { description: 'app name desc', + tags: [...contentApi.tags.app, ...rbacApi.tags.app, ...userApi.tags.app], + }, + children: [...contentApi.routes.app, ...rbacApi.routes.app, ...userApi.routes.app], + }, + { + name: 'manager', + path: 'manager', + controllers: [], + doc: { + description: '后台管理接口', tags: [ - { name: '分类操作', description: '对分类进行CRUD操作' }, - { name: '标签操作', description: '对标签进行CRUD操作' }, - { name: '文章操作', description: '对文章进行CRUD操作' }, - { name: '评论操作', description: '对评论进行CRUD操作' }, - ...userApi.tags.app, + ...contentApi.tags.manager, + ...rbacApi.tags.manager, + ...userApi.tags.manager, ], }, children: [ - { - name: 'app.content', - path: 'content', - controllers: Object.values(contentControllers), - }, - ...userApi.routes.app, + ...contentApi.routes.manager, + ...rbacApi.routes.manager, + ...userApi.routes.manager, ], }, ], diff --git a/src/modules/content/content.module.ts b/src/modules/content/content.module.ts index fe1e0ee..5431587 100644 --- a/src/modules/content/content.module.ts +++ b/src/modules/content/content.module.ts @@ -13,6 +13,8 @@ import { DatabaseModule } from '@/modules/database/database.module'; import { addEntities, addSubscribers } from '@/modules/database/utils'; +import { UserRepository } from '@/modules/user/repositories'; + import { Configure } from '../config/configure'; import { defauleContentConfig } from './config'; @@ -34,6 +36,7 @@ export class ContentModule { repositories.CategoryRepository, repositories.TagRepository, services.CategoryService, + UserRepository, { token: services.SearchService, optional: true }, ], useFactory( @@ -42,12 +45,14 @@ export class ContentModule { tagRepository: repositories.TagRepository, categoryService: services.CategoryService, searchService: SearchService, + userRepository: UserRepository, ) { return new PostService( postRepository, categoryRepository, categoryService, tagRepository, + userRepository, searchService, config.searchType, ); diff --git a/src/modules/content/controllers/category.controller.ts b/src/modules/content/controllers/category.controller.ts index 60c0ea8..4708a12 100644 --- a/src/modules/content/controllers/category.controller.ts +++ b/src/modules/content/controllers/category.controller.ts @@ -1,15 +1,4 @@ -import { - Body, - Controller, - Delete, - Get, - Param, - ParseUUIDPipe, - Patch, - Post, - Query, - SerializeOptions, -} from '@nestjs/common'; +import { Controller, Get, Param, ParseUUIDPipe, Query, SerializeOptions } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; @@ -17,11 +6,12 @@ import { Depends } from '@/modules/restful/decorators/depend.decorator'; import { PaginateDto } from '@/modules/restful/dtos/paginate.dto'; +import { Guest } from '@/modules/user/decorators/guest.decorator'; + import { ContentModule } from '../content.module'; -import { CreateCategoryDto, UpdateCategoryDto } from '../dtos/category.dto'; import { CategoryService } from '../services'; -@ApiTags('Category Operate') +@ApiTags('分类查询') @Depends(ContentModule) @Controller('category') export class CategoryController { @@ -31,6 +21,7 @@ export class CategoryController { * Search category tree */ @Get('tree') + @Guest() @SerializeOptions({ groups: ['category-tree'] }) async tree() { return this.service.findTrees(); @@ -41,6 +32,7 @@ export class CategoryController { * @param options */ @Get() + @Guest() @SerializeOptions({ groups: ['category-list'] }) async list( @Query() @@ -54,32 +46,9 @@ export class CategoryController { * @param id */ @Get(':id') + @Guest() @SerializeOptions({ groups: ['category-detail'] }) async detail(@Param('id', new ParseUUIDPipe()) id: string) { return this.service.detail(id); } - - @Post() - @SerializeOptions({ groups: ['category-detail'] }) - async store( - @Body() - data: CreateCategoryDto, - ) { - return this.service.create(data); - } - - @Patch() - @SerializeOptions({ groups: ['category-detail'] }) - async update( - @Body() - data: UpdateCategoryDto, - ) { - return this.service.update(data); - } - - @Delete(':id') - @SerializeOptions({ groups: ['category-detail'] }) - async delete(@Param('id', new ParseUUIDPipe()) id: string) { - return this.service.delete([id]); - } } diff --git a/src/modules/content/controllers/comment.controller.ts b/src/modules/content/controllers/comment.controller.ts index 41ca66b..ff6d779 100644 --- a/src/modules/content/controllers/comment.controller.ts +++ b/src/modules/content/controllers/comment.controller.ts @@ -1,7 +1,22 @@ import { Body, Controller, Delete, Get, Post, Query, SerializeOptions } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { In } from 'typeorm'; + +import { CommentEntity } from '@/modules/content/entities'; +import { CommentRepository } from '@/modules/content/repositories'; +import { PermissionAction } from '@/modules/rbac/constants'; +import { Permission } from '@/modules/rbac/decorators/permission.decorator'; +import { PermissionChecker } from '@/modules/rbac/types'; +import { checkOwnerPermission } from '@/modules/rbac/utils'; import { Depends } from '@/modules/restful/decorators/depend.decorator'; +import { Guest } from '@/modules/user/decorators/guest.decorator'; + +import { RequestUser } from '@/modules/user/decorators/user.request.decorator'; + +import { UserEntity } from '@/modules/user/entities'; + import { ContentModule } from '../content.module'; import { CreateCommentDto, @@ -11,18 +26,41 @@ import { } from '../dtos/comment.dto'; import { CommentService } from '../services'; +const permissions: Record<'create' | 'owner', PermissionChecker> = { + create: async (ab) => ab.can(PermissionAction.CREATE, CommentEntity.name), + owner: async (ab, ref, request) => + checkOwnerPermission(ab, { + request, + getData: async (items) => + ref.get(CommentRepository, { strict: false }).find({ + relations: ['user'], + where: { id: In(items) }, + }), + }), +}; + +@ApiTags('评论操作') @Depends(ContentModule) @Controller('comment') export class CommentController { constructor(protected service: CommentService) {} - + /** + * 查询评论树 + * @param options + */ @Get('tree') + @Guest() @SerializeOptions({ groups: ['comment-tree'] }) async tree(@Query() options: QueryCommentTreeDto) { return this.service.findTrees(options); } + /** + * 查询评论列表 + * @param options + */ @Get() + @Guest() @SerializeOptions({ groups: ['comment-list'] }) async list( @Query() @@ -31,13 +69,26 @@ export class CommentController { return this.service.paginate(options); } + /** + * 新增评论 + * @param data + * @param author + */ @Post() + @ApiBearerAuth() + @Permission(permissions.create) @SerializeOptions({ groups: ['comment-detail'] }) - async store(@Body() data: CreateCommentDto) { - return this.service.create(data); + async store(@Body() data: CreateCommentDto, @RequestUser() author: ClassToPlain) { + return this.service.create(data, author); } + /** + * 批量删除评论 + * @param data + */ @Delete() + @ApiBearerAuth() + @Permission(permissions.owner) @SerializeOptions({ groups: ['comment-detail'] }) async delete(@Body() data: DeleteCommentDto) { return this.service.delete(data.ids); diff --git a/src/modules/content/controllers/manager/category.controller.ts b/src/modules/content/controllers/manager/category.controller.ts new file mode 100644 index 0000000..6de2d3f --- /dev/null +++ b/src/modules/content/controllers/manager/category.controller.ts @@ -0,0 +1,98 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + SerializeOptions, +} from '@nestjs/common'; + +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; + +import { ContentModule } from '@/modules/content/content.module'; +import { CreateCategoryDto, UpdateCategoryDto } from '@/modules/content/dtos/category.dto'; +import { CategoryEntity } from '@/modules/content/entities'; +import { CategoryService } from '@/modules/content/services'; +import { PermissionAction } from '@/modules/rbac/constants'; +import { Permission } from '@/modules/rbac/decorators/permission.decorator'; +import { PermissionChecker } from '@/modules/rbac/types'; +import { Depends } from '@/modules/restful/decorators/depend.decorator'; + +import { PaginateDto } from '@/modules/restful/dtos/paginate.dto'; + +const permission: PermissionChecker = async (ab) => + ab.can(PermissionAction.MANAGE, CategoryEntity.name); + +@ApiTags('分类管理') +@ApiBearerAuth() +@Depends(ContentModule) +@Controller('category') +export class CategoryController { + constructor(protected service: CategoryService) {} + + /** + * Search category tree + */ + @Get('tree') + @Permission(permission) + @SerializeOptions({ groups: ['category-tree'] }) + async tree() { + return this.service.findTrees(); + } + + /** + * 分页查询分类列表 + * @param options + */ + @Get() + @Permission(permission) + @SerializeOptions({ groups: ['category-list'] }) + async list( + @Query() + options: PaginateDto, + ) { + return this.service.paginate(options); + } + + /** + * 查询分类明细 + * @param id + */ + @Get(':id') + @Permission(permission) + @SerializeOptions({ groups: ['category-detail'] }) + async detail(@Param('id', new ParseUUIDPipe()) id: string) { + return this.service.detail(id); + } + + @Post() + @Permission(permission) + @SerializeOptions({ groups: ['category-detail'] }) + async store( + @Body() + data: CreateCategoryDto, + ) { + return this.service.create(data); + } + + @Patch() + @Permission(permission) + @SerializeOptions({ groups: ['category-detail'] }) + async update( + @Body() + data: UpdateCategoryDto, + ) { + return this.service.update(data); + } + + @Delete(':id') + @Permission(permission) + @SerializeOptions({ groups: ['category-detail'] }) + async delete(@Param('id', new ParseUUIDPipe()) id: string) { + return this.service.delete([id]); + } +} diff --git a/src/modules/content/controllers/manager/comment.controller.ts b/src/modules/content/controllers/manager/comment.controller.ts new file mode 100644 index 0000000..47f852c --- /dev/null +++ b/src/modules/content/controllers/manager/comment.controller.ts @@ -0,0 +1,53 @@ +import { Body, Controller, Delete, Get, Query, SerializeOptions } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; + +import { ContentModule } from '@/modules/content/content.module'; +import { QueryCommentDto } from '@/modules/content/dtos/comment.dto'; +import { DeleteDto } from '@/modules/content/dtos/delete.dto'; +import { CommentEntity } from '@/modules/content/entities'; +import { CommentService } from '@/modules/content/services'; +import { PermissionAction } from '@/modules/rbac/constants'; +import { Permission } from '@/modules/rbac/decorators/permission.decorator'; +import { PermissionChecker } from '@/modules/rbac/types'; + +import { Depends } from '@/modules/restful/decorators/depend.decorator'; + +const permission: PermissionChecker = async (ab) => + ab.can(PermissionAction.MANAGE, CommentEntity.name); + +@ApiTags('评论管理') +@ApiBearerAuth() +@Depends(ContentModule) +@Controller('comments') +export class CommentController { + constructor(protected service: CommentService) {} + + /** + * 查询评论列表 + * @param query + */ + @Get() + @SerializeOptions({ groups: ['comment-list'] }) + @Permission(permission) + async list( + @Query() + query: QueryCommentDto, + ) { + return this.service.paginate(query); + } + + /** + * 批量删除评论 + * @param data + */ + @Delete() + @SerializeOptions({ groups: ['comment-list'] }) + @Permission(permission) + async delete( + @Body() + data: DeleteDto, + ) { + const { ids } = data; + return this.service.delete(ids); + } +} diff --git a/src/modules/content/controllers/manager/index.ts b/src/modules/content/controllers/manager/index.ts new file mode 100644 index 0000000..87673cd --- /dev/null +++ b/src/modules/content/controllers/manager/index.ts @@ -0,0 +1,4 @@ +export * from './category.controller'; +export * from './tag.controller'; +export * from './post.controller'; +export * from './comment.controller'; diff --git a/src/modules/content/controllers/manager/post.controller.ts b/src/modules/content/controllers/manager/post.controller.ts new file mode 100644 index 0000000..5ba50c9 --- /dev/null +++ b/src/modules/content/controllers/manager/post.controller.ts @@ -0,0 +1,130 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + SerializeOptions, +} from '@nestjs/common'; + +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { isNil } from 'lodash'; + +import { ContentModule } from '@/modules/content/content.module'; +import { DeleteWithTrashDto, RestoreDto } from '@/modules/content/dtos/delete.with.trash.dto'; +import { PostEntity } from '@/modules/content/entities'; +import { PostService } from '@/modules/content/services/post.service'; +import { PermissionAction } from '@/modules/rbac/constants'; +import { Permission } from '@/modules/rbac/decorators/permission.decorator'; +import { PermissionChecker } from '@/modules/rbac/types'; +import { Depends } from '@/modules/restful/decorators/depend.decorator'; +import { RequestUser } from '@/modules/user/decorators/user.request.decorator'; +import { UserEntity } from '@/modules/user/entities'; + +import { CreatePostDto, QueryPostDto, UpdatePostDto } from '../../dtos/post.dto'; + +const permission: PermissionChecker = async (ab) => + ab.can(PermissionAction.MANAGE, PostEntity.name); + +@ApiTags('文章管理') +@ApiBearerAuth() +@Depends(ContentModule) +@Controller('posts') +export class PostController { + constructor(protected service: PostService) {} + + /** + * 查询文章列表 + * @param options + */ + @Get() + @SerializeOptions({ groups: ['post-list'] }) + @Permission(permission) + async manageList( + @Query() + options: QueryPostDto, + ) { + return this.service.paginate(options); + } + + /** + * 查询文章详情 + * @param id + */ + @Get(':id') + @SerializeOptions({ groups: ['post-detail'] }) + @Permission(permission) + async manageDetail( + @Param('id', new ParseUUIDPipe()) + id: string, + ) { + return this.service.detail(id); + } + + /** + * 新增文章 + * @param author + * @param data + * @param user + */ + @Post() + @SerializeOptions({ groups: ['post-detail'] }) + @Permission(permission) + async storeManage( + @Body() + { author, ...data }: CreatePostDto, + @RequestUser() user: ClassToPlain, + ) { + return this.service.create(data, { + id: isNil(author) ? user.id : author, + } as ClassToPlain); + } + + /** + * 更新文章 + * @param data + */ + @Patch() + @SerializeOptions({ groups: ['post-detail'] }) + @Permission(permission) + async manageUpdate( + @Body() + data: UpdatePostDto, + ) { + return this.service.update(data); + } + + /** + * 批量删除文章 + * @param data + */ + @Delete() + @SerializeOptions({ groups: ['post-list'] }) + @Permission(permission) + async manageDelete( + @Body() + data: DeleteWithTrashDto, + ) { + const { ids, trash } = data; + return this.service.delete(ids, trash); + } + + /** + * 批量恢复文章 + * @param data + */ + @Patch('restore') + @SerializeOptions({ groups: ['post-list'] }) + @Permission(permission) + async manageRestore( + @Body() + data: RestoreDto, + ) { + const { ids } = data; + return this.service.restore(ids); + } +} diff --git a/src/modules/content/controllers/manager/tag.controller.ts b/src/modules/content/controllers/manager/tag.controller.ts new file mode 100644 index 0000000..d12374d --- /dev/null +++ b/src/modules/content/controllers/manager/tag.controller.ts @@ -0,0 +1,99 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + SerializeOptions, +} from '@nestjs/common'; + +import { ApiTags } from '@nestjs/swagger'; + +import { ContentModule } from '@/modules/content/content.module'; +import { DeleteDto } from '@/modules/content/dtos/delete.dto'; +import { CreateTagDto, UpdateTagDto } from '@/modules/content/dtos/tag.dto'; +import { TagEntity } from '@/modules/content/entities'; +import { TagService } from '@/modules/content/services'; +import { PermissionAction } from '@/modules/rbac/constants'; +import { Permission } from '@/modules/rbac/decorators/permission.decorator'; +import { PermissionChecker } from '@/modules/rbac/types'; +import { Depends } from '@/modules/restful/decorators/depend.decorator'; + +import { PaginateDto } from '@/modules/restful/dtos/paginate.dto'; + +const permission: PermissionChecker = async (ab) => ab.can(PermissionAction.MANAGE, TagEntity.name); + +@ApiTags('标签查询') +@Depends(ContentModule) +@Controller('tag') +export class TagController { + constructor(protected service: TagService) {} + + /** + * 分页查询标签列表 + * @param options + */ + @Get() + @Permission(permission) + @SerializeOptions({}) + async list( + @Query() + options: PaginateDto, + ) { + return this.service.paginate(options); + } + + /** + * 查询标签详情 + * @param id + */ + @Get(':id') + @Permission(permission) + @SerializeOptions({}) + async detail(@Param('id', new ParseUUIDPipe()) id: string) { + return this.service.detail(id); + } + + /** + * 添加新标签 + * @param data + */ + @Post() + @Permission(permission) + @SerializeOptions({}) + async store( + @Body() + data: CreateTagDto, + ) { + return this.service.create(data); + } + + /** + * 更新标签 + * @param data + */ + @Patch() + @Permission(permission) + @SerializeOptions({}) + async update( + @Body() + data: UpdateTagDto, + ) { + return this.service.update(data); + } + + /** + * 批量删除标签 + * @param data + */ + @Delete() + @Permission(permission) + @SerializeOptions({}) + async delete(@Body() data: DeleteDto) { + return this.service.delete(data.ids); + } +} diff --git a/src/modules/content/controllers/post.controller.ts b/src/modules/content/controllers/post.controller.ts index a889224..3c121ff 100644 --- a/src/modules/content/controllers/post.controller.ts +++ b/src/modules/content/controllers/post.controller.ts @@ -11,64 +11,179 @@ import { SerializeOptions, } from '@nestjs/common'; -import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos/post.dto'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; + +import { In, IsNull, Not } from 'typeorm'; + +import { + FrontendCreatePostDto, + FrontendQueryPostDto, + OwnerQueryPostDto, + OwnerUpdatePostDto, +} from '@/modules/content/dtos/post.dto'; +import { PostEntity } from '@/modules/content/entities'; +import { PostRepository } from '@/modules/content/repositories'; import { PostService } from '@/modules/content/services/post.service'; +import { SelectTrashMode } from '@/modules/database/constants'; +import { PermissionAction } from '@/modules/rbac/constants'; +import { Permission } from '@/modules/rbac/decorators/permission.decorator'; +import { PermissionChecker } from '@/modules/rbac/types'; +import { checkOwnerPermission } from '@/modules/rbac/utils'; import { Depends } from '@/modules/restful/decorators/depend.decorator'; +import { Guest } from '@/modules/user/decorators/guest.decorator'; + +import { RequestUser } from '@/modules/user/decorators/user.request.decorator'; +import { UserEntity } from '@/modules/user/entities'; + import { ContentModule } from '../content.module'; import { DeleteWithTrashDto, RestoreDto } from '../dtos/delete.with.trash.dto'; +const permissions: Record<'create' | 'owner', PermissionChecker> = { + create: async (ab) => ab.can(PermissionAction.CREATE, PostEntity.name), + owner: async (ab, ref, request) => + checkOwnerPermission(ab, { + request, + getData: async (items) => + ref + .get(PostRepository, { strict: false }) + .find({ relations: ['author'], where: { id: In(items) } }), + }), +}; + +@ApiTags('文章操作') @Depends(ContentModule) @Controller('posts') export class PostController { constructor(private postService: PostService) {} + /** + * 查询文章列表 + * @param options + */ @Get() + @Guest() @SerializeOptions({ groups: ['post-list'] }) async list( @Query() - options: QueryPostDto, + options: FrontendQueryPostDto, ) { - return this.postService.paginate(options); + return this.postService.paginate({ + ...options, + isPublished: true, + trashed: SelectTrashMode.NONE, + }); } + /** + * 分页查询自己发布的文章列表 + * @param options + * @param author + */ + @Get('owner') + @ApiBearerAuth() + @SerializeOptions({ groups: ['post-list'] }) + async listOwner( + @Query() + options: OwnerQueryPostDto, + @RequestUser() author: ClassToPlain, + ) { + return this.postService.paginate({ + ...options, + author: author.id, + }); + } + + /** + * 查询文章详情 + * @param id + */ @Get(':id') + @Guest() @SerializeOptions({ groups: ['post-detail'] }) async show(@Param('id', new ParseUUIDPipe()) id: string) { - return this.postService.detail(id); + return this.postService.detail(id, async (qb) => + qb.andWhere({ publishedAt: Not(IsNull()), deletedAt: Not(IsNull()) }), + ); } - @Post() + /** + * 查询自己发布的文章详情 + * @param id + */ + @Get('owner/:id') + @ApiBearerAuth() @SerializeOptions({ groups: ['post-detail'] }) + @Permission(permissions.owner) + async detailOwner( + @Param('id', new ParseUUIDPipe()) + id: string, + ) { + return this.postService.detail(id, async (qb) => qb.withDeleted()); + } + + /** + * 新增文章 + * @param data + * @param author + */ + @Post() + @ApiBearerAuth() + @SerializeOptions({ groups: ['post-detail'] }) + @Permission(permissions.create) async store( @Body() - data: CreatePostDto, + data: FrontendCreatePostDto, + @RequestUser() author: ClassToPlain, ) { - return this.postService.create(data); + return this.postService.create(data, author); } + /** + * 更新自己发布的文章 + * @param data + */ @Patch() + @ApiBearerAuth() @SerializeOptions({ groups: ['post-detail'] }) + @Permission(permissions.owner) async update( @Body() - data: UpdatePostDto, + data: OwnerUpdatePostDto, ) { return this.postService.update(data); } + /** + * 批量删除自己发布的文章 + * @param data + */ @Delete() - @SerializeOptions({ groups: ['post-detail'] }) - async delete(@Body() data: DeleteWithTrashDto) { - return this.postService.delete(data.ids, data.trash); + @ApiBearerAuth() + @SerializeOptions({ groups: ['post-list'] }) + @Permission(permissions.owner) + async delete( + @Body() + data: DeleteWithTrashDto, + ) { + const { ids, trash } = data; + return this.postService.delete(ids, trash); } + /** + * 批量恢复自己发布的文章 + * @param data + */ @Patch('restore') - @SerializeOptions({ groups: ['post-detail'] }) + @ApiBearerAuth() + @SerializeOptions({ groups: ['post-list'] }) + @Permission(permissions.owner) async restore( @Body() data: RestoreDto, ) { - return this.postService.restore(data.ids); + 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 4fb25af..6d28bdf 100644 --- a/src/modules/content/controllers/tag.controller.ts +++ b/src/modules/content/controllers/tag.controller.ts @@ -1,32 +1,28 @@ -import { - Body, - Controller, - Delete, - Get, - Param, - ParseUUIDPipe, - Patch, - Post, - Query, - SerializeOptions, -} from '@nestjs/common'; +import { Controller, Get, Param, ParseUUIDPipe, Query, SerializeOptions } from '@nestjs/common'; -import { DeleteDto } from '@/modules/content/dtos/delete.dto'; +import { ApiTags } from '@nestjs/swagger'; import { Depends } from '@/modules/restful/decorators/depend.decorator'; import { PaginateDto } from '@/modules/restful/dtos/paginate.dto'; +import { Guest } from '@/modules/user/decorators/guest.decorator'; + import { ContentModule } from '../content.module'; -import { CreateTagDto, UpdateTagDto } from '../dtos/tag.dto'; import { TagService } from '../services'; +@ApiTags('标签查询') @Depends(ContentModule) @Controller('tag') export class TagController { constructor(protected service: TagService) {} + /** + * 分页查询标签列表 + * @param options + */ @Get() + @Guest() @SerializeOptions({}) async list( @Query() @@ -35,33 +31,14 @@ export class TagController { return this.service.paginate(options); } + /** + * 查询标签详情 + * @param id + */ @Get(':id') + @Guest() @SerializeOptions({}) async detail(@Param('id', new ParseUUIDPipe()) id: string) { return this.service.detail(id); } - - @Post() - @SerializeOptions({}) - async store( - @Body() - data: CreateTagDto, - ) { - return this.service.create(data); - } - - @Patch() - @SerializeOptions({}) - async update( - @Body() - date: UpdateTagDto, - ) { - return this.service.update(date); - } - - @Delete() - @SerializeOptions({}) - async delete(@Body() data: DeleteDto) { - return this.service.delete(data.ids); - } } diff --git a/src/modules/content/dtos/post.dto.ts b/src/modules/content/dtos/post.dto.ts index a521493..7f30574 100644 --- a/src/modules/content/dtos/post.dto.ts +++ b/src/modules/content/dtos/post.dto.ts @@ -1,4 +1,4 @@ -import { PartialType } from '@nestjs/swagger'; +import { OmitType, PartialType } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { @@ -7,6 +7,7 @@ import { IsEnum, IsInt, IsNotEmpty, + IsNumber, IsOptional, IsUUID, MaxLength, @@ -23,6 +24,8 @@ import { SelectTrashMode } from '@/modules/database/constants'; import { IsDataExist } from '@/modules/database/constraints/data.exist.constraint'; import { PaginateOptions } from '@/modules/database/types'; +import { UserEntity } from '@/modules/user/entities'; + import { CategoryEntity, PostEntity, TagEntity } from '../entities'; /** @@ -90,10 +93,23 @@ export class QueryPostDto implements PaginateOptions { @IsUUID(undefined, { message: 'The ID format is incorrect' }) @IsOptional() tag?: string; + + /** + * 根据文章作者ID查询 + */ + @IsDataExist(UserEntity, { + message: '指定的用户不存在', + }) + @IsUUID(undefined, { message: '用户ID格式错误' }) + @IsOptional() + author?: string; } @DtoValidation({ groups: ['create'] }) export class CreatePostDto { + /** + * 文章标题 + */ @MaxLength(255, { always: true, message: 'The maximum length of the article title is $constraint1', @@ -102,10 +118,16 @@ export class CreatePostDto { @IsOptional({ groups: ['update'] }) title: string; + /** + * 文章内容 + */ @IsNotEmpty({ groups: ['create'], message: 'The content of the article must be filled in.' }) @IsOptional({ groups: ['update'] }) body: string; + /** + * 文章描述 + */ @MaxLength(500, { always: true, message: 'The maximum length of the article description is $constraint1', @@ -113,12 +135,18 @@ export class CreatePostDto { @IsOptional({ always: true }) summary?: string; + /** + * 是否发布(发布时间) + */ @Transform(({ value }) => toBoolean(value)) @IsBoolean({ always: true }) @ValidateIf((value) => !isNil(value.publish)) @IsOptional({ always: true }) publish?: boolean; + /** + * SEO关键字 + */ @MaxLength(20, { always: true, each: true, @@ -127,12 +155,18 @@ export class CreatePostDto { @IsOptional({ always: true }) keywords?: string[]; + /** + * 自定义排序 + */ @Transform(({ value }) => toNumber(value)) @Min(0, { message: 'The sorted value must be greater than 0.', always: true }) @IsInt({ always: true }) @IsOptional({ always: true }) customOrder?: number; + /** + * 所属分类ID + */ @IsDataExist(CategoryEntity, { always: true, message: 'The category does not exist' }) @IsUUID(undefined, { always: true, @@ -141,6 +175,9 @@ export class CreatePostDto { @IsOptional({ always: true }) category?: string; + /** + * 关联标签ID + */ @IsDataExist(TagEntity, { always: true, each: true, @@ -153,10 +190,30 @@ export class CreatePostDto { }) @IsOptional({ always: true }) tags?: string[]; + + /** + * 文章作者ID:可用于在管理员发布文章时分配给其它用户,如果不设置,则作者为当前管理员 + */ + @IsDataExist(UserEntity, { + always: true, + message: '用户不存在', + }) + @IsUUID(undefined, { + always: true, + message: '用户ID格式不正确', + }) + @IsOptional({ always: true }) + author?: string; } +/** + * 文章更新验证 + */ @DtoValidation({ groups: ['update'] }) export class UpdatePostDto extends PartialType(CreatePostDto) { + /** + * 待更新ID + */ @IsUUID(undefined, { groups: ['update'], message: 'The format of the article ID is incorrect.', @@ -165,3 +222,45 @@ export class UpdatePostDto extends PartialType(CreatePostDto) { @IsDataExist(PostEntity, { groups: ['update'], message: 'post id not exist when update' }) id: string; } + +/** + * 客户端查询文章列表验证 + */ +@DtoValidation({ type: 'query' }) +export class FrontendQueryPostDto extends OmitType(QueryPostDto, ['isPublished', 'trashed']) {} + +/** + * 客户端创建文章验证 + */ +@DtoValidation({ groups: ['create'] }) +export class FrontendCreatePostDto extends OmitType(CreatePostDto, ['author', 'customOrder']) { + /** + * 用户侧排序:文章在用户的文章管理而非后台中,列表的排序规则 + */ + @Transform(({ value }) => toNumber(value)) + @Min(0, { always: true, message: '排序值必须大于0' }) + @IsNumber(undefined, { always: true }) + @IsOptional({ always: true }) + userOrder?: number = 0; +} + +/** + * 用户文章更新验证 + */ +@DtoValidation({ groups: ['update'] }) +export class OwnerUpdatePostDto extends OmitType(UpdatePostDto, ['author', 'customOrder']) { + /** + * 用户侧排序:文章在用户的文章管理而非后台中,列表的排序规则 + */ + @Transform(({ value }) => toNumber(value)) + @Min(0, { always: true, message: '排序值必须大于0' }) + @IsNumber(undefined, { always: true }) + @IsOptional({ always: true }) + userOrder?: number = 0; +} + +/** + * 用户查询自己的文章列表验证 + */ +@DtoValidation({ type: 'query' }) +export class OwnerQueryPostDto extends OmitType(QueryPostDto, ['author']) {} diff --git a/src/modules/content/routes.ts b/src/modules/content/routes.ts new file mode 100644 index 0000000..69baf92 --- /dev/null +++ b/src/modules/content/routes.ts @@ -0,0 +1,44 @@ +import { RouteOption, TagOption } from '@/modules/restful/types'; + +import * as controllers from './controllers'; +import * as manageControllers from './controllers/manager'; + +export const createContentApi = () => { + const routes: Record<'app' | 'manager', RouteOption[]> = { + app: [ + { + name: 'app.content', + path: 'content', + controllers: Object.values(controllers), + }, + ], + manager: [ + { + name: 'manage.content', + path: 'content', + controllers: Object.values(manageControllers), + }, + ], + }; + const tags: Record<'app' | 'manager', Array> = { + app: [ + { name: '分类查询', description: '查询分类信息' }, + { name: '标签查询', description: '查询标签信息' }, + { + name: '文章操作', + description: '查询文章以及对自己的文章进行CRUD操作', + }, + { + name: '评论操作', + description: '查看评论以及对自己的评论进行CRD操作', + }, + ], + manager: [ + { name: '分类管理', description: '管理分类信息' }, + { name: '标签管理', description: '管理标签信息' }, + { name: '文章管理', description: '管理文章信息' }, + { name: '评论管理', description: '管理评论信息' }, + ], + }; + return { routes, tags }; +}; diff --git a/src/modules/content/services/comment.service.ts b/src/modules/content/services/comment.service.ts index e69527b..c275816 100644 --- a/src/modules/content/services/comment.service.ts +++ b/src/modules/content/services/comment.service.ts @@ -13,11 +13,14 @@ import { CommentEntity } from '@/modules/content/entities/comment.entity'; import { CommentRepository, PostRepository } from '@/modules/content/repositories'; import { BaseService } from '@/modules/database/base/service'; import { treePaginate } from '@/modules/database/utils'; +import { UserEntity } from '@/modules/user/entities'; +import { UserRepository } from '@/modules/user/repositories'; @Injectable() export class CommentService extends BaseService { constructor( protected repository: CommentRepository, + protected userRepository: UserRepository, protected postRepository: PostRepository, ) { super(repository); @@ -50,7 +53,7 @@ export class CommentService extends BaseService) { const parent = await this.getParent(undefined, data.parent); if (!isNil(parent) && parent.post.id !== data.post) { throw new ForbiddenException('Parent comment and child comment must belong same post!'); @@ -59,6 +62,7 @@ export class CommentService extends BaseService) { let publishedAt: Date | null; if (!isNil(data.publish)) { publishedAt = data.publish ? new Date() : null; } + const authorId = isNil((data as CreatePostDto).author) + ? author.id + : (data as CreatePostDto).author; const createPostDto = { ...omit(data, ['publish']), category: isNil(data.category) @@ -73,6 +91,7 @@ export class PostService extends BaseService { - const routes: Record<'app' | 'manage', RouteOption[]> = { + const routes: Record<'app' | 'manager', RouteOption[]> = { app: [ { name: 'app.rbac', @@ -12,7 +12,7 @@ export const createRbacApi = () => { controllers: Object.values(controllers), }, ], - manage: [ + manager: [ { name: 'manage.rbac', path: 'rbac', @@ -20,9 +20,9 @@ export const createRbacApi = () => { }, ], }; - const tags: Record<'app' | 'manage', Array> = { + const tags: Record<'app' | 'manager', Array> = { app: [{ name: '角色查询', description: '查询角色信息' }], - manage: [ + manager: [ { name: '角色管理', description: '管理角色信息' }, { name: '权限信息', description: '查询权限信息' }, ], diff --git a/src/modules/rbac/utils.ts b/src/modules/rbac/utils.ts index ba3ddb5..f3ecb68 100644 --- a/src/modules/rbac/utils.ts +++ b/src/modules/rbac/utils.ts @@ -16,7 +16,7 @@ export async function checkOwnerPermission( getData: (items: string[]) => Promise; permission?: string; }, -) { +): Promise { const { request, key, getData, permission } = options; const models = await getData(getRequestData(request, key)); return models.every((model) => ability.can(permission ?? PermissionAction.OWNER, model));