Compare commits

...

10 Commits

Author SHA1 Message Date
b37dfa8103 feat:增加meilisearch
- todo:软删除问题
2023-12-17 13:28:39 +08:00
431246bc23 update:配置替换为MySQL数据库,修复了文章排序字段设置错误。 2023-12-15 15:56:54 +08:00
5bcb4853e5 feat:增加批量删除、软删除和软删除恢复
- 完成基础的软删除
- 完成树形数据的软删除(children未处理)
2023-12-15 11:19:42 +08:00
c151657116 feat:自定义数据验证约束
1.学习数据自定义约束
2.给自定义数据约束实现依赖注入
2023-12-12 16:40:19 +08:00
d30273d180 fix:补充分类的存储库方法、修复分类服务没有正确添加children 2023-12-12 16:39:12 +08:00
dbc14c2137 update:更新依赖、代码格式lint 2023-12-12 14:36:01 +08:00
f4b38483d6 feat:自定义全局拦截器、过滤器、验证管道 2023-12-08 13:31:53 +08:00
781962dce0 feat:数据关联与树形数据嵌套结构的分类和评论实现
- 实现分类、评论、标签实体
- 使用了Materialized Path实现树形嵌套
- 对分类、评论、标签的存储库进行自定义改造
2023-12-03 18:06:47 +08:00
cce9a6b179 update:更新依赖 2023-11-26 13:42:43 +08:00
8a5aaa5fd8 feat:增加Vercel配置 2023-11-23 14:51:20 +08:00
71 changed files with 3621 additions and 779 deletions

3
.gitignore vendored
View File

@ -32,4 +32,5 @@ lerna-debug.log*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/extensions.json
.vercel

18
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,18 @@
{
// 使 IntelliSense
//
// 访: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "debug 3rapp",
"request": "launch",
"runtimeArgs": ["run-script", "start:debug"],
"autoAttachChildProcesses": true,
"console": "integratedTerminal",
"runtimeExecutable": "pnpm",
"skipFiles": ["<node_internals>/**"],
"type": "node"
}
]
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -20,52 +20,55 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^10.2.9",
"@nestjs/core": "^10.2.9",
"@nestjs/platform-fastify": "^10.2.9",
"@nestjs/common": "^10.2.10",
"@nestjs/core": "^10.2.10",
"@nestjs/platform-fastify": "^10.2.10",
"@nestjs/swagger": "^7.1.16",
"@nestjs/typeorm": "^10.0.1",
"better-sqlite3": "^9.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"deepmerge": "^4.3.1",
"fastify": "^4.24.3",
"lodash": "^4.17.21",
"reflect-metadata": "^0.1.13",
"meilisearch": "^0.36.0",
"mysql2": "^3.6.5",
"reflect-metadata": "^0.1.14",
"rxjs": "^7.8.1",
"sanitize-html": "^2.11.0",
"typeorm": "^0.3.17"
"typeorm": "^0.3.17",
"validator": "^13.11.0"
},
"devDependencies": {
"@nestjs/cli": "^10.2.1",
"@nestjs/schematics": "^10.0.3",
"@nestjs/testing": "^10.2.9",
"@nestjs/testing": "^10.2.10",
"@swc/cli": "^0.1.63",
"@swc/core": "^1.3.96",
"@types/jest": "^29.5.8",
"@types/lodash": "^4.14.201",
"@types/node": "^20.9.2",
"@types/sanitize-html": "^2.9.4",
"@swc/core": "^1.3.100",
"@types/jest": "^29.5.11",
"@types/lodash": "^4.14.202",
"@types/node": "^20.10.4",
"@types/sanitize-html": "^2.9.5",
"@types/supertest": "^2.0.16",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"eslint": "^8.54.0",
"@types/validator": "^13.11.7",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"eslint": "^8.55.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-airbnb-typescript": "^17.1.0",
"eslint-config-prettier": "^9.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-jest": "^27.6.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-unused-imports": "^3.0.0",
"jest": "^29.7.0",
"prettier": "^3.1.0",
"prettier": "^3.1.1",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.1",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.2.2"
"typescript": "^5.3.3"
},
"jest": {
"moduleFileExtensions": [

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,43 @@
import { Module } from '@nestjs/common';
import { database } from '@/config';
import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { content, database, meilli } from '@/config';
import { ContentModule } from '@/modules/content/content.module';
import { CoreModule } from '@/modules/core/core.module';
import { AppFilter, AppIntercepter, AppPipe } from '@/modules/core/providers';
import { DatabaseModule } from '@/modules/database/database.module';
import { MeilliModule } from '@/modules/meilisearch/melli.module';
import { WelcomeModule } from '@/modules/welcome/welcome.module';
@Module({
imports: [DatabaseModule.forRoot(database), ContentModule, WelcomeModule, CoreModule.forRoot()],
imports: [
DatabaseModule.forRoot(database),
ContentModule.forRoot(content),
WelcomeModule,
CoreModule.forRoot(),
MeilliModule.forRoot(meilli),
],
controllers: [],
providers: [],
providers: [
{
provide: APP_PIPE,
useValue: new AppPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
validationError: { target: false },
}),
},
{
provide: APP_INTERCEPTOR,
useClass: AppIntercepter,
},
{
provide: APP_FILTER,
useClass: AppFilter,
},
],
})
export class AppModule {}

View File

@ -0,0 +1,5 @@
import { ContentConfig } from '@/modules/content/types';
export const content = (): ContentConfig => ({
searchType: 'meilli',
});

View File

@ -1,5 +1,3 @@
import { resolve } from 'path';
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
/**
@ -7,17 +5,17 @@ import { TypeOrmModuleOptions } from '@nestjs/typeorm';
*/
export const database = (): TypeOrmModuleOptions => ({
// 以下为mysql配置
// charset: 'utf8mb4',
// logging: ['error'],
// type: 'mysql',
// host: '127.0.0.1',
// port: 3306,
// username: 'root',
// password: '123456789',
// database: 'ink_apps',
charset: 'utf8mb4',
logging: ['error'],
type: 'mysql',
host: 'localhost',
port: 3306,
username: 'root',
password: '12345678910',
database: 'ink_apps',
// 以下为sqlite配置
type: 'better-sqlite3',
database: resolve(__dirname, '../../back/database4.db'),
// type: 'better-sqlite3',
// database: resolve(__dirname, '../../back/database9.db'),
synchronize: true,
autoLoadEntities: true,
});

View File

@ -1 +1,3 @@
export * from './content.config';
export * from './database.config';
export * from './meilli.config';

View File

@ -0,0 +1,9 @@
import { MelliConfig } from '@/modules/meilisearch/types';
export const meilli = (): MelliConfig => [
{
name: 'default',
host: 'http://localhost:7700',
apiKey: '12345678910',
},
];

View File

@ -1,6 +1,8 @@
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { useContainer } from 'class-validator';
import { AppModule } from '@/app.module';
const bootstrap = async () => {
@ -11,8 +13,13 @@ const bootstrap = async () => {
app.setGlobalPrefix('api');
await app.listen(2333, () => {
console.log('api: http://localhost:2333/api');
// 使validator的约束可以使用nestjs的容器
useContainer(app.select(AppModule), {
fallbackOnErrors: true,
});
await app.listen(3100, () => {
console.log('api: http://localhost:3100/api');
});
};

View File

@ -13,5 +13,6 @@ export enum PostOrderType {
CREATED = 'createdAt',
UPDATED = 'updatedAt',
PUBLISHED = 'publishedAt',
COMMENTCOUNT = 'commentCount',
CUSTOM = 'custom',
}

View File

@ -1,22 +1,73 @@
import { Module } from '@nestjs/common';
import { DynamicModule, Module, ModuleMetadata } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PostController } from '@/modules/content/controllers';
import { PostEntity } from '@/modules/content/entities';
import { PostRepository } from '@/modules/content/repositories';
import { PostService, SanitizeService } from '@/modules/content/services';
import { SanitizeService } from '@/modules/content/services/sanitize.service';
import { PostSubscriber } from '@/modules/content/subscribers';
import { ContentConfig } from '@/modules/content/types';
import { DatabaseModule } from '@/modules/database/database.module';
@Module({
imports: [
TypeOrmModule.forFeature([PostEntity]),
DatabaseModule.forRepository([PostRepository]),
],
controllers: [PostController],
providers: [PostService, PostSubscriber, SanitizeService],
exports: [PostService, DatabaseModule.forRepository([PostRepository])],
})
export class ContentModule {}
import * as controllers from './controllers';
import * as entities from './entities';
import * as repositories from './repositories';
import * as services from './services';
import { PostService } from './services';
@Module({})
export class ContentModule {
static forRoot(configRegister: () => ContentConfig): DynamicModule {
const config: Required<ContentConfig> = {
searchType: 'against',
...(configRegister ? configRegister() : {}),
};
const providers: ModuleMetadata['providers'] = [
...Object.values(services),
PostSubscriber,
SanitizeService,
{
provide: PostService,
inject: [
repositories.PostRepository,
repositories.CategoryRepository,
services.CategoryService,
repositories.TagRepository,
{ token: services.SearchService, optional: true },
],
useFactory(
postRepository: repositories.PostRepository,
categoryRepository: repositories.CategoryRepository,
categoryService: services.CategoryService,
tagRepository: repositories.TagRepository,
searchService: services.SearchService,
) {
return new PostService(
postRepository,
categoryRepository,
categoryService,
tagRepository,
searchService,
config.searchType,
);
},
},
];
if (config.searchType === 'meilli') providers.push(services.SearchService);
return {
module: ContentModule,
imports: [
TypeOrmModule.forFeature(Object.values(entities)),
DatabaseModule.forRepository(Object.values(repositories)),
],
controllers: Object.values(controllers),
providers,
exports: [
...Object.values(services),
DatabaseModule.forRepository(Object.values(repositories)),
PostService,
],
};
}
}

View File

@ -0,0 +1,81 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
SerializeOptions,
} from '@nestjs/common';
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 {
constructor(protected service: CategoryService) {}
@Get('tree')
@SerializeOptions({ groups: ['category-tree'] })
async tree(@Query() options: QueryCategoryTreeDto) {
return this.service.findTrees(options);
}
@Get()
@SerializeOptions({ groups: ['category-list'] })
async list(
@Query()
options: QueryCategoryDto,
) {
return this.service.paginate(options);
}
@Get(':id')
@SerializeOptions({ groups: ['category-detail'] })
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
return this.service.detail(id);
}
@Post()
@SerializeOptions({ groups: ['category-detail'] })
async create(
@Body()
data: CreateCategoryDto,
) {
return this.service.create(data);
}
@Patch()
@SerializeOptions({ groups: ['category-detail'] })
async update(
@Body()
data: UpdateCategoryDto,
) {
return this.service.update(data);
}
@Delete()
@SerializeOptions({ groups: ['category-detail'] })
async delete(@Body() data: DeleteWithTrashDto) {
const { ids, trash } = data;
return this.service.delete(ids, trash);
}
@Patch('restore')
@SerializeOptions({ groups: ['category-detail'] })
async restore(@Body() data: RestoreDto) {
const { ids } = data;
return this.service.restore(ids);
}
}

