Compare commits
10 Commits
fe94150b2b
...
b37dfa8103
Author | SHA1 | Date | |
---|---|---|---|
b37dfa8103 | |||
431246bc23 | |||
5bcb4853e5 | |||
c151657116 | |||
d30273d180 | |||
dbc14c2137 | |||
f4b38483d6 | |||
781962dce0 | |||
cce9a6b179 | |||
8a5aaa5fd8 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -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
18
.vscode/launch.json
vendored
Normal 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.
41
package.json
41
package.json
@ -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": [
|
||||
|
1242
pnpm-lock.yaml
1242
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -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 {}
|
||||
|
5
src/config/content.config.ts
Normal file
5
src/config/content.config.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { ContentConfig } from '@/modules/content/types';
|
||||
|
||||
export const content = (): ContentConfig => ({
|
||||
searchType: 'meilli',
|
||||
});
|
@ -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,
|
||||
});
|
||||
|
@ -1 +1,3 @@
|
||||
export * from './content.config';
|
||||
export * from './database.config';
|
||||
export * from './meilli.config';
|
||||
|
9
src/config/meilli.config.ts
Normal file
9
src/config/meilli.config.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { MelliConfig } from '@/modules/meilisearch/types';
|
||||
|
||||
export const meilli = (): MelliConfig => [
|
||||
{
|
||||
name: 'default',
|
||||
host: 'http://localhost:7700',
|
||||
apiKey: '12345678910',
|
||||
},
|
||||
];
|
11
src/main.ts
11
src/main.ts
@ -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');
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -13,5 +13,6 @@ export enum PostOrderType {
|
||||
CREATED = 'createdAt',
|
||||
UPDATED = 'updatedAt',
|
||||
PUBLISHED = 'publishedAt',
|
||||
COMMENTCOUNT = 'commentCount',
|
||||
CUSTOM = 'custom',
|
||||
}
|
||||
|
@ -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,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
81
src/modules/content/controllers/category.controller.ts
Normal file
81
src/modules/content/controllers/category.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
44
src/modules/content/controllers/comment.controller.ts
Normal file
44
src/modules/content/controllers/comment.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -1 +1,4 @@
|
||||
export * from './category.controller';
|
||||
export * from './comment.controller';
|
||||
export * from './post.controller';
|
||||
export * from './tag.controller';
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
73
src/modules/content/controllers/tag.controller.ts
Normal file
73
src/modules/content/controllers/tag.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
91
src/modules/content/dtos/category.dto.ts
Normal file
91
src/modules/content/dtos/category.dto.ts
Normal 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;
|
||||
}
|
69
src/modules/content/dtos/comment.dto.ts
Normal file
69
src/modules/content/dtos/comment.dto.ts
Normal 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;
|
||||
}
|
@ -1 +1,4 @@
|
||||
export * from './category.dto';
|
||||
export * from './comment.dto';
|
||||
export * from './post.dto';
|
||||
export * from './tag.dto';
|
||||
|
@ -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必须指定' })
|
||||
|
70
src/modules/content/dtos/tag.dto.ts
Normal file
70
src/modules/content/dtos/tag.dto.ts
Normal 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;
|
||||
}
|
57
src/modules/content/entities/category.entity.ts
Normal file
57
src/modules/content/entities/category.entity.ts
Normal 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[]>;
|
||||
}
|
55
src/modules/content/entities/comment.entity.ts
Normal file
55
src/modules/content/entities/comment.entity.ts
Normal 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[]>;
|
||||
}
|
@ -1 +1,4 @@
|
||||
export * from './category.entity';
|
||||
export * from './comment.entity';
|
||||
export * from './post.entity';
|
||||
export * from './tag.entity';
|
||||
|
@ -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>[];
|
||||
}
|
||||
|
45
src/modules/content/entities/tag.entity.ts
Normal file
45
src/modules/content/entities/tag.entity.ts
Normal 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[]>;
|
||||
}
|
165
src/modules/content/repositories/category.repository.ts
Normal file
165
src/modules/content/repositories/category.repository.ts
Normal 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();
|
||||
}
|
||||
}
|
137
src/modules/content/repositories/comment.repository.ts
Normal file
137
src/modules/content/repositories/comment.repository.ts
Normal 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[];
|
||||
}
|
||||
}
|
@ -1 +1,4 @@
|
||||
export * from './category.repository';
|
||||
export * from './comment.repository';
|
||||
export * from './post.repository';
|
||||
export * from './tag.repository';
|
||||
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
18
src/modules/content/repositories/tag.repository.ts
Normal file
18
src/modules/content/repositories/tag.repository.ts
Normal 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');
|
||||
}
|
||||
}
|
171
src/modules/content/services/category.service.ts
Normal file
171
src/modules/content/services/category.service.ts
Normal 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();
|
||||
}
|
||||
}
|
123
src/modules/content/services/comment.service.ts
Normal file
123
src/modules/content/services/comment.service.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
136
src/modules/content/services/search.service.ts
Normal file
136
src/modules/content/services/search.service.ts
Normal 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);
|
||||
}
|
||||
}
|
126
src/modules/content/services/tag.service.ts
Normal file
126
src/modules/content/services/tag.service.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
5
src/modules/content/types.ts
Normal file
5
src/modules/content/types.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export type SearchType = 'like' | 'against' | 'meilli';
|
||||
|
||||
export interface ContentConfig {
|
||||
searchType?: SearchType;
|
||||
}
|
4
src/modules/core/constants.ts
Normal file
4
src/modules/core/constants.ts
Normal file
@ -0,0 +1,4 @@
|
||||
/**
|
||||
* DTOValidation 装饰器选项
|
||||
*/
|
||||
export const DTO_VALIDATION_OPTIONS = 'dto_validation_options';
|
3
src/modules/core/constraints/index.ts
Normal file
3
src/modules/core/constraints/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './match.constraint';
|
||||
export * from './match.phone.constraint';
|
||||
export * from './password.constraint';
|
41
src/modules/core/constraints/match.constraint.ts
Normal file
41
src/modules/core/constraints/match.constraint.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
48
src/modules/core/constraints/match.phone.constraint.ts
Normal file
48
src/modules/core/constraints/match.phone.constraint.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
64
src/modules/core/constraints/password.constraint.ts
Normal file
64
src/modules/core/constraints/password.constraint.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
15
src/modules/core/decorators/dto-validation.decorator.ts
Normal file
15
src/modules/core/decorators/dto-validation.decorator.ts
Normal 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 ?? {});
|
1
src/modules/core/decorators/index.ts
Normal file
1
src/modules/core/decorators/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './dto-validation.decorator';
|
52
src/modules/core/providers/app.filter.ts
Normal file
52
src/modules/core/providers/app.filter.ts
Normal 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);
|
||||
}
|
||||
}
|
84
src/modules/core/providers/app.pipe.ts
Normal file
84
src/modules/core/providers/app.pipe.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -1 +1,3 @@
|
||||
export * from './app.filter';
|
||||
export * from './app.interceptor';
|
||||
export * from './app.pipe';
|
||||
|
@ -2,3 +2,12 @@
|
||||
* 自定义 Repository 元数据
|
||||
*/
|
||||
export const CUSTOM_REPOSITORY_METADATA = 'CUSTOM_REPOSITORY_METADATA';
|
||||
|
||||
/**
|
||||
* 软删除数据查询类型
|
||||
*/
|
||||
export enum SelectTrashMode {
|
||||
ALL = 'all',
|
||||
ONLY = 'only',
|
||||
NONE = 'none',
|
||||
}
|
||||
|
94
src/modules/database/constraints/data.exist.constraint.ts
Normal file
94
src/modules/database/constraints/data.exist.constraint.ts
Normal 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 };
|
5
src/modules/database/constraints/index.ts
Normal file
5
src/modules/database/constraints/index.ts
Normal 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';
|
90
src/modules/database/constraints/tree.unique.constraint.ts
Normal file
90
src/modules/database/constraints/tree.unique.constraint.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
106
src/modules/database/constraints/tree.unique.exist.constraint.ts
Normal file
106
src/modules/database/constraints/tree.unique.exist.constraint.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
83
src/modules/database/constraints/unique.constraint.ts
Normal file
83
src/modules/database/constraints/unique.constraint.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
93
src/modules/database/constraints/unique.exist.constraint.ts
Normal file
93
src/modules/database/constraints/unique.exist.constraint.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
@ -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,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
17
src/modules/meilisearch/helpers.ts
Normal file
17
src/modules/meilisearch/helpers.ts
Normal 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;
|
||||
};
|
52
src/modules/meilisearch/meilli.service.ts
Normal file
52
src/modules/meilisearch/meilli.service.ts
Normal 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;
|
||||
}
|
||||
}
|
28
src/modules/meilisearch/melli.module.ts
Normal file
28
src/modules/meilisearch/melli.module.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
}
|
7
src/modules/meilisearch/types.ts
Normal file
7
src/modules/meilisearch/types.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Config } from 'meilisearch';
|
||||
|
||||
// MelliSearch模块的配置
|
||||
export type MelliConfig = MelliOption[];
|
||||
|
||||
// MeilliSearch的连接节点配置
|
||||
export type MelliOption = Config & { name: string };
|
18
src/modules/restful/dtos/delete-with-trash.dto.ts
Normal file
18
src/modules/restful/dtos/delete-with-trash.dto.ts
Normal 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;
|
||||
}
|
19
src/modules/restful/dtos/delete.dto.ts
Normal file
19
src/modules/restful/dtos/delete.dto.ts
Normal 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[] = [];
|
||||
}
|
3
src/modules/restful/dtos/index.ts
Normal file
3
src/modules/restful/dtos/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './delete-with-trash.dto';
|
||||
export * from './delete.dto';
|
||||
export * from './restore.dto';
|
19
src/modules/restful/dtos/restore.dto.ts
Normal file
19
src/modules/restful/dtos/restore.dto.ts
Normal 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
16
vercel.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue
Block a user