add rbac module

This commit is contained in:
liuyi 2025-06-30 17:43:12 +08:00
parent db5b553a93
commit 2c70ce4cdf
17 changed files with 797 additions and 114 deletions

View File

@ -1,11 +1,14 @@
import { Configure } from '@/modules/config/configure'; import { Configure } from '@/modules/config/configure';
import { ConfigureFactory } from '@/modules/config/types'; 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 { ApiConfig, VersionOption } from '@/modules/restful/types';
import { createUserApi } from '@/modules/user/routes'; import { createUserApi } from '@/modules/user/routes';
export const v1 = async (configure: Configure): Promise<VersionOption> => { export const v1 = async (configure: Configure): Promise<VersionOption> => {
const contentApi = createContentApi();
const userApi = createUserApi(); const userApi = createUserApi();
const rbacApi = createRbacApi();
return { return {
routes: [ routes: [
{ {
@ -14,21 +17,26 @@ export const v1 = async (configure: Configure): Promise<VersionOption> => {
controllers: [], controllers: [],
doc: { doc: {
description: 'app name desc', 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: [ tags: [
{ name: '分类操作', description: '对分类进行CRUD操作' }, ...contentApi.tags.manager,
{ name: '标签操作', description: '对标签进行CRUD操作' }, ...rbacApi.tags.manager,
{ name: '文章操作', description: '对文章进行CRUD操作' }, ...userApi.tags.manager,
{ name: '评论操作', description: '对评论进行CRUD操作' },
...userApi.tags.app,
], ],
}, },
children: [ children: [
{ ...contentApi.routes.manager,
name: 'app.content', ...rbacApi.routes.manager,
path: 'content', ...userApi.routes.manager,
controllers: Object.values(contentControllers),
},
...userApi.routes.app,
], ],
}, },
], ],

View File

@ -13,6 +13,8 @@ import { DatabaseModule } from '@/modules/database/database.module';
import { addEntities, addSubscribers } from '@/modules/database/utils'; import { addEntities, addSubscribers } from '@/modules/database/utils';
import { UserRepository } from '@/modules/user/repositories';
import { Configure } from '../config/configure'; import { Configure } from '../config/configure';
import { defauleContentConfig } from './config'; import { defauleContentConfig } from './config';
@ -34,6 +36,7 @@ export class ContentModule {
repositories.CategoryRepository, repositories.CategoryRepository,
repositories.TagRepository, repositories.TagRepository,
services.CategoryService, services.CategoryService,
UserRepository,
{ token: services.SearchService, optional: true }, { token: services.SearchService, optional: true },
], ],
useFactory( useFactory(
@ -42,12 +45,14 @@ export class ContentModule {
tagRepository: repositories.TagRepository, tagRepository: repositories.TagRepository,
categoryService: services.CategoryService, categoryService: services.CategoryService,
searchService: SearchService, searchService: SearchService,
userRepository: UserRepository,
) { ) {
return new PostService( return new PostService(
postRepository, postRepository,
categoryRepository, categoryRepository,
categoryService, categoryService,
tagRepository, tagRepository,
userRepository,
searchService, searchService,
config.searchType, config.searchType,
); );

View File

@ -1,15 +1,4 @@
import { import { Controller, Get, Param, ParseUUIDPipe, Query, SerializeOptions } from '@nestjs/common';
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
SerializeOptions,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; 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 { PaginateDto } from '@/modules/restful/dtos/paginate.dto';
import { Guest } from '@/modules/user/decorators/guest.decorator';
import { ContentModule } from '../content.module'; import { ContentModule } from '../content.module';
import { CreateCategoryDto, UpdateCategoryDto } from '../dtos/category.dto';
import { CategoryService } from '../services'; import { CategoryService } from '../services';
@ApiTags('Category Operate') @ApiTags('分类查询')
@Depends(ContentModule) @Depends(ContentModule)
@Controller('category') @Controller('category')
export class CategoryController { export class CategoryController {
@ -31,6 +21,7 @@ export class CategoryController {
* Search category tree * Search category tree
*/ */
@Get('tree') @Get('tree')
@Guest()
@SerializeOptions({ groups: ['category-tree'] }) @SerializeOptions({ groups: ['category-tree'] })
async tree() { async tree() {
return this.service.findTrees(); return this.service.findTrees();
@ -41,6 +32,7 @@ export class CategoryController {
* @param options * @param options
*/ */
@Get() @Get()
@Guest()
@SerializeOptions({ groups: ['category-list'] }) @SerializeOptions({ groups: ['category-list'] })
async list( async list(
@Query() @Query()
@ -54,32 +46,9 @@ export class CategoryController {
* @param id * @param id
*/ */
@Get(':id') @Get(':id')
@Guest()
@SerializeOptions({ groups: ['category-detail'] }) @SerializeOptions({ groups: ['category-detail'] })
async detail(@Param('id', new ParseUUIDPipe()) id: string) { async detail(@Param('id', new ParseUUIDPipe()) id: string) {
return this.service.detail(id); 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]);
}
} }

View File

@ -1,7 +1,22 @@
import { Body, Controller, Delete, Get, Post, Query, SerializeOptions } from '@nestjs/common'; 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 { 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 { ContentModule } from '../content.module';
import { import {
CreateCommentDto, CreateCommentDto,
@ -11,18 +26,41 @@ import {
} from '../dtos/comment.dto'; } from '../dtos/comment.dto';
import { CommentService } from '../services'; 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) @Depends(ContentModule)
@Controller('comment') @Controller('comment')
export class CommentController { export class CommentController {
constructor(protected service: CommentService) {} constructor(protected service: CommentService) {}
/**
*
* @param options
*/
@Get('tree') @Get('tree')
@Guest()
@SerializeOptions({ groups: ['comment-tree'] }) @SerializeOptions({ groups: ['comment-tree'] })
async tree(@Query() options: QueryCommentTreeDto) { async tree(@Query() options: QueryCommentTreeDto) {
return this.service.findTrees(options); return this.service.findTrees(options);
} }
/**
*
* @param options
*/
@Get() @Get()
@Guest()
@SerializeOptions({ groups: ['comment-list'] }) @SerializeOptions({ groups: ['comment-list'] })
async list( async list(
@Query() @Query()
@ -31,13 +69,26 @@ export class CommentController {
return this.service.paginate(options); return this.service.paginate(options);
} }
/**
*
* @param data
* @param author
*/
@Post() @Post()
@ApiBearerAuth()
@Permission(permissions.create)
@SerializeOptions({ groups: ['comment-detail'] }) @SerializeOptions({ groups: ['comment-detail'] })
async store(@Body() data: CreateCommentDto) { async store(@Body() data: CreateCommentDto, @RequestUser() author: ClassToPlain<UserEntity>) {
return this.service.create(data); return this.service.create(data, author);
} }
/**
*
* @param data
*/
@Delete() @Delete()
@ApiBearerAuth()
@Permission(permissions.owner)
@SerializeOptions({ groups: ['comment-detail'] }) @SerializeOptions({ groups: ['comment-detail'] })
async delete(@Body() data: DeleteCommentDto) { async delete(@Body() data: DeleteCommentDto) {
return this.service.delete(data.ids); return this.service.delete(data.ids);

View File

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

View File

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

View File

@ -0,0 +1,4 @@
export * from './category.controller';
export * from './tag.controller';
export * from './post.controller';
export * from './comment.controller';

View File

@ -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<UserEntity>,
) {
return this.service.create(data, {
id: isNil(author) ? user.id : author,
} as ClassToPlain<UserEntity>);
}
/**
*
* @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);
}
}

View File

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

View File

@ -11,64 +11,179 @@ import {
SerializeOptions, SerializeOptions,
} from '@nestjs/common'; } 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 { 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 { 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 { ContentModule } from '../content.module';
import { DeleteWithTrashDto, RestoreDto } from '../dtos/delete.with.trash.dto'; 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) @Depends(ContentModule)
@Controller('posts') @Controller('posts')
export class PostController { export class PostController {
constructor(private postService: PostService) {} constructor(private postService: PostService) {}
/**
*
* @param options
*/
@Get() @Get()
@Guest()
@SerializeOptions({ groups: ['post-list'] }) @SerializeOptions({ groups: ['post-list'] })
async list( async list(
@Query() @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<UserEntity>,
) {
return this.postService.paginate({
...options,
author: author.id,
});
}
/**
*
* @param id
*/
@Get(':id') @Get(':id')
@Guest()
@SerializeOptions({ groups: ['post-detail'] }) @SerializeOptions({ groups: ['post-detail'] })
async show(@Param('id', new ParseUUIDPipe()) id: string) { 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'] }) @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( async store(
@Body() @Body()
data: CreatePostDto, data: FrontendCreatePostDto,
@RequestUser() author: ClassToPlain<UserEntity>,
) { ) {
return this.postService.create(data); return this.postService.create(data, author);
} }
/**
*
* @param data
*/
@Patch() @Patch()
@ApiBearerAuth()
@SerializeOptions({ groups: ['post-detail'] }) @SerializeOptions({ groups: ['post-detail'] })
@Permission(permissions.owner)
async update( async update(
@Body() @Body()
data: UpdatePostDto, data: OwnerUpdatePostDto,
) { ) {
return this.postService.update(data); return this.postService.update(data);
} }
/**
*
* @param data
*/
@Delete() @Delete()
@SerializeOptions({ groups: ['post-detail'] }) @ApiBearerAuth()
async delete(@Body() data: DeleteWithTrashDto) { @SerializeOptions({ groups: ['post-list'] })
return this.postService.delete(data.ids, data.trash); @Permission(permissions.owner)
async delete(
@Body()
data: DeleteWithTrashDto,
) {
const { ids, trash } = data;
return this.postService.delete(ids, trash);
} }
/**
*
* @param data
*/
@Patch('restore') @Patch('restore')
@SerializeOptions({ groups: ['post-detail'] }) @ApiBearerAuth()
@SerializeOptions({ groups: ['post-list'] })
@Permission(permissions.owner)
async restore( async restore(
@Body() @Body()
data: RestoreDto, data: RestoreDto,
) { ) {
return this.postService.restore(data.ids); const { ids } = data;
return this.postService.restore(ids);
} }
} }

View File

@ -1,32 +1,28 @@
import { import { Controller, Get, Param, ParseUUIDPipe, Query, SerializeOptions } from '@nestjs/common';
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
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 { Depends } from '@/modules/restful/decorators/depend.decorator';
import { PaginateDto } from '@/modules/restful/dtos/paginate.dto'; import { PaginateDto } from '@/modules/restful/dtos/paginate.dto';
import { Guest } from '@/modules/user/decorators/guest.decorator';
import { ContentModule } from '../content.module'; import { ContentModule } from '../content.module';
import { CreateTagDto, UpdateTagDto } from '../dtos/tag.dto';
import { TagService } from '../services'; import { TagService } from '../services';
@ApiTags('标签查询')
@Depends(ContentModule) @Depends(ContentModule)
@Controller('tag') @Controller('tag')
export class TagController { export class TagController {
constructor(protected service: TagService) {} constructor(protected service: TagService) {}
/**
*
* @param options
*/
@Get() @Get()
@Guest()
@SerializeOptions({}) @SerializeOptions({})
async list( async list(
@Query() @Query()
@ -35,33 +31,14 @@ export class TagController {
return this.service.paginate(options); return this.service.paginate(options);
} }
/**
*
* @param id
*/
@Get(':id') @Get(':id')
@Guest()
@SerializeOptions({}) @SerializeOptions({})
async detail(@Param('id', new ParseUUIDPipe()) id: string) { async detail(@Param('id', new ParseUUIDPipe()) id: string) {
return this.service.detail(id); 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);
}
} }

View File

@ -1,4 +1,4 @@
import { PartialType } from '@nestjs/swagger'; import { OmitType, PartialType } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
@ -7,6 +7,7 @@ import {
IsEnum, IsEnum,
IsInt, IsInt,
IsNotEmpty, IsNotEmpty,
IsNumber,
IsOptional, IsOptional,
IsUUID, IsUUID,
MaxLength, MaxLength,
@ -23,6 +24,8 @@ import { SelectTrashMode } from '@/modules/database/constants';
import { IsDataExist } from '@/modules/database/constraints/data.exist.constraint'; import { IsDataExist } from '@/modules/database/constraints/data.exist.constraint';
import { PaginateOptions } from '@/modules/database/types'; import { PaginateOptions } from '@/modules/database/types';
import { UserEntity } from '@/modules/user/entities';
import { CategoryEntity, PostEntity, TagEntity } from '../entities'; import { CategoryEntity, PostEntity, TagEntity } from '../entities';
/** /**
@ -90,10 +93,23 @@ export class QueryPostDto implements PaginateOptions {
@IsUUID(undefined, { message: 'The ID format is incorrect' }) @IsUUID(undefined, { message: 'The ID format is incorrect' })
@IsOptional() @IsOptional()
tag?: string; tag?: string;
/**
* ID查询
*/
@IsDataExist(UserEntity, {
message: '指定的用户不存在',
})
@IsUUID(undefined, { message: '用户ID格式错误' })
@IsOptional()
author?: string;
} }
@DtoValidation({ groups: ['create'] }) @DtoValidation({ groups: ['create'] })
export class CreatePostDto { export class CreatePostDto {
/**
*
*/
@MaxLength(255, { @MaxLength(255, {
always: true, always: true,
message: 'The maximum length of the article title is $constraint1', message: 'The maximum length of the article title is $constraint1',
@ -102,10 +118,16 @@ export class CreatePostDto {
@IsOptional({ groups: ['update'] }) @IsOptional({ groups: ['update'] })
title: string; title: string;
/**
*
*/
@IsNotEmpty({ groups: ['create'], message: 'The content of the article must be filled in.' }) @IsNotEmpty({ groups: ['create'], message: 'The content of the article must be filled in.' })
@IsOptional({ groups: ['update'] }) @IsOptional({ groups: ['update'] })
body: string; body: string;
/**
*
*/
@MaxLength(500, { @MaxLength(500, {
always: true, always: true,
message: 'The maximum length of the article description is $constraint1', message: 'The maximum length of the article description is $constraint1',
@ -113,12 +135,18 @@ export class CreatePostDto {
@IsOptional({ always: true }) @IsOptional({ always: true })
summary?: string; summary?: string;
/**
* ()
*/
@Transform(({ value }) => toBoolean(value)) @Transform(({ value }) => toBoolean(value))
@IsBoolean({ always: true }) @IsBoolean({ always: true })
@ValidateIf((value) => !isNil(value.publish)) @ValidateIf((value) => !isNil(value.publish))
@IsOptional({ always: true }) @IsOptional({ always: true })
publish?: boolean; publish?: boolean;
/**
* SEO关键字
*/
@MaxLength(20, { @MaxLength(20, {
always: true, always: true,
each: true, each: true,
@ -127,12 +155,18 @@ export class CreatePostDto {
@IsOptional({ always: true }) @IsOptional({ always: true })
keywords?: string[]; keywords?: string[];
/**
*
*/
@Transform(({ value }) => toNumber(value)) @Transform(({ value }) => toNumber(value))
@Min(0, { message: 'The sorted value must be greater than 0.', always: true }) @Min(0, { message: 'The sorted value must be greater than 0.', always: true })
@IsInt({ always: true }) @IsInt({ always: true })
@IsOptional({ always: true }) @IsOptional({ always: true })
customOrder?: number; customOrder?: number;
/**
* ID
*/
@IsDataExist(CategoryEntity, { always: true, message: 'The category does not exist' }) @IsDataExist(CategoryEntity, { always: true, message: 'The category does not exist' })
@IsUUID(undefined, { @IsUUID(undefined, {
always: true, always: true,
@ -141,6 +175,9 @@ export class CreatePostDto {
@IsOptional({ always: true }) @IsOptional({ always: true })
category?: string; category?: string;
/**
* ID
*/
@IsDataExist(TagEntity, { @IsDataExist(TagEntity, {
always: true, always: true,
each: true, each: true,
@ -153,10 +190,30 @@ export class CreatePostDto {
}) })
@IsOptional({ always: true }) @IsOptional({ always: true })
tags?: string[]; tags?: string[];
/**
* 文章作者ID:可用于在管理员发布文章时分配给其它用户,,
*/
@IsDataExist(UserEntity, {
always: true,
message: '用户不存在',
})
@IsUUID(undefined, {
always: true,
message: '用户ID格式不正确',
})
@IsOptional({ always: true })
author?: string;
} }
/**
*
*/
@DtoValidation({ groups: ['update'] }) @DtoValidation({ groups: ['update'] })
export class UpdatePostDto extends PartialType(CreatePostDto) { export class UpdatePostDto extends PartialType(CreatePostDto) {
/**
* ID
*/
@IsUUID(undefined, { @IsUUID(undefined, {
groups: ['update'], groups: ['update'],
message: 'The format of the article ID is incorrect.', 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' }) @IsDataExist(PostEntity, { groups: ['update'], message: 'post id not exist when update' })
id: string; 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']) {}

View File

@ -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<string | TagOption>> = {
app: [
{ name: '分类查询', description: '查询分类信息' },
{ name: '标签查询', description: '查询标签信息' },
{
name: '文章操作',
description: '查询文章以及对自己的文章进行CRUD操作',
},
{
name: '评论操作',
description: '查看评论以及对自己的评论进行CRD操作',
},
],
manager: [
{ name: '分类管理', description: '管理分类信息' },
{ name: '标签管理', description: '管理标签信息' },
{ name: '文章管理', description: '管理文章信息' },
{ name: '评论管理', description: '管理评论信息' },
],
};
return { routes, tags };
};

View File

@ -13,11 +13,14 @@ import { CommentEntity } from '@/modules/content/entities/comment.entity';
import { CommentRepository, PostRepository } from '@/modules/content/repositories'; import { CommentRepository, PostRepository } from '@/modules/content/repositories';
import { BaseService } from '@/modules/database/base/service'; import { BaseService } from '@/modules/database/base/service';
import { treePaginate } from '@/modules/database/utils'; import { treePaginate } from '@/modules/database/utils';
import { UserEntity } from '@/modules/user/entities';
import { UserRepository } from '@/modules/user/repositories';
@Injectable() @Injectable()
export class CommentService extends BaseService<CommentEntity, CommentRepository> { export class CommentService extends BaseService<CommentEntity, CommentRepository> {
constructor( constructor(
protected repository: CommentRepository, protected repository: CommentRepository,
protected userRepository: UserRepository,
protected postRepository: PostRepository, protected postRepository: PostRepository,
) { ) {
super(repository); super(repository);
@ -50,7 +53,7 @@ export class CommentService extends BaseService<CommentEntity, CommentRepository
return treePaginate(query, comments); return treePaginate(query, comments);
} }
async create(data: CreateCommentDto) { async create(data: CreateCommentDto, author: ClassToPlain<UserEntity>) {
const parent = await this.getParent(undefined, data.parent); const parent = await this.getParent(undefined, data.parent);
if (!isNil(parent) && parent.post.id !== data.post) { if (!isNil(parent) && parent.post.id !== data.post) {
throw new ForbiddenException('Parent comment and child comment must belong same post!'); throw new ForbiddenException('Parent comment and child comment must belong same post!');
@ -59,6 +62,7 @@ export class CommentService extends BaseService<CommentEntity, CommentRepository
...data, ...data,
parent, parent,
post: await this.getPost(data.post), post: await this.getPost(data.post),
author: await this.userRepository.findOneByOrFail({ id: author.id }),
}); });
return this.repository.findOneOrFail({ return this.repository.findOneOrFail({
where: { id: item.id }, where: { id: item.id },

View File

@ -5,7 +5,12 @@ import { isArray, isFunction, omit, pick } from 'lodash';
import { EntityNotFoundError, In, IsNull, Not, SelectQueryBuilder } from 'typeorm'; import { EntityNotFoundError, In, IsNull, Not, SelectQueryBuilder } from 'typeorm';
import { PostOrder } from '@/modules/content/constants'; import { PostOrder } from '@/modules/content/constants';
import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos/post.dto'; import {
CreatePostDto,
FrontendCreatePostDto,
QueryPostDto,
UpdatePostDto,
} from '@/modules/content/dtos/post.dto';
import { PostEntity } from '@/modules/content/entities/post.entity'; import { PostEntity } from '@/modules/content/entities/post.entity';
import { CategoryRepository } from '@/modules/content/repositories'; import { CategoryRepository } from '@/modules/content/repositories';
import { PostRepository } from '@/modules/content/repositories/post.repository'; import { PostRepository } from '@/modules/content/repositories/post.repository';
@ -16,6 +21,10 @@ import { SelectTrashMode } from '@/modules/database/constants';
import { QueryHook } from '@/modules/database/types'; import { QueryHook } from '@/modules/database/types';
import { paginate } from '@/modules/database/utils'; import { paginate } from '@/modules/database/utils';
import { UserEntity } from '@/modules/user/entities';
import { UserRepository } from '@/modules/user/repositories';
import { TagRepository } from '../repositories/tag.repository'; import { TagRepository } from '../repositories/tag.repository';
import { CategoryService } from './category.service'; import { CategoryService } from './category.service';
@ -33,6 +42,7 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
protected categoryRepository: CategoryRepository, protected categoryRepository: CategoryRepository,
protected categoryService: CategoryService, protected categoryService: CategoryService,
protected tagRepository: TagRepository, protected tagRepository: TagRepository,
protected userRepository: UserRepository,
protected searchService?: SearchService, protected searchService?: SearchService,
protected searchType: SearchType = 'mysql', protected searchType: SearchType = 'mysql',
) { ) {
@ -61,11 +71,19 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
return item; return item;
} }
async create(data: CreatePostDto) { /**
*
* @param data
* @param author
*/
async create(data: CreatePostDto | FrontendCreatePostDto, author: ClassToPlain<UserEntity>) {
let publishedAt: Date | null; let publishedAt: Date | null;
if (!isNil(data.publish)) { if (!isNil(data.publish)) {
publishedAt = data.publish ? new Date() : null; publishedAt = data.publish ? new Date() : null;
} }
const authorId = isNil((data as CreatePostDto).author)
? author.id
: (data as CreatePostDto).author;
const createPostDto = { const createPostDto = {
...omit(data, ['publish']), ...omit(data, ['publish']),
category: isNil(data.category) category: isNil(data.category)
@ -73,6 +91,7 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
: await this.categoryRepository.findOneOrFail({ where: { id: data.category } }), : await this.categoryRepository.findOneOrFail({ where: { id: data.category } }),
tags: isArray(data.tags) ? await this.tagRepository.findBy({ id: In(data.tags) }) : [], tags: isArray(data.tags) ? await this.tagRepository.findBy({ id: In(data.tags) }) : [],
publishedAt, publishedAt,
author: await this.userRepository.findOneByOrFail({ id: authorId }),
}; };
const item = await this.repository.save(createPostDto); const item = await this.repository.save(createPostDto);
const result = await this.detail(item.id); const result = await this.detail(item.id);
@ -82,12 +101,20 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
return result; return result;
} }
/**
*
* @param data
*/
async update(data: UpdatePostDto) { async update(data: UpdatePostDto) {
let publishedAt: Date | null; let publishedAt: Date | null;
if (!isNil(data.publish)) { if (!isNil(data.publish)) {
publishedAt = data.publish ? new Date() : null; publishedAt = data.publish ? new Date() : null;
} }
const post = await this.detail(data.id); const post = await this.detail(data.id);
if (!isNil(data.author)) {
post.author = await this.userRepository.findOneByOrFail({ id: data.author });
await this.repository.save(post);
}
if (data.category !== undefined) { if (data.category !== undefined) {
post.category = isNil(data.category) post.category = isNil(data.category)
? null ? null
@ -102,7 +129,7 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
.addAndRemove(data.tags, post.tags ?? []); .addAndRemove(data.tags, post.tags ?? []);
} }
await this.repository.update(data.id, { await this.repository.update(data.id, {
...omit(data, ['id', 'publish', 'tags', 'category']), ...omit(data, ['id', 'publish', 'tags', 'category', 'author']),
publishedAt, publishedAt,
}); });
const result = await this.detail(data.id); const result = await this.detail(data.id);

View File

@ -4,7 +4,7 @@ import * as controllers from './controllers';
import * as manageControllers from './controllers/manager'; import * as manageControllers from './controllers/manager';
export const createRbacApi = () => { export const createRbacApi = () => {
const routes: Record<'app' | 'manage', RouteOption[]> = { const routes: Record<'app' | 'manager', RouteOption[]> = {
app: [ app: [
{ {
name: 'app.rbac', name: 'app.rbac',
@ -12,7 +12,7 @@ export const createRbacApi = () => {
controllers: Object.values(controllers), controllers: Object.values(controllers),
}, },
], ],
manage: [ manager: [
{ {
name: 'manage.rbac', name: 'manage.rbac',
path: 'rbac', path: 'rbac',
@ -20,9 +20,9 @@ export const createRbacApi = () => {
}, },
], ],
}; };
const tags: Record<'app' | 'manage', Array<string | TagOption>> = { const tags: Record<'app' | 'manager', Array<string | TagOption>> = {
app: [{ name: '角色查询', description: '查询角色信息' }], app: [{ name: '角色查询', description: '查询角色信息' }],
manage: [ manager: [
{ name: '角色管理', description: '管理角色信息' }, { name: '角色管理', description: '管理角色信息' },
{ name: '权限信息', description: '查询权限信息' }, { name: '权限信息', description: '查询权限信息' },
], ],

View File

@ -16,7 +16,7 @@ export async function checkOwnerPermission<T extends ObjectLiteral>(
getData: (items: string[]) => Promise<T[]>; getData: (items: string[]) => Promise<T[]>;
permission?: string; permission?: string;
}, },
) { ): Promise<boolean> {
const { request, key, getData, permission } = options; const { request, key, getData, permission } = options;
const models = await getData(getRequestData(request, key)); const models = await getData(getRequestData(request, key));
return models.every((model) => ability.can(permission ?? PermissionAction.OWNER, model)); return models.every((model) => ability.can(permission ?? PermissionAction.OWNER, model));