View File

@ -0,0 +1,44 @@
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 {
constructor(protected service: CommentService) {}
@Get('tree')
@SerializeOptions({ groups: ['comment-tree'] })
async tree(
@Query()
query: QueryCommentTreeDto,
) {
return this.service.findTrees(query);
}
@Get()
@SerializeOptions({ groups: ['comment-list'] })
async list(
@Query()
query: QueryCommentDto,
) {
return this.service.paginate(query);
}
@Post()
@SerializeOptions({ groups: ['comment-detail'] })
async store(
@Body()
data: CreateCommentDto,
) {
return this.service.create(data);
}
@Delete()
@SerializeOptions({ groups: ['comment-detail'] })
async delete(@Body() data: DeleteDto) {
const { ids } = data;
return this.service.delete(ids);
}
}

View File

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

View File

@ -9,20 +9,17 @@ import {
Post,
Query,
SerializeOptions,
UseInterceptors,
ValidationPipe,
} from '@nestjs/common';
import { CreatePostDto, UpdatePostDto } from '@/modules/content/dtos';
import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos';
import { PostService } from '@/modules/content/services';
import { AppIntercepter } from '@/modules/core/providers';
import { PaginateOptions } from '@/modules/database/types';
import { DeleteDto, DeleteWithTrashDto } from '@/modules/restful/dtos';
/**
*
*
*/
@UseInterceptors(AppIntercepter)
@Controller('posts')
export class PostController {
constructor(private postService: PostService) {}
@ -30,16 +27,8 @@ export class PostController {
@Get()
@SerializeOptions({ groups: ['post-list'] })
async list(
@Query(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
validationError: { target: false },
}),
)
options: PaginateOptions,
@Query()
options: QueryPostDto,
) {
return this.postService.paginate(options);
}
@ -53,15 +42,7 @@ export class PostController {
@Post()
@SerializeOptions({ groups: ['post-detail'] })
async store(
@Body(
new ValidationPipe({
transform: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
validationError: { target: false },
groups: ['create'],
}),
)
@Body()
data: CreatePostDto,
) {
return this.postService.create(data);
@ -70,23 +51,25 @@ export class PostController {
@Patch()
@SerializeOptions({ groups: ['post-detail'] })
async update(
@Body(
new ValidationPipe({
transform: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
validationError: { target: false },
groups: ['update'],
}),
)
@Body()
data: UpdatePostDto,
) {
return this.postService.update(data);
}
@Delete(':id')
@Delete()
@SerializeOptions({ groups: ['post-detail'] })
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
return this.postService.delete(id);
async delete(@Body() data: DeleteWithTrashDto) {
const { ids, trash } = data;
return this.postService.delete(ids, trash);
}
@Patch('restore')
@SerializeOptions({ groups: ['post-detail'] })
async restore(@Body() data: DeleteDto) {
const { ids } = data;
return this.postService.restore(ids);
}
}

View File

@ -0,0 +1,73 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
SerializeOptions,
} from '@nestjs/common';
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 {
constructor(protected service: TagService) {}
@Get()
@SerializeOptions({})
async list(
@Query()
options: QueryTagsDto,
) {
return this.service.paginate(options);
}
@Get(':id')
@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()
data: UpdateTagDto,
) {
return this.service.update(data);
}
@Delete()
@SerializeOptions({ groups: ['post-list'] })
async delete(@Body() data: DeleteWithTrashDto) {
const { ids, trash } = data;
return this.service.delete(ids, trash);
}
@Patch('restore')
@SerializeOptions({ groups: ['post-list'] })
async restore(@Body() data: DeleteDto) {
const { ids } = data;
return this.service.restore(ids);
}
}

View File

@ -0,0 +1,91 @@
import { PartialType } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsDefined,
IsEnum,
IsNotEmpty,
IsNumber,
IsOptional,
IsUUID,
MaxLength,
Min,
ValidateIf,
} from 'class-validator';
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 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()
@IsOptional()
page = 1;
@Transform(({ value }) => toNumber(value))
@Min(1, { message: '每页显示数量必须大于1' })
@IsNumber()
@IsOptional()
limit: 10;
}
/**
*
*/
@DtoValidation({ groups: ['create'] })
export class CreateCategoryDto {
@IsTreeUnique(CategoryEntity, {
groups: ['create'],
message: '分类名称重复',
})
@IsTreeUniqueExist(CategoryEntity, {
groups: ['update'],
message: '名称重复',
})
@MaxLength(25, {
always: true,
message: '分类名称长度最大为$constraint1',
})
@IsNotEmpty({ groups: ['create'], message: '分类名称不能为空' })
@IsOptional({ groups: ['update'] })
name: string;
@IsDataExist(CategoryEntity, { always: true, message: '父分类不存在' })
@IsUUID(undefined, { always: true, message: '父分类ID格式不正确' })
@ValidateIf((value) => value.parent !== null && value.parent)
@IsOptional({ always: true })
@Transform(({ value }) => (value === 'null' ? null : value))
parent?: string;
@Transform(({ value }) => toNumber(value))
@Min(0, { always: true, message: '排序值必须大于0' })
@IsNumber(undefined, { always: true })
@IsOptional({ always: true })
customOrder = 0;
}
/**
*
*/
@DtoValidation({ groups: ['update'] })
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {
@IsUUID(undefined, { groups: ['update'], message: '分类ID格式不正确' })
@IsDefined({ groups: ['update'], message: '分类ID必须指定' })
id: string;
}

View File

@ -0,0 +1,69 @@
import { PickType } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsDefined,
IsNotEmpty,
IsNumber,
IsOptional,
IsUUID,
MaxLength,
Min,
ValidateIf,
} from 'class-validator';
import { toNumber } from 'lodash';
import { CommentEntity, PostEntity } from '@/modules/content/entities';
import { IsDataExist } from '@/modules/database/constraints';
import { PaginateOptions } from '@/modules/database/types';
/**
*
*/
export class QueryCommentDto implements PaginateOptions {
@IsDataExist(PostEntity, {
message: '文章不存在',
})
@IsUUID(undefined, { message: 'ID格式错误' })
@IsOptional()
post?: string;
@Transform(({ value }) => toNumber(value))
@Min(1, { message: '当前页数必须大于1' })
@IsNumber()
@IsOptional()
page = 1;
@Transform(({ value }) => toNumber(value))
@Min(1, { message: '每页显示数量必须大于1' })
@IsNumber()
@IsOptional()
limit: 10;
}
/**
*
*/
export class QueryCommentTreeDto extends PickType(QueryCommentDto, ['post']) {}
/**
*
*/
export class CreateCommentDto {
@MaxLength(1000, { message: '评论内容长度最大为$constraint1' })
@IsNotEmpty({ message: '评论内容不能为空' })
body: string;
@IsUUID(undefined, { message: 'ID格式错误' })
@IsDefined({ message: 'ID必须指定' })
post: string;
@IsDataExist(CommentEntity, {
message: '父评论不存在',
})
@IsUUID(undefined, { message: 'ID格式错误' })
@ValidateIf((value) => value.parent !== null && value.parent)
@IsOptional({ always: true })
@Transform(({ value }) => (value === 'null' ? null : value))
parent?: string;
}

View File

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

View File

@ -18,12 +18,17 @@ import {
import { isNil, toNumber } from 'lodash';
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';
/**
*
*/
@DtoValidation({ type: 'query' })
export class QueryPostDto implements PaginateOptions {
@Transform(({ value }) => toBoolean(value))
@IsBoolean()
@ -46,11 +51,39 @@ export class QueryPostDto implements PaginateOptions {
@IsNumber()
@IsOptional()
limit: 10;
@IsDataExist(CategoryEntity, {
always: true,
message: '分类不存在',
})
@IsUUID(undefined, { message: '分类ID必须是UUID' })
@IsOptional()
category?: string;
@IsDataExist(TagEntity, {
always: true,
message: '标签不存在',
})
@IsUUID(undefined, { message: '标签ID必须是UUID' })
@IsOptional()
tag?: string;
@IsEnum(SelectTrashMode)
@IsOptional()
trashed?: SelectTrashMode;
@MaxLength(100, {
always: true,
message: '搜索字符串长度不得超过$constraint1',
})
@IsOptional({ always: true })
search?: string;
}
/**
*
*/
@DtoValidation({ groups: ['create'] })
export class CreatePostDto {
@MaxLength(255, {
always: true,
@ -93,11 +126,33 @@ export class CreatePostDto {
@IsNumber(undefined, { always: true })
@IsOptional({ always: true })
customOrder = 0;
@IsDataExist(CategoryEntity, {
message: '分类不存在',
})
@IsUUID(undefined, { message: '分类ID必须是UUID', each: true, always: true })
@IsOptional({ groups: ['update'] })
category: string;
@IsDataExist(TagEntity, {
each: true,
always: true,
message: '标签不存在',
})
@IsUUID(undefined, {
each: true,
always: true,
message: '每个标签ID必须是UUID',
})
@IsNotEmpty({ groups: ['create'], message: '至少需要一个标签' })
@IsOptional({ always: true })
tags?: string[];
}
/**
*
*/
@DtoValidation({ groups: ['update'] })
export class UpdatePostDto extends PartialType(CreatePostDto) {
@IsUUID(undefined, { groups: ['update'], message: '文章ID格式错误' })
@IsDefined({ groups: ['update'], message: '文章ID必须指定' })

View File

@ -0,0 +1,70 @@
import { PartialType } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsDefined,
IsEnum,
IsNotEmpty,
IsNumber,
IsOptional,
IsUUID,
MaxLength,
Min,
} from 'class-validator';
import { toNumber } from 'lodash';
import { DtoValidation } from '@/modules/core/decorators';
import { SelectTrashMode } from '@/modules/database/constants';
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()
@IsOptional()
page = 1;
@Transform(({ value }) => toNumber(value))
@Min(1, { message: '每页显示数量必须大于1' })
@IsNumber()
@IsOptional()
limit: 10;
}
/**
*
*/
@DtoValidation({ groups: ['create'] })
export class CreateTagDto {
@MaxLength(25, {
always: true,
message: '标签名称长度最大为$constraint1',
})
@IsNotEmpty({ groups: ['create'], message: '标签名称不能为空' })
@IsOptional({ groups: ['update'] })
name: string;
@MaxLength(255, {
always: true,
message: '标签描述长度最大为$constraint1',
})
@IsOptional({ always: true })
description?: string;
}
/**
*
*/
@DtoValidation({ groups: ['update'] })
export class UpdateTagDto extends PartialType(CreateTagDto) {
@IsUUID(undefined, { groups: ['update'], message: '标签ID格式不正确' })
@IsDefined({ groups: ['update'], message: '标签ID必须指定' })
id: string;
}

View File

@ -0,0 +1,57 @@
import { Exclude, Expose, Type } from 'class-transformer';
import {
BaseEntity,
Column,
DeleteDateColumn,
Entity,
Index,
OneToMany,
PrimaryColumn,
Relation,
Tree,
TreeChildren,
TreeParent,
} from 'typeorm';
import { PostEntity } from './post.entity';
@Exclude()
@Tree('materialized-path')
@Entity('content_categories')
export class CategoryEntity extends BaseEntity {
@Expose()
@PrimaryColumn({ type: 'varchar', generated: 'uuid', length: 36 })
id: string;
@Expose()
@Column({ comment: '分类名称' })
@Index({ fulltext: true })
name: string;
@Expose({ groups: ['category-tree', 'category-list', 'category-detail'] })
@Column({ comment: '分类排序', default: 0 })
customOrder: number;
@Expose()
@Type(() => Date)
@DeleteDateColumn({
comment: '删除时间',
})
deletedAt: Date;
@Expose({ groups: ['category-list'] })
depth = 0;
@Expose({ groups: ['category-detail', 'category-list'] })
@TreeParent({ onDelete: 'NO ACTION' })
parent: Relation<CategoryEntity> | null;
@Expose({ groups: ['category-tree'] })
@TreeChildren({ cascade: true })
children: Relation<CategoryEntity>[];
@OneToMany(() => PostEntity, (post) => post.category, {
cascade: true,
})
posts: Relation<PostEntity[]>;
}

View File

@ -0,0 +1,55 @@
import { Exclude, Expose } from 'class-transformer';
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
Index,
ManyToOne,
PrimaryColumn,
Relation,
Tree,
TreeChildren,
TreeParent,
} from 'typeorm';
import { PostEntity } from '@/modules/content/entities/post.entity';
@Exclude()
@Tree('materialized-path')
@Entity('content_comments')
export class CommentEntity extends BaseEntity {
@Expose()
@PrimaryColumn({ type: 'varchar', generated: 'uuid', length: 36 })
id: string;
@Expose()
@Column({ comment: '评论内容', type: 'text' })
@Index({ fulltext: true })
body: string;
@Expose()
@CreateDateColumn({
comment: '创建时间',
})
createdAt: Date;
@Expose()
@ManyToOne(() => PostEntity, (post) => post.comments, {
nullable: false,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
})
post: Relation<PostEntity>;
@Expose({ groups: ['comment-list'] })
depth = 0;
@Expose({ groups: ['comment-detail', 'comment-list'] })
@TreeParent({ onDelete: 'NO ACTION' })
parent: Relation<CommentEntity> | null;
@Expose({ groups: ['comment-tree'] })
@TreeChildren({ cascade: true })
children: Relation<CommentEntity[]>;
}

View File

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

View File

@ -1,55 +1,115 @@
import { Exclude, Expose } from 'class-transformer';
import { Exclude, Expose, Type } from 'class-transformer';
import {
BaseEntity,
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinTable,
ManyToMany,
ManyToOne,
OneToMany,
PrimaryColumn,
Relation,
UpdateDateColumn,
} from 'typeorm';
import { PostBodyType } from '@/modules/content/constants';
import { PostBodyType } from '../constants';
import { CategoryEntity } from './category.entity';
import { CommentEntity } from './comment.entity';
import { TagEntity } from './tag.entity';
@Exclude()
@Entity('content_posts')
export class PostEntity extends BaseEntity {
@Expose()
@PrimaryColumn({ type: 'varchar', generated: 'uuid', length: '36' })
@PrimaryColumn({ type: 'varchar', generated: 'uuid', length: 36 })
id: string;
@Expose()
@Column({ comment: '文章标题' })
@Index({ fulltext: true })
title: string;
@Expose({ groups: ['post-detail'] })
@Column({ comment: '文章内容', type: 'text' })
@Index({ fulltext: true })
body: string;
@Expose()
@Column({ comment: '文章摘要', nullable: true })
summary: string;
@Column({ comment: '文章描述', nullable: true })
@Index({ fulltext: true })
summary?: string;
@Expose()
@Column({ comment: '关键字', type: 'simple-array', nullable: true })
keywords?: string[];
@Expose()
@Column({ comment: '文章类型', type: 'varchar', default: PostBodyType.MD })
@Column({
comment: '文章类型',
type: 'varchar',
// 如果是mysql或者postgresql你可以使用enum类型
// enum: PostBodyType,
default: PostBodyType.MD,
})
type: PostBodyType;
@Expose()
@Column({ comment: '发布时间', type: 'varchar', nullable: true })
@Column({
comment: '发布时间',
type: 'varchar',
nullable: true,
})
publishedAt?: Date | null;
@Expose()
@CreateDateColumn({ comment: '创建时间' })
@Column({ comment: '自定义文章排序', default: 0 })
customOrder: number;
@Expose()
@CreateDateColumn({
comment: '创建时间',
})
createdAt: Date;
@Expose()
@UpdateDateColumn({ comment: '更新时间' })
@UpdateDateColumn({
comment: '更新时间',
})
updatedAt: Date;
@Expose()
@Column({ comment: '文章自定义排序', default: 0 })
customOrder: number;
@Type(() => Date)
@DeleteDateColumn({
comment: '删除时间',
})
deletedAt: Date;
/**
* queryBuilder生成的评论数量()
*/
@Expose()
commentCount: number;
@Expose()
@ManyToOne(() => CategoryEntity, (category) => category.posts, {
nullable: true,
onDelete: 'SET NULL',
})
category: Relation<CategoryEntity>;
@Expose()
@ManyToMany(() => TagEntity, (tag) => tag.posts, {
cascade: true,
})
@JoinTable()
tags: Relation<TagEntity>[];
@OneToMany(() => CommentEntity, (comment) => comment.post, {
cascade: true,
})
comments: Relation<CommentEntity>[];
}

View File

@ -0,0 +1,45 @@
import { Exclude, Expose, Type } from 'class-transformer';
import {
Column,
DeleteDateColumn,
Entity,
Index,
ManyToMany,
PrimaryColumn,
Relation,
} from 'typeorm';
import { PostEntity } from '@/modules/content/entities/post.entity';
@Exclude()
@Entity('content_tags')
export class TagEntity {
@Expose()
@PrimaryColumn({ type: 'varchar', generated: 'uuid', length: 36 })
id: string;
@Expose()
@Column({ comment: '分类名称' })
@Index({ fulltext: true })
name: string;
@Expose()
@Column({ comment: '标签描述', nullable: true })
description?: string;
@Expose()
@Type(() => Date)
@DeleteDateColumn({
comment: '删除时间',
})
deletedAt: Date;
/**
* queryBuilder生成的文章数量()
*/
@Expose()
postCount: number;
@ManyToMany(() => PostEntity, (post) => post.tags)
posts: Relation<PostEntity[]>;
}

View File

@ -0,0 +1,165 @@
import { pick, unset } from 'lodash';
import { FindOptionsUtils, FindTreeOptions, TreeRepository } from 'typeorm';
import { CategoryEntity } from '@/modules/content/entities';
import { CustomRepository } from '@/modules/database/decorators';
@CustomRepository(CategoryEntity)
export class CategoryRepository extends TreeRepository<CategoryEntity> {
/**
*
*/
buildBaseQB() {
return this.createQueryBuilder('category').leftJoinAndSelect('category.parent', 'parent');
}
/**
*
* @param options
*/
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;
}
/**
*
* @param options
*/
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');
qb.where(`${escapeAlias('category')}.${escapeColumn(parentPropertyName)} IS NULL`);
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();
}
/**
*
* @param entity
* @param options
*/
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();
}
/**
*
* @param entity
* @param options
*/
findAncestors(
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();
}
/**
*
* @param trees
* @param depth
* @param parent
*/
async toFlatTrees(trees: CategoryEntity[], depth = 0, parent: CategoryEntity | null = null) {
const data: Omit<CategoryEntity, 'children'>[] = [];
for (const tree of trees) {
tree.depth = depth;
tree.parent = parent;
const { children } = tree;
unset(tree, 'children');
data.push(tree);
data.push(...(await this.toFlatTrees(children, depth + 1, tree)));
}
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.deletedAt IS NOT NULL`);
}
return qb.getCount();
}
}

View File

@ -0,0 +1,137 @@
import { pick, unset } from 'lodash';
import {
FindOptionsUtils,
FindTreeOptions,
SelectQueryBuilder,
TreeRepository,
TreeRepositoryUtils,
} from 'typeorm';
import { CommentEntity } from '@/modules/content/entities';
import { CustomRepository } from '@/modules/database/decorators';
type FindCommentTreeOptions = FindTreeOptions & {
addQuery?: (query: SelectQueryBuilder<CommentEntity>) => SelectQueryBuilder<CommentEntity>;
};
@CustomRepository(CommentEntity)
export class CommentRepository extends TreeRepository<CommentEntity> {
/**
*
*/
buildBaseQB(qb: SelectQueryBuilder<CommentEntity>): SelectQueryBuilder<CommentEntity> {
return qb
.leftJoinAndSelect(`comment.parent`, 'parent')
.leftJoinAndSelect(`comment.post`, 'post')
.orderBy('comment.createdAt', 'DESC');
}
/**
*
* @param options
*/
async findTrees(options: FindCommentTreeOptions = {}) {
options.relations = ['parent', 'children'];
const roots = await this.findRoots(options);
await Promise.all(roots.map((root) => this.findDescendantsTree(root, options)));
return roots;
}
/**
*
* @param options
*/
findRoots(options: FindCommentTreeOptions = {}) {
const { addQuery, ...rest } = options;
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;
let qb = this.buildBaseQB(this.createQueryBuilder('comment'));
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, rest);
qb.where(`${escapeAlias('comment')}.${escapeColumn(parentPropertyName)} IS NULL`);
qb = addQuery ? addQuery(qb) : qb;
return qb.getMany();
}
/**
*
* @param closureTableAlias
* @param entity
* @param options
*/
createDtsQueryBuilder(
closureTableAlias: string,
entity: CommentEntity,
options: FindCommentTreeOptions = {},
): SelectQueryBuilder<CommentEntity> {
const { addQuery } = options;
const qb = this.buildBaseQB(
super.createDescendantsQueryBuilder('comment', closureTableAlias, entity),
);
return addQuery ? addQuery(qb) : qb;
}
/**
*
* @param entity
* @param options
*/
async findDescendantsTree(
entity: CommentEntity,
options: FindCommentTreeOptions = {},
): Promise<CommentEntity> {
const qb: SelectQueryBuilder<CommentEntity> = this.createDtsQueryBuilder(
'treeClosure',
entity,
options,
);
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, pick(options, ['relations', 'depth']));
const entities = await qb.getRawAndEntities();
const relationMaps = TreeRepositoryUtils.createRelationMaps(
this.manager,
this.metadata,
'comment',
entities.raw,
);
TreeRepositoryUtils.buildChildrenEntityTree(
this.metadata,
entity,
entities.entities,
relationMaps,
{
depth: -1,
...pick(options, ['relations']),
},
);
return entity;
}
/**
*
* @param trees
* @param depth
*/
async toFlatTrees(trees: CommentEntity[], depth = 0) {
const data: Omit<CommentEntity, 'children'>[] = [];
for (const tree of trees) {
tree.depth = depth;
const { children } = tree;
unset(tree, 'children');
data.push(tree);
data.push(...(await this.toFlatTrees(children, depth + 1)));
}
return data as CommentEntity[];
}
}

View File

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

View File

@ -1,11 +1,21 @@
import { Repository } from 'typeorm';
import { PostEntity } from '@/modules/content/entities';
import { CommentEntity, PostEntity } from '@/modules/content/entities';
import { CustomRepository } from '@/modules/database/decorators';
@CustomRepository(PostEntity)
export class PostRepository extends Repository<PostEntity> {
buildBaseQB() {
return this.createQueryBuilder('post');
// 在查询之前先查询出评论数量在添加到commentCount字段上
return this.createQueryBuilder('post')
.leftJoinAndSelect('post.category', 'category')
.leftJoinAndSelect('post.tags', 'tags')
.addSelect((subQuery) => {
return subQuery
.select('COUNT(c.id)', 'count')
.from(CommentEntity, 'c')
.where('c.post.id = post.id');
}, 'commentCount')
.loadRelationCountAndMap('post.commentCount', 'post.comments');
}
}

View File

@ -0,0 +1,18 @@
import { Repository } from 'typeorm';
import { PostEntity, TagEntity } from '@/modules/content/entities';
import { CustomRepository } from '@/modules/database/decorators';
@CustomRepository(TagEntity)
export class TagRepository extends Repository<TagEntity> {
buildBaseQB() {
return this.createQueryBuilder('tag')
.leftJoinAndSelect('tag.posts', 'posts')
.addSelect(
(subQuery) => subQuery.select('COUNT(p.id)', 'count').from(PostEntity, 'p'),
'postCount',
)
.orderBy('postCount', 'DESC')
.loadRelationCountAndMap('tag.postCount', 'tag.posts');
}
}

View File

@ -0,0 +1,171 @@
import { Injectable } from '@nestjs/common';
import { isNil, omit } from 'lodash';
import { EntityNotFoundError, In } from 'typeorm';
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';
/**
*
*/
@Injectable()
export class CategoryService {
constructor(protected repository: CategoryRepository) {}
/**
*
*/
async findTrees(options: QueryCategoryTreeDto) {
const { trashed = SelectTrashMode.NONE } = options;
return this.repository.findTrees({
withTrashed: trashed === SelectTrashMode.ALL || trashed === SelectTrashMode.ONLY,
onlyTrashed: trashed === SelectTrashMode.ONLY,
});
}
/**
*
* @param options
*/
async paginate(options: QueryCategoryDto) {
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);
}
/**
*
* @param id
*/
async detail(id: string) {
return this.repository.findOneOrFail({
where: { id },
relations: ['parent'],
});
}
/**
*
* @param data
*/
async create(data: CreateCategoryDto) {
const item = await this.repository.save({
...data,
parent: await this.getParent(undefined, data.parent),
});
return this.detail(item.id);
}
/**
*
* @param data
*/
async update(data: UpdateCategoryDto) {
await this.repository.update(data.id, omit(data, ['id', 'parent']));
const item = await this.detail(data.id);
const parent = await this.getParent(item.parent?.id, data.parent);
const sholdUpdateParent =
(!isNil(item.parent) && !isNil(parent) && item.parent.id !== parent.id) ||
(isNil(item.parent) && !isNil(parent)) ||
(!isNil(item.parent) && isNil(parent));
// 父分类单独更新
if (parent !== undefined && sholdUpdateParent) {
item.parent = parent;
await this.repository.save(item, { reload: true });
}
return item;
}
/**
*
* @param id
*/
async delete(ids: string[], trash?: boolean) {
const items = await this.repository.find({
where: { id: In(ids) },
withDeleted: true,
relations: ['parent', 'children'],
});
// 把子分类提升一级
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);
}
}
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 current ID
* @param id
*/
protected async getParent(current?: string, parentId?: string) {
if (current === parentId) return undefined;
let parent: CategoryEntity | undefined;
if (parentId !== undefined) {
if (parentId === null) return null;
parent = await this.repository.findOne({ where: { id: parentId } });
if (!parent)
throw new EntityNotFoundError(
CategoryEntity,
`Parent category ${parentId} not exists!`,
);
}
return parent;
}
/**
*
* @param ids
*/
async restore(ids: string[]) {
const items = await this.repository.find({
where: { id: In(ids) } as any,
withDeleted: true,
});
const trasheds = items.filter((item) => !isNil(item)).map((item) => item.id);
if (trasheds.length < 1) return [];
await this.repository.restore(trasheds);
const qb = this.repository.buildBaseQB();
qb.andWhereInIds(trasheds);
return qb.getMany();
}
}

View File

@ -0,0 +1,123 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { isNil } from 'lodash';
import { EntityNotFoundError, In, SelectQueryBuilder } from 'typeorm';
import { CreateCommentDto, QueryCommentDto, QueryCommentTreeDto } from '@/modules/content/dtos';
import { CommentEntity } from '@/modules/content/entities';
import { CommentRepository, PostRepository } from '@/modules/content/repositories';
import { treePaginate } from '@/modules/database/helpers';
/**
*
*/
@Injectable()
export class CommentService {
constructor(
protected repository: CommentRepository,
protected postRepository: PostRepository,
) {}
/**
*
* @param options
*/
async findTrees(options: QueryCommentTreeDto = {}) {
return this.repository.findTrees({
addQuery: (qb) => {
return isNil(options.post) ? qb : qb.where('post.id = :id', { id: options.post });
},
});
}
/**
*
* @param dto
*/
async paginate(dto: QueryCommentDto) {
const { post, ...query } = dto;
const addQuery = (qb: SelectQueryBuilder<CommentEntity>) => {
const condition: Record<string, string> = {};
if (!isNil(post)) condition.post = post;
return Object.keys(condition).length > 0 ? qb.andWhere(condition) : qb;
};
const data = await this.repository.findRoots({ addQuery });
let comments: CommentEntity[] = [];
for (let i = 0; i < data.length; i++) {
const c = data[i];
comments.push(
await this.repository.findDescendantsTree(c, {
addQuery,
}),
);
}
comments = await this.repository.toFlatTrees(comments);
return treePaginate(query, comments);
}
/**
*
* @param data
* @param user
*/
async create(data: CreateCommentDto) {
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!');
}
const item = await this.repository.save({
...data,
parent,
post: await this.getPost(data.post),
});
return this.repository.findOneOrFail({ where: { id: item.id } });
}
/**
*
* @param ids
*/
async delete(ids: string[]) {
const comments = await this.repository.find({ where: { id: In(ids) } });
return this.repository.remove(comments);
}
/**
*
* @parem id
*/
protected async getPost(id: string) {
return !isNil(id) ? this.postRepository.findOneOrFail({ where: { id } }) : id;
}
/**
*
* @param current ID
* @param parentId
*/
protected async getParent(current?: string, parentId?: string) {
if (current === parentId) return undefined;
let parent: CommentEntity | undefined;
if (parentId !== undefined) {
if (parentId === null) return null;
parent = await this.repository.findOne({
relations: ['parent', 'post'],
where: { id: parentId },
});
if (!parent)
throw new EntityNotFoundError(
CommentEntity,
`Parent comment ${parentId} not exists !`,
);
}
return parent;
}
}

View File

@ -1,2 +1,5 @@
export * from './category.service';
export * from './comment.service';
export * from './post.service';
export * from './sanitize.service';
export * from './search.service';
export * from './tag.service';

View File

@ -1,27 +1,48 @@
import { Injectable } from '@nestjs/common';
import { isFunction, isNil, omit } from 'lodash';
import { isArray, isFunction, isNil, omit, pick } from 'lodash';
import { EntityNotFoundError, IsNull, Not, SelectQueryBuilder } from 'typeorm';
import { EntityNotFoundError, In, IsNull, Not, SelectQueryBuilder } from 'typeorm';
import { PostOrderType } from '@/modules/content/constants';
import { CreatePostDto, UpdatePostDto } from '@/modules/content/dtos';
import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos';
import { PostEntity } from '@/modules/content/entities';
import { PostRepository } from '@/modules/content/repositories';
import { CategoryRepository, PostRepository, TagRepository } from '@/modules/content/repositories';
import { CategoryService } from '@/modules/content/services/category.service';
import { SearchService } from '@/modules/content/services/search.service';
import { SearchType } from '@/modules/content/types';
import { SelectTrashMode } from '@/modules/database/constants';
import { paginate } from '@/modules/database/helpers';
import { PaginateOptions, QueryHook } from '@/modules/database/types';
import { QueryHook } from '@/modules/database/types';
type FindParams = {
[key in keyof Omit<QueryPostDto, 'limit' | 'page'>]: QueryPostDto[key];
};
@Injectable()
export class PostService {
constructor(protected repository: PostRepository) {}
constructor(
protected repository: PostRepository,
protected categoryRepository: CategoryRepository,
protected categoryService: CategoryService,
protected tagRepository: TagRepository,
protected searchService?: SearchService,
protected search_type: SearchType = 'against',
) {}
/**
*
* @param options
* @param callback
*/
async paginate(options: PaginateOptions, callback?: QueryHook<PostEntity>) {
async paginate(options: QueryPostDto, callback?: QueryHook<PostEntity>) {
if (!isNil(this.searchService) && !isNil(options.search) && this.search_type === 'meilli') {
return this.searchService.search(
options.search,
pick(options, ['trashed', 'page', 'limit']),
);
}
const qb = await this.buildListQuery(this.repository.buildBaseQB(), options, callback);
return paginate(qb, options);
}
@ -45,7 +66,22 @@ export class PostService {
* @param data
*/
async create(data: CreatePostDto) {
const item = await this.repository.save(data);
const createPostDto = {
...data,
// 文章所属的分类
category: !isNil(data.category)
? await this.categoryRepository.findOneOrFail({ where: { id: data.category } })
: null,
// 文章关联的标签
tags: isArray(data.tags)
? await this.tagRepository.findBy({
id: In(data.tags),
})
: [],
};
const item = await this.repository.save(createPostDto);
if (!isNil(this.searchService)) await this.searchService.create(item);
return this.detail(item.id);
}
@ -55,17 +91,82 @@ export class PostService {
* @param data
*/
async update(data: UpdatePostDto) {
await this.repository.update(data.id, omit(data, ['id']));
return this.detail(data.id);
const post = await this.detail(data.id);
if (data.category !== undefined) {
// 更新分类
const category = isNil(data.category)
? null
: await this.categoryRepository.findOneByOrFail({ id: data.category });
post.category = category;
this.repository.save(post, { reload: true });
}
if (isArray(data.tags)) {
// 更新文章关联标签
await this.repository
.createQueryBuilder('post')
.relation(PostEntity, 'tags')
.of(post)
.addAndRemove(data.tags, post.tags ?? []);
}
await this.repository.update(data.id, omit(data, ['id', 'tags', 'category']));
const result = await this.detail(data.id);
if (!isNil(this.searchService)) await this.searchService.update([post]);
return result;
}
/**
*
* @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) },
withDeleted: true,
});
let result: PostEntity[] = [];
if (trash) {
// 对已软删除的数据再次删除时直接通过remove方法从数据库中清除
const directs = items.filter((item) => !isNil(item.deletedAt));
const softs = items.filter((item) => isNil(item.deletedAt));
result = [
...(await this.repository.remove(directs)),
...(await this.repository.softRemove(softs)),
];
if (!isNil(this.searchService)) {
await this.searchService.delete(directs.map(({ id }) => id));
await this.searchService.update(softs);
}
} else {
result = await this.repository.remove(items);
if (!isNil(this.searchService)) {
await this.searchService.delete(result.map(({ id }) => id));
}
}
return result;
}
/**
*
* @param ids
*/
async restore(ids: string[]) {
const items = await this.repository.find({
where: { id: In(ids) },
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();
}
/**
@ -76,23 +177,59 @@ export class PostService {
*/
protected async buildListQuery(
qb: SelectQueryBuilder<PostEntity>,
options: Record<string, any>,
options: FindParams,
callback?: QueryHook<PostEntity>,
) {
const { orderBy, isPublished } = options;
let newQb = qb;
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') {
newQb = isPublished
? newQb.where({
isPublished
? qb.where({
publishedAt: Not(IsNull()),
})
: newQb.where({
: qb.where({
publishedAt: IsNull(),
});
}
newQb = this.queryOrderBy(newQb, orderBy);
if (callback) return callback(newQb);
return newQb;
this.queryOrderBy(qb, orderBy);
if (category) await this.queryByCategory(category, qb);
if (!isNil(options.search)) this.buildSearchQuery(qb, options.search);
// 查询某个标签关联的文章
if (tag) qb.where('tags.id = :id', { id: tag });
if (callback) return callback(qb);
return qb;
}
protected async buildSearchQuery(qb: SelectQueryBuilder<PostEntity>, search: string) {
if (this.search_type === 'like') {
qb.andWhere('title LIKE :search', { search: `%${search}%` })
.orWhere('body LIKE :search', { search: `%${search}%` })
.orWhere('summary LIKE :search', { search: `%${search}%` })
.orWhere('category.name LIKE :search', { search: `%${search}%` })
.orWhere('tags.name LIKE :search', { search: `%${search}%` });
} else if (this.search_type === 'against') {
qb.andWhere('MATCH(title) AGAINST (:search IN BOOLEAN MODE)', {
search: `${search}*`,
})
.orWhere('MATCH(body) AGAINST (:search IN BOOLEAN MODE)', {
search: `${search}*`,
})
.orWhere('MATCH(summary) AGAINST (:search IN BOOLEAN MODE)', {
search: `${search}*`,
})
.orWhere('MATCH(category.name) AGAINST (:search IN BOOLEAN MODE)', {
search: `${search}*`,
})
.orWhere('MATCH(tags.name) AGAINST (:search IN BOOLEAN MODE)', {
search: `${search}*`,
});
}
return qb;
}
/**
@ -108,13 +245,31 @@ export class PostService {
return qb.orderBy('post.updatedAt', 'DESC');
case PostOrderType.PUBLISHED:
return qb.orderBy('post.publishedAt', 'DESC');
case PostOrderType.COMMENTCOUNT:
return qb.orderBy('commentCount', 'DESC');
case PostOrderType.CUSTOM:
return qb.orderBy('customOrder', 'DESC');
default:
return qb
.orderBy('post.createdAt', 'DESC')
.addOrderBy('post.updatedAt', 'DESC')
.addOrderBy('post.publishedAt', 'DESC');
.addOrderBy('post.publishedAt', 'DESC')
.addOrderBy('commentCount', 'DESC');
}
}
/**
* Query构建
* @param id
* @param qb
*/
protected async queryByCategory(id: string, qb: SelectQueryBuilder<PostEntity>) {
const root = await this.categoryService.detail(id);
const tree = await this.categoryRepository.findDescendantsTree(root);
const flatDes = await this.categoryRepository.toFlatTrees(tree.children);
const ids = [tree.id, ...flatDes.map((item) => item.id)];
return qb.where('category.id IN (:...ids)', {
ids,
});
}
}

View File

@ -0,0 +1,136 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { instanceToPlain } from 'class-transformer';
import { isNil, omit, pick } from 'lodash';
import MeiliSearch from 'meilisearch';
import { PostEntity } from '@/modules/content/entities';
import { CategoryRepository, CommentRepository } from '@/modules/content/repositories';
import { SelectTrashMode } from '@/modules/database/constants';
import { MeilliService } from '@/modules/meilisearch/meilli.service';
interface SearchOption {
trashed?: SelectTrashMode;
isPublished?: boolean;
page?: number;
limit?: number;
}
async function getPostData(
catRepo: CategoryRepository,
cmtRepo: CommentRepository,
post: PostEntity,
) {
const categories = [
...(await catRepo.findAncestors(post.category)).map((item) => {
return {
id: item.id,
name: item.name,
};
}),
{ id: post.category.id, name: post.category.name },
];
const comments = (
await cmtRepo.find({
relations: ['post'],
where: { post: { id: post.id } },
})
).map((item) => ({ id: item.id, body: item.body }));
return [
{
...pick(instanceToPlain(post), [
'id',
'title',
'body',
'summary',
'commentCount',
'deletedAt',
'publishedAt',
'createdAt',
'updatedAt',
]),
categories,
tags: post.tags.map((item) => ({ id: item.id, name: item.name })),
comments,
},
];
}
@Injectable()
export class SearchService {
index = 'content';
protected _client: MeiliSearch;
constructor(
protected meilliService: MeilliService,
protected categoryRepository: CategoryRepository,
protected commentRepository: CommentRepository,
) {
this._client = this.meilliService.getClient();
}
get client() {
if (isNil(this._client)) throw new ForbiddenException('Has not any meilli search client!');
return this._client;
}
async search(text: string, param: SearchOption = {}) {
await this.client.index(this.index).addDocuments([]);
this.client.index(this.index).updateFilterableAttributes(['deletedAt', 'publishedAt']);
this.client.index(this.index).updateSortableAttributes(['updatedAt', 'commentCount']);
const option = { page: 1, limit: 10, trashed: SelectTrashMode.NONE, ...param };
const limit = isNil(option.limit) || option.limit < 1 ? 1 : option.limit;
const page = isNil(option.page) || option.page < 1 ? 1 : option.page;
let filter = ['deletedAt IS NULL'];
if (option.trashed === SelectTrashMode.ALL) {
filter = [];
} else if (option.trashed === SelectTrashMode.ONLY) {
filter = ['deletedAt IS NOT NULL'];
}
if (!isNil(option.isPublished)) {
filter.push(option.isPublished ? 'publishedAt IS NOT NULL' : 'deletedAt IS NULL');
}
const result = await this.client.index(this.index).search(text, {
page,
limit,
sort: ['updatedAt:desc', 'commentCount:desc'],
filter,
});
return {
items: result.hits,
currentPage: result.page,
perPage: result.hitsPerPage,
totalItems: result.estimatedTotalHits,
itemCount: result.totalHits,
...omit(result, ['hits', 'page', 'hitsPerPage', 'estimatedTotalHits', 'totalHits']),
};
}
async create(post: PostEntity) {
return this.client
.index(this.index)
.addDocuments(await getPostData(this.categoryRepository, this.commentRepository, post));
}
async update(posts: PostEntity[]) {
return this.client
.index(this.index)
.updateDocuments(
await Promise.all(
posts.map((post) =>
getPostData(this.categoryRepository, this.commentRepository, post),
),
),
);
}
async delete(ids: string[]) {
return this.client.index(this.index).deleteDocuments(ids);
}
}

View File

@ -0,0 +1,126 @@
import { Injectable } from '@nestjs/common';
import { isNil, omit } from 'lodash';
import { In, SelectQueryBuilder } from 'typeorm';
import { CreateTagDto, QueryTagsDto, UpdateTagDto } from '@/modules/content/dtos';
import { TagEntity } from '@/modules/content/entities';
import { TagRepository } from '@/modules/content/repositories';
import { SelectTrashMode } from '@/modules/database/constants';
import { paginate } from '@/modules/database/helpers';
import { QueryHook } from '@/modules/database/types';
type FindParams = {
[key in keyof Omit<QueryTagsDto, 'limit' | 'page'>]: QueryTagsDto[key];
};
/**
*
*/
@Injectable()
export class TagService {
constructor(protected repository: TagRepository) {}
/**
*
* @param options
* @param callback
*/
async paginate(options: QueryTagsDto) {
const qb = await this.buildListQuery(this.repository.buildBaseQB(), options);
return paginate(qb, options);
}
/**
*
* @param id
* @param callback
*/
async detail(id: string) {
const qb = this.repository.buildBaseQB();
qb.where(`tag.id = :id`, { id });
return qb.getOneOrFail();
}
/**
*
* @param data
*/
async create(data: CreateTagDto) {
const item = await this.repository.save(data);
return this.detail(item.id);
}
/**
*
* @param data
*/
async update(data: UpdateTagDto) {
await this.repository.update(data.id, omit(data, ['id']));
return this.detail(data.id);
}
/**
*
* @param ids
*/
async delete(ids: string[], trash: boolean) {
const items = await this.repository.find({
where: { id: In(ids) } as any,
withDeleted: true,
});
if (trash) {
const directs = items.filter((item) => !isNil(item.deletedAt));
const sorts = items.filter((item) => isNil(item.deletedAt));
return [
...(await this.repository.remove(directs)),
...(await this.repository.softRemove(sorts)),
];
}
return this.repository.remove(items);
}
/**
*
* @param ids
*/
async restore(ids: string[]) {
const items = await this.repository.find({
where: { id: In(ids) } as any,
withDeleted: true,
});
// 过滤掉不在回收站的标签
const trashed = items.filter((item) => !isNil(item.deletedAt)).map((item) => item.id);
if (trashed.length < 1) return trashed;
await this.repository.restore(trashed);
const qb = this.repository.buildBaseQB().where({ id: In(trashed) });
return qb.getMany();
}
/**
* ()
* @param qb
* @param options
* @param callback
*/
protected async buildListQuery(
qb: SelectQueryBuilder<TagEntity>,
options: FindParams,
callback?: QueryHook<TagEntity>,
) {
const { trashed } = options;
if (trashed === SelectTrashMode.ALL || trashed === SelectTrashMode.ONLY) {
qb.withDeleted();
if (trashed === SelectTrashMode.ONLY) qb.where(`tag.deletedAt IS NOT NULL`);
}
if (callback) return callback(qb);
return qb;
}
}

View File

@ -3,7 +3,7 @@ import { DataSource, EventSubscriber } from 'typeorm';
import { PostBodyType } from '@/modules/content/constants';
import { PostEntity } from '@/modules/content/entities';
import { PostRepository } from '@/modules/content/repositories';
import { SanitizeService } from '@/modules/content/services';
import { SanitizeService } from '@/modules/content/services/sanitize.service';
@EventSubscriber()
export class PostSubscriber {

View File

@ -0,0 +1,5 @@
export type SearchType = 'like' | 'against' | 'meilli';
export interface ContentConfig {
searchType?: SearchType;
}

View File

@ -0,0 +1,4 @@
/**
* DTOValidation
*/
export const DTO_VALIDATION_OPTIONS = 'dto_validation_options';

View File

@ -0,0 +1,3 @@
export * from './match.constraint';
export * from './match.phone.constraint';
export * from './password.constraint';

View File

@ -0,0 +1,41 @@
import {
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
registerDecorator,
} from 'class-validator';
/**
*
*/
@ValidatorConstraint({ name: 'isMatch' })
export class MatchConstraint implements ValidatorConstraintInterface {
validate(value: any, args?: ValidationArguments) {
const [relatedProperty] = args.constraints;
const relatedValue = (args.object as any)[relatedProperty];
return value === relatedValue;
}
defaultMessage(args?: ValidationArguments) {
const [relatedProperty] = args.constraints;
return `${relatedProperty} and ${args.property} don't match`;
}
}
/**
* DTO中两个属性的值是否相等的验证规则
* @param relatedProperty
* @param validationOptions class-validator库的选项
*/
export function IsMatch(relatedProperty: string, validationOptions?: ValidationOptions) {
return (object: RecordAny, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [relatedProperty],
validator: MatchConstraint,
});
};
}

View File

@ -0,0 +1,48 @@
import {
ValidationArguments,
ValidationOptions,
isMobilePhone,
registerDecorator,
} from 'class-validator';
import { IsMobilePhoneOptions, MobilePhoneLocale } from 'validator/lib/isMobilePhone';
/**
* ,"区域号.手机号"
*/
export function isMatchPhone(
value: any,
locale?: MobilePhoneLocale,
options?: IsMobilePhoneOptions,
): boolean {
if (!value) return false;
const phoneArr: string[] = value.split('.');
return isMobilePhone(phoneArr.join(''), locale, options);
}
/**
* ,"区域号.手机号"
* @param locales
* @param options isMobilePhone约束选项
* @param validationOptions class-validator库的选项
*/
export function IsMatchPhone(
locales?: MobilePhoneLocale | MobilePhoneLocale[],
options?: IsMobilePhoneOptions,
validationOptions?: ValidationOptions,
) {
return (object: RecordAny, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [locales || 'any', options],
validator: {
validate: (value: any, args: ValidationArguments): boolean =>
isMatchPhone(value, args.constraints[0], args.constraints[1]),
defaultMessage: (_args: ValidationArguments) =>
'$property must be a phone number, eg: +86.12345678901',
},
});
};
}

View File

@ -0,0 +1,64 @@
import {
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
registerDecorator,
} from 'class-validator';
type ModelType = 1 | 2 | 3 | 4 | 5;
@ValidatorConstraint({ name: 'isPassword', async: false })
export class IsPasswordConstraint implements ValidatorConstraintInterface {
validate(value: any, args?: ValidationArguments) {
const validateModel: ModelType = args.constraints[0] ?? 1;
switch (validateModel) {
// 必需由大写或小写字母组成(默认)
case 1:
return /\d/.test(value) && /[a-zA-Z]/.test(value);
// 必需由小写字母组成
case 2:
return /\d/.test(value) && /[a-z]/.test(value);
// 必须由大写字母组成
case 3:
return /\d/.test(value) && /[A-Z]/.test(value);
// 必需包含数字,小写字母,大写字母
case 4:
return (
/\d/.test(value) &&
/[a-z]/.test(value) &&
/[A-Z]/.test(value) &&
/[!@#$%^&]/.test(value)
);
default:
return /\d/.test(value) && /[a-zA-Z]/.test(value);
}
}
defaultMessage(_args?: ValidationArguments) {
return `($value) 's format error!`;
}
}
/**
*
* 模式1: 必须由大写或小写字母组成()
* 模式2: 必须由小写字母组成
* 模式3: 必须由大写字母组成
* 模式4: 必须包含数字,,
* 模式5: 必须包含数字,,,
* @param model
* @param validationOptions
*/
export function IsPassword(model?: ModelType, validationOptions?: ValidationOptions) {
return (object: RecordAny, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [model],
validator: IsPasswordConstraint,
});
};
}

View File

@ -0,0 +1,15 @@
import { Paramtype, SetMetadata } from '@nestjs/common';
import { ClassTransformOptions } from 'class-transformer';
import { ValidatorOptions } from 'class-validator';
import { DTO_VALIDATION_OPTIONS } from '@/modules/core/constants';
/**
* DTO类装饰器
* @params options
*/
export const DtoValidation = (
options?: ValidatorOptions & { transformOptions?: ClassTransformOptions } & {
type?: Paramtype;
},
) => SetMetadata(DTO_VALIDATION_OPTIONS, options ?? {});

View File

@ -0,0 +1 @@
export * from './dto-validation.decorator';

View File

@ -0,0 +1,52 @@
import { ArgumentsHost, Catch, HttpException, HttpStatus, Type } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';
import { isObject } from 'lodash';
import { EntityNotFoundError, EntityPropertyNotFoundError, QueryFailedError } from 'typeorm';
/**
*
*/
@Catch()
export class AppFilter<T = Error> extends BaseExceptionFilter<T> {
protected resExceptions: Array<{ class: Type<Error>; status?: number } | Type<Error>> = [
{ class: EntityNotFoundError, status: HttpStatus.NOT_FOUND },
{ class: QueryFailedError, status: HttpStatus.BAD_REQUEST },
{ class: EntityPropertyNotFoundError, status: HttpStatus.BAD_REQUEST },
];
// eslint-disable-next-line consistent-return
catch(exception: T, host: ArgumentsHost) {
const applicationRef =
this.applicationRef || (this.httpAdapterHost && this.httpAdapterHost.httpAdapter)!;
// 是否在自定义的异常处理类列表中
const resException = this.resExceptions.find((item) =>
'class' in item ? exception instanceof item.class : exception instanceof item,
);
// 如果不在自定义异常处理类列表也没有继承自HttpException
if (!resException && !(exception instanceof HttpException)) {
return this.handleUnknownError(exception, host, applicationRef);
}
let res: string | object = '';
let status = HttpStatus.INTERNAL_SERVER_ERROR;
if (exception instanceof HttpException) {
res = exception.getResponse();
status = exception.getStatus();
} else if (resException) {
// 如果在自定义异常处理类列表中
const e = exception as unknown as Error;
res = e.message;
if ('class' in resException && resException.status) {
status = resException.status;
}
}
const message = isObject(res)
? res
: {
statusCode: status,
message: res,
};
applicationRef!.reply(host.getArgByIndex(1), message, status);
}
}

View File

@ -0,0 +1,84 @@
import {
ArgumentMetadata,
BadRequestException,
Injectable,
Paramtype,
ValidationPipe,
} from '@nestjs/common';
import { isObject, omit } from 'lodash';
import { DTO_VALIDATION_OPTIONS } from '@/modules/core/constants';
import { deepMerge } from '@/modules/core/helpers';
/**
* DTO验证
*/
@Injectable()
export class AppPipe extends ValidationPipe {
async transform(value: any, metadata: ArgumentMetadata) {
const { metatype, type } = metadata;
// 获取要验证的dto类
const dto = metatype as any;
// 获取dto类的装饰器元数据中的自定义验证选项
const options = Reflect.getMetadata(DTO_VALIDATION_OPTIONS, dto) || {};
// 把当前已设置的选项解构到备份对象
const originOptions = { ...this.validatorOptions };
// 把当前已设置的class-transform选项解构到备份对象
const originTransform = { ...this.transformOptions };
// 把自定义的class-transform和type选项解构
const { transformOptions, type: optionsType, ...customOptions } = options;
// 根据DTO类上设置的type来设置当前的DTO请求类型,默认为'body'
const requestType: Paramtype = optionsType ?? 'body';
// 如果被验证的DTO设置的请求类型与被验证的数据的请求类型不是同一种类型 跳过此管道
if (requestType !== type) return value;
// 合并当前transform选项和自定义选项
if (transformOptions) {
this.transformOptions = deepMerge(
this.transformOptions,
transformOptions ?? {},
'replace',
);
}
// 合并当前验证选项和自定义选项
this.validatorOptions = deepMerge(this.validatorOptions, customOptions ?? {}, 'replace');
const toValidate = isObject(value)
? Object.fromEntries(
Object.entries(value as RecordAny).map(([key, v]) => {
if (!isObject(v) || !('mimetype' in v)) return [key, v];
return [key, omit(v, ['fields'])];
}),
)
: value;
try {
// 序列化并验证dto对象
let result = await super.transform(toValidate, metadata);
// 如果dto类中存在transform静态方法,则返回调用进一步transform之后的结果
if (typeof result.transform === 'function') {
result === (await result.transform(result));
const { transform, ...data } = result;
result = data;
}
// 重置验证选项
this.validatorOptions = originOptions;
// 重置transform选项
this.transformOptions = originTransform;
return result;
} catch (error: any) {
// 重置验证选项
this.validatorOptions = originOptions;
// 重置transform选项
this.transformOptions = originTransform;
if ('response' in error) throw new BadRequestException(error.response);
throw new BadRequestException(error);
}
}
}

View File

@ -1 +1,3 @@
export * from './app.filter';
export * from './app.interceptor';
export * from './app.pipe';

View File

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

View File

@ -0,0 +1,94 @@
import { Injectable } from '@nestjs/common';
import {
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
registerDecorator,
} from 'class-validator';
import { DataSource, ObjectType, Repository } from 'typeorm';
type Condition = {
entity: ObjectType<any>;
/**
* ,id
*/
map?: string;
};
/**
*
*/
@ValidatorConstraint({ name: 'dataExist', async: true })
@Injectable()
export class DataExistConstraint implements ValidatorConstraintInterface {
constructor(private dataSource: DataSource) {}
async validate(value: string, args?: ValidationArguments) {
let repo: Repository<any>;
if (!value) return true;
// 默认对比字段是id
let map = 'id';
// 通过传入的entity获取其repository
if ('entity' in args.constraints[0]) {
map = args.constraints[0].map ?? 'id';
repo = this.dataSource.getRepository(args.constraints[0].entity);
} else {
repo = this.dataSource.getRepository(args.constraints[0]);
}
// 通过查询记录是否存在进行验证
const item = await repo.findOne({ where: { [map]: value } });
return !!item;
}
defaultMessage(args?: ValidationArguments) {
if (!args.constraints[0]) {
return 'Model not been specified!';
}
return `All instance of ${args.constraints[0].name} must been exists in database!`;
}
}
/**
*
* @param entity
* @param validationOptions
*/
function IsDataExist(
entity: ObjectType<any>,
validationOptions?: ValidationOptions,
): (object: RecordAny, propertyName: string) => void;
/**
*
* @param condition
* @param validationOptions
*/
function IsDataExist(
condition: Condition,
validationOptions?: ValidationOptions,
): (object: RecordAny, propertyName: string) => void;
/**
*
* @param condition
* @param validationOptions
*/
function IsDataExist(
condition: ObjectType<any> | Condition,
validationOptions: ValidationOptions,
): (object: RecordAny, propertyName: string) => void {
return (object: RecordAny, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [condition],
validator: DataExistConstraint,
});
};
}
export { IsDataExist };

View File

@ -0,0 +1,5 @@
export * from './data.exist.constraint';
export * from './tree.unique.constraint';
export * from './tree.unique.exist.constraint';
export * from './unique.constraint';
export * from './unique.exist.constraint';

View File

@ -0,0 +1,90 @@
import { Injectable } from '@nestjs/common';
import {
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
registerDecorator,
} from 'class-validator';
import { isNil, merge } from 'lodash';
import { DataSource, ObjectType } from 'typeorm';
type Condition = {
entity: ObjectType<any>;
parentKey?: string;
property?: string;
};
/**
*
*/
@Injectable()
@ValidatorConstraint({ name: 'treeDataUnique', async: true })
export class UniqueTreeConstraint implements ValidatorConstraintInterface {
constructor(private dataSource: DataSource) {}
async validate(value: any, args: ValidationArguments) {
const config: Omit<Condition, 'entity'> = {
parentKey: 'parent',
property: args.property,
};
const condition = ('entity' in args.constraints[0]
? merge(config, args.constraints[0])
: {
...config,
entity: args.constraints[0],
}) as unknown as Required<Condition>;
// 需要查询的属性名,默认为当前验证的属性
const argsObj = args.object as any;
if (!condition.entity) return false;
try {
// 获取repository
const repo = this.dataSource.getTreeRepository(condition.entity);
if (isNil(value)) return true;
const collection = await repo.find({
where: {
parent: !argsObj[condition.parentKey]
? null
: { id: argsObj[condition.parentKey] },
},
withDeleted: true,
});
// 对比每个子分类的queryProperty值是否与当前验证的dto属性相同,如果有相同的则验证失败
return collection.every((item) => item[condition.property] !== value);
} catch (err) {
return false;
}
}
defaultMessage(args: ValidationArguments) {
const { entity, property } = args.constraints[0];
const queryProperty = property ?? args.property;
if (!entity) {
return 'Model not been specified!';
}
return `${queryProperty} of ${entity.name} must been unique with siblings element!`;
}
}
/**
*
* @param params
* @param validationOptions
*/
export function IsTreeUnique(
params: ObjectType<any> | Condition,
validationOptions?: ValidationOptions,
) {
return (object: Record<string, any>, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [params],
validator: UniqueTreeConstraint,
});
};
}

View File

@ -0,0 +1,106 @@
import { Injectable } from '@nestjs/common';
import {
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
registerDecorator,
} from 'class-validator';
import { merge } from 'lodash';
import { DataSource, ObjectType } from 'typeorm';
type Condition = {
entity: ObjectType<any>;
/**
* id
*/
ignore?: string;
/**
* ,ignore
*/
findKey?: string;
/**
* ,
*/
property?: string;
};
/**
* ,ignore指定忽略的字段
*/
@Injectable()
@ValidatorConstraint({ name: 'treeDataUniqueExist', async: true })
export class UniqueTreeExistConstraint implements ValidatorConstraintInterface {
constructor(private dataSource: DataSource) {}
async validate(value: any, args: ValidationArguments) {
const config: Omit<Condition, 'entity'> = {
ignore: 'id',
property: args.property,
};
const condition = ('entity' in args.constraints[0]
? merge(config, args.constraints[0])
: {
...config,
entity: args.constraints[0],
}) as unknown as Required<Condition>;
if (!condition.findKey) {
condition.findKey = condition.ignore;
}
if (!condition.entity) return false;
// 在传入的dto数据中获取需要忽略的字段的值
const ignoreValue = (args.object as any)[condition.ignore];
// 查询条件字段的值
const keyValue = (args.object as any)[condition.findKey];
if (!ignoreValue || !keyValue) return false;
const repo = this.dataSource.getTreeRepository(condition.entity);
// 根据查询条件查询出当前验证的数据
const item = await repo.findOne({
where: { [condition.findKey]: keyValue },
relations: ['parent'],
});
// 没有此数据则验证失败
if (!item) return false;
// 如果验证数据没有parent则把所有顶级分类作为验证数据否则就把同一个父分类下的子分类作为验证数据
const rows: any[] = await repo.find({
where: {
parent: !item.parent ? null : { id: item.parent.id },
},
withDeleted: true,
});
// 在忽略本身数据后如果同级别其它数据与验证的queryProperty的值相同则验证失败
return !rows.find(
(row) => row[condition.property] === value && row[condition.ignore] !== ignoreValue,
);
}
defaultMessage(args: ValidationArguments) {
const { entity, property } = args.constraints[0];
const queryProperty = property ?? args.property;
if (!entity) {
return 'Model not been specified!';
}
return `${queryProperty} of ${entity.name} must been unique with siblings element!`;
}
}
/**
*
* @param params
* @param validationOptions
*/
export function IsTreeUniqueExist(
params: ObjectType<any> | Condition,
validationOptions?: ValidationOptions,
) {
return (object: Record<string, any>, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [params],
validator: UniqueTreeExistConstraint,
});
};
}

View File

@ -0,0 +1,83 @@
import { Injectable } from '@nestjs/common';
import {
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
registerDecorator,
} from 'class-validator';
import { isNil, merge } from 'lodash';
import { DataSource, ObjectType } from 'typeorm';
type Condition = {
entity: ObjectType<any>;
/**
* 使
*/
property?: string;
};
/**
*
*/
@ValidatorConstraint({ name: 'dataUnique', async: true })
@Injectable()
export class UniqueConstraint implements ValidatorConstraintInterface {
constructor(private dataSource: DataSource) {}
async validate(value: any, args: ValidationArguments) {
// 获取要验证的模型和字段
const config: Omit<Condition, 'entity'> = {
property: args.property,
};
const condition = ('entity' in args.constraints[0]
? merge(config, args.constraints[0])
: {
...config,
entity: args.constraints[0],
}) as unknown as Required<Condition>;
if (!condition.entity) return false;
try {
// 查询是否存在数据,如果已经存在则验证失败
const repo = this.dataSource.getRepository(condition.entity);
return isNil(
await repo.findOne({ where: { [condition.property]: value }, withDeleted: true }),
);
} catch (err) {
// 如果数据库操作异常则验证失败
return false;
}
}
defaultMessage(args: ValidationArguments) {
const { entity, property } = args.constraints[0];
const queryProperty = property ?? args.property;
if (!(args.object as any).getManager) {
return 'getManager function not been found!';
}
if (!entity) {
return 'Model not been specified!';
}
return `${queryProperty} of ${entity.name} must been unique!`;
}
}
/**
*
* @param params Entity类或验证条件对象
* @param validationOptions
*/
export function IsUnique(
params: ObjectType<any> | Condition,
validationOptions?: ValidationOptions,
) {
return (object: Record<string, any>, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [params],
validator: UniqueConstraint,
});
};
}

View File

@ -0,0 +1,93 @@
import { Injectable } from '@nestjs/common';
import {
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
registerDecorator,
} from 'class-validator';
import { isNil, merge } from 'lodash';
import { DataSource, Not, ObjectType } from 'typeorm';
type Condition = {
entity: ObjectType<any>;
/**
* id
*/
ignore?: string;
/**
* 使
*/
property?: string;
};
/**
* ,ignore忽略忽略的字段
*/
@ValidatorConstraint({ name: 'dataUniqueExist', async: true })
@Injectable()
export class UniqueExistContraint implements ValidatorConstraintInterface {
constructor(private dataSource: DataSource) {}
async validate(value: any, args: ValidationArguments) {
const config: Omit<Condition, 'entity'> = {
ignore: 'id',
property: args.property,
};
const condition = ('entity' in args.constraints[0]
? merge(config, args.constraints[0])
: {
...config,
entity: args.constraints[0],
}) as unknown as Required<Condition>;
if (!condition.entity) return false;
// 在传入的dto数据中获取需要忽略的字段的值
const ignoreValue = (args.object as any)[condition.ignore];
// 如果忽略字段不存在则验证失败
if (ignoreValue === undefined) return false;
// 通过entity获取repository
const repo = this.dataSource.getRepository(condition.entity);
// 查询忽略字段之外的数据是否对queryProperty的值唯一
return isNil(
await repo.findOne({
where: {
[condition.property]: value,
[condition.ignore]: Not(ignoreValue),
},
withDeleted: true,
}),
);
}
defaultMessage(args: ValidationArguments) {
const { entity, property } = args.constraints[0];
const queryProperty = property ?? args.property;
if (!(args.object as any).getManager) {
return 'getManager function not been found!';
}
if (!entity) {
return 'Model not been specified!';
}
return `${queryProperty} of ${entity.name} must been unique!`;
}
}
/**
*
* @param params Entity类或验证条件对象
* @param validationOptions
*/
export function IsUniqueExist(
params: ObjectType<any> | Condition,
validationOptions?: ValidationOptions,
) {
return (object: Record<string, any>, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [params],
validator: UniqueExistContraint,
});
};
}

View File

@ -4,6 +4,13 @@ import { TypeOrmModule, TypeOrmModuleOptions, getDataSourceToken } from '@nestjs
import { DataSource, ObjectType } from 'typeorm';
import { CUSTOM_REPOSITORY_METADATA } from '@/modules/database/constants';
import {
DataExistConstraint,
UniqueConstraint,
UniqueExistContraint,
UniqueTreeConstraint,
} from '@/modules/database/constraints';
import { UniqueTreeExistConstraint } from '@/modules/database/constraints/tree.unique.exist.constraint';
@Module({})
export class DatabaseModule {
@ -12,6 +19,13 @@ export class DatabaseModule {
global: true,
module: DatabaseModule,
imports: [TypeOrmModule.forRoot(configRegister())],
providers: [
DataExistConstraint,
UniqueConstraint,
UniqueExistContraint,
UniqueTreeConstraint,
UniqueTreeExistConstraint,
],
};
}

View File

@ -13,34 +13,61 @@ export const paginate = async <E extends ObjectLiteral>(
options: PaginateOptions,
): Promise<PaginateReturn<E>> => {
const limit = isNil(options.limit) || options.limit < 1 ? 1 : options.limit;
const page = isNil(options.page) || options.page < 1 ? 1 : options.page;
const start = page >= 1 ? page - 1 : 0;
const totalItems = await qb.getCount();
qb.take(limit).skip(start * limit);
const items = await qb.getMany();
const totalPages =
totalItems % limit === 0
? Math.floor(totalItems / limit)
: Math.floor(totalItems / limit) + 1;
const remainder = totalItems % limit !== 0 ? totalItems % limit : limit;
const itemCount = page < totalPages ? limit : remainder;
return {
items,
meta: {
totalItems,
totalPages,
itemCount,
perPage: limit,
totalPages,
currentPage: page,
},
};
};
/**
*
* @param options
* @param data
*/
export const treePaginate = <E extends ObjectLiteral>(
options: PaginateOptions,
data: E[],
): PaginateReturn<E> => {
const { page, limit } = options;
let items: E[] = [];
const totalItems = data.length;
const totalRst = totalItems / limit;
const totalPages =
totalRst > Math.floor(totalRst) ? Math.floor(totalRst) + 1 : Math.floor(totalRst);
let itemCount = 0;
if (page <= totalPages) {
itemCount = page === totalPages ? totalItems - (totalPages - 1) * limit : limit;
const start = (page - 1) * limit;
items = data.slice(start, start + itemCount);
}
return {
meta: {
itemCount,
totalItems,
perPage: limit,
totalPages,
currentPage: page,
},
items,
};
};

View File

@ -0,0 +1,17 @@
import { MelliConfig } from '@/modules/meilisearch/types';
export const createMeilliOptions = async (
config: MelliConfig,
): Promise<MelliConfig | undefined> => {
if (config.length <= 0) return config;
let options: MelliConfig = [...config];
const names = options.map(({ name }) => name);
if (!names.includes('default')) options[0].name = 'default';
else if (names.filter((name) => name === 'default').length > 0) {
options = options.reduce(
(o, n) => (o.map(({ name }) => name).includes('default') ? o : [...o, n]),
[],
);
}
return options;
};

View File

@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { isNil } from 'lodash';
import MeiliSearch from 'meilisearch';
import { MelliConfig } from '@/modules/meilisearch/types';
@Injectable()
export class MeilliService {
protected options: MelliConfig;
/**
*
*/
protected clients: Map<string, MeiliSearch> = new Map();
constructor(options: MelliConfig) {
this.options = options;
}
getOptions() {
return this.options;
}
/**
*
*/
async createClients() {
this.options.forEach(async (o) => {
this.clients.set(o.name, new MeiliSearch(o));
});
}
/**
*
* @param name ,default
*/
getClient(name?: string): MeiliSearch {
let key = 'default';
if (!isNil(name)) key = name;
if (!this.clients.has(key)) {
throw new Error(`client ${key} does not exist`);
}
return this.clients.get(key);
}
/**
*
*/
getClients(): Map<string, MeiliSearch> {
return this.clients;
}
}

View File

@ -0,0 +1,28 @@
import { DynamicModule, Module } from '@nestjs/common';
import { createMeilliOptions } from '@/modules/meilisearch/helpers';
import { MeilliService } from '@/modules/meilisearch/meilli.service';
import { MelliConfig } from '@/modules/meilisearch/types';
@Module({})
export class MeilliModule {
static forRoot(configRegister: () => MelliConfig): DynamicModule {
return {
global: true,
module: MeilliModule,
providers: [
{
provide: MeilliService,
useFactory: async () => {
const service = new MeilliService(
await createMeilliOptions(configRegister()),
);
service.createClients();
return service;
},
},
],
exports: [MeilliService],
};
}
}

View File

@ -0,0 +1,7 @@
import { Config } from 'meilisearch';
// MelliSearch模块的配置
export type MelliConfig = MelliOption[];
// MeilliSearch的连接节点配置
export type MelliOption = Config & { name: string };

View File

@ -0,0 +1,18 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional } from 'class-validator';
import { DtoValidation } from '@/modules/core/decorators';
import { toBoolean } from '@/modules/core/helpers';
import { DeleteDto } from '@/modules/restful/dtos/delete.dto';
/**
*
*/
@DtoValidation()
export class DeleteWithTrashDto extends DeleteDto {
@Transform(({ value }) => toBoolean(value))
@IsBoolean()
@IsOptional()
trash?: boolean;
}

View File

@ -0,0 +1,19 @@
import { IsDefined, IsUUID } from 'class-validator';
import { DtoValidation } from '@/modules/core/decorators';
/**
*
*/
@DtoValidation()
export class DeleteDto {
@IsUUID(undefined, {
each: true,
message: 'ID格式错误',
})
@IsDefined({
each: true,
message: 'ID必须指定',
})
ids: string[] = [];
}

View File

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

View File

@ -0,0 +1,19 @@
import { IsDefined, IsUUID } from 'class-validator';
import { DtoValidation } from '@/modules/core/decorators';
/**
*
*/
@DtoValidation()
export class RestoreDto {
@IsUUID(undefined, {
each: true,
message: 'ID格式错误',
})
@IsDefined({
each: true,
message: 'ID必须指定',
})
ids: string[] = [];
}

16
vercel.json Normal file
View File

@ -0,0 +1,16 @@
{
"version": 2,
"name": "ink-nestjs-api",
"builds": [
{
"src": "dist/main.js",
"use": "@vercel/node"
}
],
"routes": [
{
"src": "/(.*)",
"dest": "dist/main.js"
}
]
}