Compare commits
11
Commits
fe94150b2b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ec73cc0e7 | ||
|
|
b37dfa8103 | ||
|
|
431246bc23 | ||
|
|
5bcb4853e5 | ||
|
|
c151657116 | ||
|
|
d30273d180 | ||
|
|
dbc14c2137 | ||
|
|
f4b38483d6 | ||
|
|
781962dce0 | ||
|
|
cce9a6b179 | ||
|
|
8a5aaa5fd8 |
+2
-1
@@ -32,4 +32,5 @@ lerna-debug.log*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/extensions.json
|
||||
.vercel
|
||||
|
||||
Vendored
+18
@@ -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.
+22
-19
@@ -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": [
|
||||
|
||||
Generated
+601
-641
File diff suppressed because it is too large
Load Diff
+32
-3
@@ -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 {}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { MelliConfig } from '@/modules/meilisearch/types';
|
||||
|
||||
export const meilli = (): MelliConfig => [
|
||||
{
|
||||
name: 'default',
|
||||
host: 'http://localhost:7700',
|
||||
apiKey: '12345678910',
|
||||
},
|
||||
];
|
||||
+9
-2
@@ -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,76 @@
|
||||
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,
|
||||
repositories.CommentRepository,
|
||||
{ token: services.SearchService, optional: true },
|
||||
],
|
||||
useFactory(
|
||||
postRepository: repositories.PostRepository,
|
||||
categoryRepository: repositories.CategoryRepository,
|
||||
categoryService: services.CategoryService,
|
||||
tagRepository: repositories.TagRepository,
|
||||
commentRepository: repositories.CommentRepository,
|
||||
searchService: services.SearchService,
|
||||
) {
|
||||
return new PostService(
|
||||
postRepository,
|
||||
categoryRepository,
|
||||
categoryService,
|
||||
tagRepository,
|
||||
commentRepository,
|
||||
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,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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必须指定' })
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[]>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Exclude, Expose, Type } from 'class-transformer';
|
||||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
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: 'CASCADE' })
|
||||
parent: Relation<CommentEntity> | null;
|
||||
|
||||
@Expose({ groups: ['comment-tree'] })
|
||||
@TreeChildren({ cascade: true })
|
||||
children: Relation<CommentEntity[]>;
|
||||
|
||||
@Expose()
|
||||
@Type(() => Date)
|
||||
@DeleteDateColumn({
|
||||
comment: '删除时间',
|
||||
})
|
||||
deletedAt: Date;
|
||||
}
|
||||
@@ -1 +1,4 @@
|
||||
export * from './category.entity';
|
||||
export * from './comment.entity';
|
||||
export * from './post.entity';
|
||||
export * from './tag.entity';
|
||||
|
||||
@@ -1,55 +1,113 @@
|
||||
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)
|
||||
@JoinTable()
|
||||
tags: Relation<TagEntity>[];
|
||||
|
||||
@OneToMany(() => CommentEntity, (comment) => comment.post, {
|
||||
cascade: true,
|
||||
})
|
||||
comments: Relation<CommentEntity>[];
|
||||
}
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { CategoryEntity } from '@/modules/content/entities';
|
||||
import { BaseTreeRepository } from '@/modules/database/base';
|
||||
import { OrderType, TreeChildrenResolve } from '@/modules/database/constants';
|
||||
import { CustomRepository } from '@/modules/database/decorators';
|
||||
|
||||
@CustomRepository(CategoryEntity)
|
||||
export class CategoryRepository extends BaseTreeRepository<CategoryEntity> {
|
||||
protected _qbName = 'category';
|
||||
|
||||
protected orderBy = { name: 'customOrder', order: OrderType.ASC };
|
||||
|
||||
protected _childrenResolve = TreeChildrenResolve.UP;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { isNil } from 'lodash';
|
||||
import { FindTreeOptions, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { CommentEntity } from '@/modules/content/entities';
|
||||
import { BaseTreeRepository } from '@/modules/database/base';
|
||||
import { CustomRepository } from '@/modules/database/decorators';
|
||||
import { QueryParams } from '@/modules/database/types';
|
||||
|
||||
@CustomRepository(CommentEntity)
|
||||
export class CommentRepository extends BaseTreeRepository<CommentEntity> {
|
||||
protected _qbName = 'comment';
|
||||
|
||||
protected orderBy = 'createdAt';
|
||||
|
||||
buildBaseQB(qb: SelectQueryBuilder<CommentEntity>): SelectQueryBuilder<CommentEntity> {
|
||||
return super.buildBaseQB(qb).leftJoinAndSelect(`${this.qbName}.post`, 'post');
|
||||
}
|
||||
|
||||
async findTrees(
|
||||
options: FindTreeOptions & QueryParams<CommentEntity> & { post?: string } = {},
|
||||
): Promise<CommentEntity[]> {
|
||||
return super.findTrees({
|
||||
...options,
|
||||
addQuery: async (qb) => {
|
||||
return isNil(options.post) ? qb : qb.where('post.id = :id', { id: options.post });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1 +1,4 @@
|
||||
export * from './category.repository';
|
||||
export * from './comment.repository';
|
||||
export * from './post.repository';
|
||||
export * from './tag.repository';
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { PostEntity } from '@/modules/content/entities';
|
||||
import { CommentEntity, PostEntity } from '@/modules/content/entities';
|
||||
import { BaseRepository } from '@/modules/database/base';
|
||||
import { CustomRepository } from '@/modules/database/decorators';
|
||||
|
||||
@CustomRepository(PostEntity)
|
||||
export class PostRepository extends Repository<PostEntity> {
|
||||
export class PostRepository extends BaseRepository<PostEntity> {
|
||||
protected _qbName = 'post';
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { PostEntity, TagEntity } from '@/modules/content/entities';
|
||||
import { BaseRepository } from '@/modules/database/base';
|
||||
import { CustomRepository } from '@/modules/database/decorators';
|
||||
|
||||
@CustomRepository(TagEntity)
|
||||
export class TagRepository extends BaseRepository<TagEntity> {
|
||||
protected _qbName = 'tag';
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNil, omit } from 'lodash';
|
||||
import { EntityNotFoundError } from 'typeorm';
|
||||
|
||||
import {
|
||||
CreateCategoryDto,
|
||||
QueryCategoryDto,
|
||||
QueryCategoryTreeDto,
|
||||
UpdateCategoryDto,
|
||||
} from '@/modules/content/dtos';
|
||||
import { CategoryEntity } from '@/modules/content/entities';
|
||||
import { CategoryRepository } from '@/modules/content/repositories';
|
||||
import { BaseService } from '@/modules/database/base';
|
||||
import { SelectTrashMode } from '@/modules/database/constants';
|
||||
import { treePaginate } from '@/modules/database/helpers';
|
||||
|
||||
/**
|
||||
* 分类数据操作
|
||||
*/
|
||||
@Injectable()
|
||||
export class CategoryService extends BaseService<CategoryEntity, CategoryRepository> {
|
||||
protected enableTrash = true;
|
||||
|
||||
constructor(protected repository: CategoryRepository) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询分类树
|
||||
*/
|
||||
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']));
|
||||
await this.detail(data.id);
|
||||
const item = await this.repository.findOneOrFail({
|
||||
where: { id: data.id },
|
||||
relations: ['parent'],
|
||||
});
|
||||
const parent = await this.getParent(item.parent?.id, data.parent);
|
||||
const shouldUpdateParent =
|
||||
(!isNil(item.parent) && !isNil(parent) && item.parent.id !== parent.id) ||
|
||||
(isNil(item.parent) && !isNil(parent)) ||
|
||||
(!isNil(item.parent) && isNil(parent));
|
||||
// 父分类单独更新
|
||||
if (parent !== undefined && shouldUpdateParent) {
|
||||
item.parent = parent;
|
||||
await this.repository.save(item, { reload: true });
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取请求传入的父分类
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNil } from 'lodash';
|
||||
|
||||
import { EntityNotFoundError, 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 { BaseService } from '@/modules/database/base';
|
||||
|
||||
/**
|
||||
* 评论数据操作
|
||||
*/
|
||||
@Injectable()
|
||||
export class CommentService extends BaseService<CommentEntity, CommentRepository> {
|
||||
constructor(
|
||||
protected repository: CommentRepository,
|
||||
protected postRepository: PostRepository,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接查询评论树
|
||||
* @param options
|
||||
*/
|
||||
async findTrees(options: QueryCommentTreeDto = {}) {
|
||||
return this.repository.findTrees({
|
||||
addQuery: async (qb) => {
|
||||
return isNil(options.post) ? qb : qb.where('post.id = :id', { id: options.post });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找一篇文章的评论并分页
|
||||
* @param dto
|
||||
*/
|
||||
async paginate(options: QueryCommentDto) {
|
||||
const { post } = options;
|
||||
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;
|
||||
};
|
||||
return super.paginate({
|
||||
...options,
|
||||
addQuery,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增评论
|
||||
* @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 id
|
||||
*/
|
||||
protected async getPost(id: string) {
|
||||
return !isNil(id) ? this.postRepository.findOneOrFail({ where: { id } }) : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取请求传入的父分类
|
||||
* @param current 当前分类的ID
|
||||
* @param id
|
||||
*/
|
||||
protected async getParent(current?: string, id?: string) {
|
||||
if (current === id) return undefined;
|
||||
let parent: CommentEntity | undefined;
|
||||
if (id !== undefined) {
|
||||
if (id === null) return null;
|
||||
parent = await this.repository.findOne({
|
||||
relations: ['parent', 'post'],
|
||||
where: { id },
|
||||
});
|
||||
if (!parent) {
|
||||
throw new EntityNotFoundError(CommentEntity, `Parent comment ${id} 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,62 @@
|
||||
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,
|
||||
CommentRepository,
|
||||
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 { BaseService } from '@/modules/database/base';
|
||||
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) {}
|
||||
export class PostService extends BaseService<PostEntity, PostRepository, FindParams> {
|
||||
protected enableTrash = true;
|
||||
|
||||
constructor(
|
||||
protected repository: PostRepository,
|
||||
protected categoryRepository: CategoryRepository,
|
||||
protected categoryService: CategoryService,
|
||||
protected tagRepository: TagRepository,
|
||||
protected commentRepository: CommentRepository,
|
||||
protected searchService?: SearchService,
|
||||
protected search_type: SearchType = 'against',
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分页数据
|
||||
* @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']),
|
||||
) as any;
|
||||
}
|
||||
const qb = await this.buildListQuery(this.repository.buildBaseQB(), options, callback);
|
||||
return paginate(qb, options);
|
||||
}
|
||||
@@ -45,8 +80,21 @@ 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 +103,88 @@ 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;
|
||||
await this.repository.save(post);
|
||||
}
|
||||
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,
|
||||
relations: ['category', 'comments', 'tags'],
|
||||
});
|
||||
let result: PostEntity[] = [];
|
||||
if (trash) {
|
||||
// 对已软删除的数据再次删除时直接通过remove方法从数据库中清除
|
||||
const directs = items.filter((item) => !isNil(item.deletedAt));
|
||||
// 需要记录直接删除的ids,因为remove(directs)会使directs的id为undefined,再次删除meili数据会有问题!!!
|
||||
const directIds = directs.map(({ id }) => id);
|
||||
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(directIds);
|
||||
await this.searchService.update(softs);
|
||||
}
|
||||
} else {
|
||||
result = await this.repository.remove(items);
|
||||
if (!isNil(this.searchService)) {
|
||||
await this.searchService.delete(ids);
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
for (const post of trasheds) {
|
||||
await this.commentRepository.restore({
|
||||
post: { id: post },
|
||||
deletedAt: Not(IsNull()),
|
||||
});
|
||||
}
|
||||
|
||||
const qb = await this.buildListQuery(this.repository.buildBaseQB(), {}, async (qbuilder) =>
|
||||
qbuilder.andWhereInIds(trasheds),
|
||||
);
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,23 +195,63 @@ 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 +267,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
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,
|
||||
) {
|
||||
let categories: { id: string; name: string }[] = [];
|
||||
if (post.category) {
|
||||
categories = [
|
||||
...(await catRepo.findAncestors(post.category)).map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
})),
|
||||
{ id: post.category.id, name: post.category.name },
|
||||
];
|
||||
}
|
||||
|
||||
let comments: { id: string; body: string }[] = [];
|
||||
|
||||
comments = (
|
||||
await cmtRepo.find({
|
||||
relations: ['post'],
|
||||
where: { post: { id: post.id } },
|
||||
})
|
||||
).map((item) => ({ id: item.id, body: item.body }));
|
||||
|
||||
let tags: { id: string; name: string }[] = [];
|
||||
if (post.tags) {
|
||||
tags = post.tags.map((item) => ({ id: item.id, name: item.name }));
|
||||
}
|
||||
|
||||
return {
|
||||
...pick(instanceToPlain(post), [
|
||||
'id',
|
||||
'title',
|
||||
'body',
|
||||
'summary',
|
||||
'commentCount',
|
||||
'deletedAt',
|
||||
'publishedAt',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
]),
|
||||
body: post.body,
|
||||
categories,
|
||||
tags,
|
||||
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[]) {
|
||||
const payload = await Promise.all(
|
||||
posts?.map((post) =>
|
||||
getPostData(this.categoryRepository, this.commentRepository, post),
|
||||
),
|
||||
);
|
||||
return this.client.index(this.index).updateDocuments(payload);
|
||||
}
|
||||
|
||||
async delete(ids: string[]) {
|
||||
return this.client.index(this.index).deleteDocuments(ids);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { omit } from 'lodash';
|
||||
|
||||
import { CreateTagDto, UpdateTagDto } from '@/modules/content/dtos';
|
||||
import { TagEntity } from '@/modules/content/entities';
|
||||
import { TagRepository } from '@/modules/content/repositories';
|
||||
import { BaseService } from '@/modules/database/base';
|
||||
|
||||
/**
|
||||
* 标签数据操作
|
||||
*/
|
||||
@Injectable()
|
||||
export class TagService extends BaseService<TagEntity, TagRepository> {
|
||||
protected enableTrash = true;
|
||||
|
||||
constructor(protected repository: TagRepository) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建标签
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
import { Optional } from '@nestjs/common';
|
||||
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';
|
||||
import { BaseSubscriber } from '@/modules/database/base';
|
||||
|
||||
@EventSubscriber()
|
||||
export class PostSubscriber {
|
||||
export class PostSubscriber extends BaseSubscriber<PostEntity> {
|
||||
protected entity = PostEntity;
|
||||
|
||||
constructor(
|
||||
protected dataSource: DataSource,
|
||||
protected sanitizeService: SanitizeService,
|
||||
protected postRepository: PostRepository,
|
||||
) {}
|
||||
@Optional() protected sanitizeService?: SanitizeService,
|
||||
) {
|
||||
super(dataSource);
|
||||
}
|
||||
|
||||
listenTo() {
|
||||
return PostEntity;
|
||||
@@ -19,6 +25,7 @@ export class PostSubscriber {
|
||||
|
||||
/**
|
||||
* 加载文章数据的处理
|
||||
* @param entity
|
||||
*/
|
||||
async afterLoad(entity: PostEntity) {
|
||||
if (entity.type === PostBodyType.HTML) {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export type SearchType = 'like' | 'against' | 'meilli';
|
||||
|
||||
export interface ContentConfig {
|
||||
searchType?: SearchType;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* DTOValidation 装饰器选项
|
||||
*/
|
||||
export const DTO_VALIDATION_OPTIONS = 'dto_validation_options';
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './match.constraint';
|
||||
export * from './match.phone.constraint';
|
||||
export * from './password.constraint';
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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 ?? {});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dto-validation.decorator';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './repository';
|
||||
export * from './service';
|
||||
export * from './subcriber';
|
||||
export * from './tree.repository';
|
||||
@@ -0,0 +1,46 @@
|
||||
import { isNil } from 'lodash';
|
||||
|
||||
import { ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { OrderType } from '@/modules/database/constants';
|
||||
import { getOrderByQuery } from '@/modules/database/helpers';
|
||||
import { OrderQueryType } from '@/modules/database/types';
|
||||
|
||||
/**
|
||||
* 基础存储类
|
||||
*/
|
||||
export abstract class BaseRepository<E extends ObjectLiteral> extends Repository<E> {
|
||||
/**
|
||||
* 构建查询时默认的模型对应的查询名称
|
||||
*/
|
||||
protected abstract _qbName: string;
|
||||
|
||||
/**
|
||||
* 返回查询器名称
|
||||
*/
|
||||
get qbName() {
|
||||
return this._qbName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建基础查询器
|
||||
*/
|
||||
buildBaseQB(): SelectQueryBuilder<E> {
|
||||
return this.createQueryBuilder(this.qbName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认排序规则,可以通过每个方法的orderBy选项进行覆盖
|
||||
*/
|
||||
protected orderBy?: string | { name: string; order: `${OrderType}` };
|
||||
|
||||
/**
|
||||
* 生成排序的QueryBuilder
|
||||
* @param qb
|
||||
* @param orderBy
|
||||
*/
|
||||
addOrderByQuery(qb: SelectQueryBuilder<E>, orderBy?: OrderQueryType) {
|
||||
const orderByQuery = orderBy ?? this.orderBy;
|
||||
return !isNil(orderByQuery) ? getOrderByQuery(qb, this.qbName, orderByQuery) : qb;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { isNil } from 'lodash';
|
||||
import { In, ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { SelectTrashMode, TreeChildrenResolve } from '../constants';
|
||||
import { paginate, treePaginate } from '../helpers';
|
||||
import { PaginateOptions, PaginateReturn, QueryHook, ServiceListQueryOption } from '../types';
|
||||
|
||||
import { BaseRepository } from './repository';
|
||||
import { BaseTreeRepository } from './tree.repository';
|
||||
/**
|
||||
* CRUD操作服务
|
||||
*/
|
||||
export abstract class BaseService<
|
||||
E extends ObjectLiteral,
|
||||
R extends BaseRepository<E> | BaseTreeRepository<E>,
|
||||
P extends ServiceListQueryOption<E> = ServiceListQueryOption<E>,
|
||||
> {
|
||||
/**
|
||||
* 服务默认存储类
|
||||
*/
|
||||
protected repository: R;
|
||||
|
||||
/**
|
||||
* 是否开启软删除功能
|
||||
*/
|
||||
protected enableTrash = true;
|
||||
|
||||
constructor(repository: R) {
|
||||
this.repository = repository;
|
||||
if (
|
||||
!(
|
||||
this.repository instanceof BaseRepository ||
|
||||
this.repository instanceof BaseTreeRepository
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'Repository must instance of BaseRepository or BaseTreeRepository in DataService!',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据列表
|
||||
* @param params 查询参数
|
||||
* @param callback 回调查询
|
||||
*/
|
||||
async list(options?: P, callback?: QueryHook<E>): Promise<E[]> {
|
||||
const { trashed: isTrashed = false } = options ?? {};
|
||||
const trashed = isTrashed || SelectTrashMode.NONE;
|
||||
if (this.repository instanceof BaseTreeRepository) {
|
||||
const withTrashed =
|
||||
this.enableTrash &&
|
||||
(trashed === SelectTrashMode.ALL || trashed === SelectTrashMode.ONLY);
|
||||
const onlyTrashed = this.enableTrash && trashed === SelectTrashMode.ONLY;
|
||||
const tree = await this.repository.findTrees({
|
||||
...options,
|
||||
withTrashed,
|
||||
onlyTrashed,
|
||||
});
|
||||
return this.repository.toFlatTrees(tree);
|
||||
}
|
||||
const qb = await this.buildListQB(this.repository.buildBaseQB(), options, callback);
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分页数据
|
||||
* @param options 分页选项
|
||||
* @param callback 回调查询
|
||||
*/
|
||||
async paginate(
|
||||
options?: PaginateOptions & P,
|
||||
callback?: QueryHook<E>,
|
||||
): Promise<PaginateReturn<E>> {
|
||||
const queryOptions = (options ?? {}) as P;
|
||||
if (this.repository instanceof BaseTreeRepository) {
|
||||
const data = await this.list(queryOptions, callback);
|
||||
return treePaginate(options, data) as PaginateReturn<E>;
|
||||
}
|
||||
const qb = await this.buildListQB(this.repository.buildBaseQB(), queryOptions, callback);
|
||||
return paginate(qb, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据详情
|
||||
* @param id
|
||||
* @param trashed 查询时是否包含已软删除的数据
|
||||
* @param callback 回调查询
|
||||
*/
|
||||
async detail(id: string, callback?: QueryHook<E>): Promise<E> {
|
||||
const qb = await this.buildItemQB(id, this.repository.buildBaseQB(), callback);
|
||||
const item = await qb.getOne();
|
||||
if (!item) throw new NotFoundException(`${this.repository.qbName} ${id} not exists!`);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建数据,如果子类没有实现则抛出404
|
||||
* @param data 请求数据
|
||||
* @param others 其它参数
|
||||
*/
|
||||
create(data: any, ...others: any[]): Promise<E> {
|
||||
throw new ForbiddenException(`Can not to create ${this.repository.qbName}!`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新数据,如果子类没有实现则抛出404
|
||||
* @param data 请求数据
|
||||
* @param others 其它参数
|
||||
*/
|
||||
update(data: any, ...others: any[]): Promise<E> {
|
||||
throw new ForbiddenException(`Can not to update ${this.repository.qbName}!`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除数据
|
||||
* @param data 需要删除的id列表
|
||||
* @param trash 是否只扔到回收站,如果为true则软删除
|
||||
*/
|
||||
async delete(ids: string[], trash?: boolean) {
|
||||
let items: E[] = [];
|
||||
if (this.repository instanceof BaseTreeRepository) {
|
||||
items = await this.repository.find({
|
||||
where: { id: In(ids) as any },
|
||||
withDeleted: this.enableTrash ? true : undefined,
|
||||
relations: ['parent', 'children'],
|
||||
});
|
||||
if (this.repository.childrenResolve === TreeChildrenResolve.UP) {
|
||||
for (const item of items) {
|
||||
if (isNil(item.children) || item.children.length <= 0) continue;
|
||||
const nchildren = [...item.children].map((c) => {
|
||||
c.parent = item.parent;
|
||||
return item;
|
||||
});
|
||||
await this.repository.save(nchildren);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items = await this.repository.find({
|
||||
where: { id: In(ids) as any },
|
||||
withDeleted: this.enableTrash ? true : undefined,
|
||||
});
|
||||
}
|
||||
if (this.enableTrash && trash) {
|
||||
const directs = items.filter((item) => !isNil(item.deletedAt));
|
||||
const softs = items.filter((item) => isNil(item.deletedAt));
|
||||
return [
|
||||
...(await this.repository.remove(directs)),
|
||||
...(await this.repository.softRemove(softs)),
|
||||
];
|
||||
}
|
||||
return this.repository.remove(items);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量恢复回收站中的数据
|
||||
* @param data 需要恢复的id列表
|
||||
*/
|
||||
async restore(ids: string[]) {
|
||||
if (!this.enableTrash) {
|
||||
throw new ForbiddenException(
|
||||
`Can not to retore ${this.repository.qbName},because trash not enabled!`,
|
||||
);
|
||||
}
|
||||
const items = await this.repository.find({
|
||||
where: { id: In(ids) as any },
|
||||
withDeleted: true,
|
||||
});
|
||||
const trasheds = items.filter((item) => !isNil(item));
|
||||
if (trasheds.length < 0) return [];
|
||||
await this.repository.restore(trasheds.map((item) => item.id));
|
||||
const qb = await this.buildListQB(
|
||||
this.repository.buildBaseQB(),
|
||||
undefined,
|
||||
async (builder) => builder.andWhereInIds(trasheds),
|
||||
);
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取查询单个项目的QueryBuilder
|
||||
* @param id 查询数据的ID
|
||||
* @param qb querybuilder实例
|
||||
* @param callback 查询回调
|
||||
*/
|
||||
protected async buildItemQB(id: string, qb: SelectQueryBuilder<E>, callback?: QueryHook<E>) {
|
||||
qb.where(`${this.repository.qbName}.id = :id`, { id });
|
||||
if (callback) return callback(qb);
|
||||
return qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取查询数据列表的 QueryBuilder
|
||||
* @param qb querybuilder实例
|
||||
* @param options 查询选项
|
||||
* @param callback 查询回调
|
||||
*/
|
||||
protected async buildListQB(qb: SelectQueryBuilder<E>, options?: P, callback?: QueryHook<E>) {
|
||||
const { trashed } = options ?? {};
|
||||
const queryName = this.repository.qbName;
|
||||
// 是否查询回收站
|
||||
if (
|
||||
this.enableTrash &&
|
||||
(trashed === SelectTrashMode.ALL || trashed === SelectTrashMode.ONLY)
|
||||
) {
|
||||
qb.withDeleted();
|
||||
if (trashed === SelectTrashMode.ONLY) {
|
||||
qb.where(`${queryName}.deletedAt is not null`);
|
||||
}
|
||||
}
|
||||
if (callback) return callback(qb);
|
||||
return qb;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Optional } from '@nestjs/common';
|
||||
import { isNil } from 'lodash';
|
||||
import {
|
||||
DataSource,
|
||||
EntitySubscriberInterface,
|
||||
EntityTarget,
|
||||
EventSubscriber,
|
||||
InsertEvent,
|
||||
ObjectLiteral,
|
||||
ObjectType,
|
||||
RecoverEvent,
|
||||
RemoveEvent,
|
||||
SoftRemoveEvent,
|
||||
TransactionCommitEvent,
|
||||
TransactionRollbackEvent,
|
||||
TransactionStartEvent,
|
||||
UpdateEvent,
|
||||
} from 'typeorm';
|
||||
|
||||
import { getCustomRepository } from '@/modules/database/helpers';
|
||||
import { RepositoryType } from '@/modules/database/types';
|
||||
|
||||
/**
|
||||
* 基础模型观察者
|
||||
*/
|
||||
type SubscriberEvent<E extends ObjectLiteral> =
|
||||
| InsertEvent<E>
|
||||
| UpdateEvent<E>
|
||||
| SoftRemoveEvent<E>
|
||||
| RemoveEvent<E>
|
||||
| RecoverEvent<E>
|
||||
| TransactionStartEvent
|
||||
| TransactionCommitEvent
|
||||
| TransactionRollbackEvent;
|
||||
|
||||
/**
|
||||
* 基础模型观察者
|
||||
*/
|
||||
@EventSubscriber()
|
||||
export abstract class BaseSubscriber<E extends ObjectLiteral>
|
||||
implements EntitySubscriberInterface<E>
|
||||
{
|
||||
/**
|
||||
* 监听的模型
|
||||
*/
|
||||
protected abstract entity: ObjectType<E>;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param dataSource 数据连接池
|
||||
*/
|
||||
constructor(@Optional() protected dataSource?: DataSource) {
|
||||
if (!isNil(this.dataSource)) this.dataSource.subscribers.push(this);
|
||||
}
|
||||
|
||||
protected getDataSource(event: SubscriberEvent<E>) {
|
||||
return this.dataSource ?? event.connection;
|
||||
}
|
||||
|
||||
protected getManage(event: SubscriberEvent<E>) {
|
||||
return this.dataSource ? this.dataSource.manager : event.manager;
|
||||
}
|
||||
|
||||
listenTo() {
|
||||
return this.entity;
|
||||
}
|
||||
|
||||
async afterLoad(entity: any) {
|
||||
// 是否启用树形
|
||||
if ('parent' in entity && isNil(entity.depth)) entity.depth = 0;
|
||||
}
|
||||
|
||||
protected getRepositoy<
|
||||
C extends ClassType<T>,
|
||||
T extends RepositoryType<E>,
|
||||
A extends EntityTarget<ObjectLiteral>,
|
||||
>(event: SubscriberEvent<E>, repository?: C, entity?: A) {
|
||||
return isNil(repository)
|
||||
? this.getDataSource(event).getRepository(entity ?? this.entity)
|
||||
: getCustomRepository<T, E>(this.getDataSource(event), repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某个字段是否被更新
|
||||
* @param cloumn
|
||||
* @param event
|
||||
*/
|
||||
protected isUpdated(cloumn: keyof E, event: UpdateEvent<E>) {
|
||||
return !!event.updatedColumns.find((item) => item.propertyName === cloumn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { isNil, pick, unset } from 'lodash';
|
||||
import {
|
||||
EntityManager,
|
||||
EntityTarget,
|
||||
FindOptionsUtils,
|
||||
FindTreeOptions,
|
||||
ObjectLiteral,
|
||||
QueryRunner,
|
||||
SelectQueryBuilder,
|
||||
TreeRepository,
|
||||
TreeRepositoryUtils,
|
||||
} from 'typeorm';
|
||||
|
||||
import { OrderType, TreeChildrenResolve } from '../constants';
|
||||
import { getOrderByQuery } from '../helpers';
|
||||
import { OrderQueryType, QueryParams } from '../types';
|
||||
|
||||
/**
|
||||
* 基础树形存储类
|
||||
*/
|
||||
export class BaseTreeRepository<E extends ObjectLiteral> extends TreeRepository<E> {
|
||||
/**
|
||||
* 查询器名称
|
||||
*/
|
||||
protected _qbName = 'treeEntity';
|
||||
|
||||
/**
|
||||
* 删除父分类后是否提升子分类的等级
|
||||
*/
|
||||
protected _childrenResolve?: TreeChildrenResolve;
|
||||
|
||||
/**
|
||||
* 默认排序规则,可以通过每个方法的orderBy选项进行覆盖
|
||||
*/
|
||||
protected orderBy?: string | { name: string; order: `${OrderType}` };
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
|
||||
constructor(target: EntityTarget<E>, manager: EntityManager, queryRunner?: QueryRunner) {
|
||||
super(target, manager, queryRunner);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回查询器名称
|
||||
*/
|
||||
get qbName() {
|
||||
return this._qbName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回子分类的等级
|
||||
*/
|
||||
get childrenResolve() {
|
||||
return this._childrenResolve;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建基础查询器
|
||||
*/
|
||||
buildBaseQB(qb?: SelectQueryBuilder<E>): SelectQueryBuilder<E> {
|
||||
const queryBuilder = qb ?? this.createQueryBuilder(this.qbName);
|
||||
return queryBuilder.leftJoinAndSelect(`${this.qbName}.parent`, 'parent');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成排序的QueryBuilder
|
||||
* @param qb
|
||||
* @param orderBy
|
||||
*/
|
||||
addOrderByQuery(qb: SelectQueryBuilder<E>, orderBy?: OrderQueryType) {
|
||||
const orderByQuery = orderBy ?? this.orderBy;
|
||||
return !isNil(orderByQuery) ? getOrderByQuery(qb, this.qbName, orderByQuery) : qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询树形分类
|
||||
* @param options
|
||||
*/
|
||||
async findTrees(options?: FindTreeOptions & QueryParams<E>) {
|
||||
const roots = await this.findRoots(options);
|
||||
await Promise.all(roots.map((root) => this.findDescendantsTree(root, options)));
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询顶级分类
|
||||
* @param options
|
||||
*/
|
||||
async findRoots(options?: FindTreeOptions & QueryParams<E>) {
|
||||
const { addQuery, orderBy, withTrashed, onlyTrashed } = 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.addOrderByQuery(this.buildBaseQB(), orderBy);
|
||||
qb.where(`${escapeAlias(this.qbName)}.${escapeColumn(parentPropertyName)} IS NULL`);
|
||||
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, pick(options, ['relations', 'depth']));
|
||||
qb = addQuery ? await addQuery(qb) : qb;
|
||||
if (withTrashed) {
|
||||
qb.withDeleted();
|
||||
if (onlyTrashed) qb.where(`${this.qbName}.deletedAt IS NOT NULL`);
|
||||
}
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询后代树
|
||||
* @param entity
|
||||
* @param options
|
||||
*/
|
||||
async findDescendantsTree(entity: E, options?: FindTreeOptions & QueryParams<E>) {
|
||||
const { addQuery, orderBy, withTrashed, onlyTrashed } = options ?? {};
|
||||
let qb = this.buildBaseQB(
|
||||
this.createDescendantsQueryBuilder(this.qbName, 'treeClosure', entity),
|
||||
);
|
||||
qb = addQuery
|
||||
? await addQuery(this.addOrderByQuery(qb, orderBy))
|
||||
: this.addOrderByQuery(qb, orderBy);
|
||||
if (withTrashed) {
|
||||
qb.withDeleted();
|
||||
if (onlyTrashed) qb.where(`${this.qbName}.deletedAt IS NOT NULL`);
|
||||
}
|
||||
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, pick(options, ['relations', 'depth']));
|
||||
const entities = await qb.getRawAndEntities();
|
||||
const relationMaps = TreeRepositoryUtils.createRelationMaps(
|
||||
this.manager,
|
||||
this.metadata,
|
||||
this.qbName,
|
||||
entities.raw,
|
||||
);
|
||||
TreeRepositoryUtils.buildChildrenEntityTree(
|
||||
this.metadata,
|
||||
entity,
|
||||
entities.entities,
|
||||
relationMaps,
|
||||
{
|
||||
depth: -1,
|
||||
...pick(options, ['relations']),
|
||||
},
|
||||
);
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询祖先树
|
||||
* @param entity
|
||||
* @param options
|
||||
*/
|
||||
async findAncestorsTree(entity: E, options?: FindTreeOptions & QueryParams<E>) {
|
||||
const { addQuery, orderBy, withTrashed, onlyTrashed } = options ?? {};
|
||||
let qb = this.buildBaseQB(
|
||||
this.createDescendantsQueryBuilder(this.qbName, 'treeClosure', entity),
|
||||
);
|
||||
qb = addQuery
|
||||
? await addQuery(this.addOrderByQuery(qb, orderBy))
|
||||
: this.addOrderByQuery(qb, orderBy);
|
||||
if (withTrashed) {
|
||||
qb.withDeleted();
|
||||
if (onlyTrashed) qb.where(`${this.qbName}.deletedAt IS NOT NULL`);
|
||||
}
|
||||
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options);
|
||||
|
||||
const entities = await qb.getRawAndEntities();
|
||||
const relationMaps = TreeRepositoryUtils.createRelationMaps(
|
||||
this.manager,
|
||||
this.metadata,
|
||||
'treeEntity',
|
||||
entities.raw,
|
||||
);
|
||||
TreeRepositoryUtils.buildParentEntityTree(
|
||||
this.metadata,
|
||||
entity,
|
||||
entities.entities,
|
||||
relationMaps,
|
||||
);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询后代元素
|
||||
* @param entity
|
||||
* @param options
|
||||
*/
|
||||
async findDescendants(entity: E, options?: FindTreeOptions & QueryParams<E>) {
|
||||
const { addQuery, orderBy, withTrashed, onlyTrashed } = options ?? {};
|
||||
let qb = this.buildBaseQB(
|
||||
this.createDescendantsQueryBuilder(this.qbName, 'treeClosure', entity),
|
||||
);
|
||||
qb = addQuery
|
||||
? await addQuery(this.addOrderByQuery(qb, orderBy))
|
||||
: this.addOrderByQuery(qb, orderBy);
|
||||
if (withTrashed) {
|
||||
qb.withDeleted();
|
||||
if (onlyTrashed) qb.where(`${this.qbName}.deletedAt IS NOT NULL`);
|
||||
}
|
||||
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options);
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询祖先元素
|
||||
* @param entity
|
||||
* @param options
|
||||
*/
|
||||
async findAncestors(entity: E, options?: FindTreeOptions & QueryParams<E>) {
|
||||
const { addQuery, orderBy, withTrashed, onlyTrashed } = options ?? {};
|
||||
let qb = this.buildBaseQB(
|
||||
this.createAncestorsQueryBuilder(this.qbName, 'treeClosure', entity),
|
||||
);
|
||||
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options);
|
||||
qb = addQuery
|
||||
? await addQuery(this.addOrderByQuery(qb, orderBy))
|
||||
: this.addOrderByQuery(qb, orderBy);
|
||||
if (withTrashed) {
|
||||
qb.withDeleted();
|
||||
if (onlyTrashed) qb.where(`${this.qbName}.deletedAt IS NOT NULL`);
|
||||
}
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计后代元素数量
|
||||
* @param entity
|
||||
* @param options
|
||||
*/
|
||||
async countDescendants(entity: E, options?: FindTreeOptions & QueryParams<E>) {
|
||||
const { addQuery, orderBy, withTrashed, onlyTrashed } = options ?? {};
|
||||
let qb = this.createDescendantsQueryBuilder(this.qbName, 'treeClosure', entity);
|
||||
qb = addQuery
|
||||
? await addQuery(this.addOrderByQuery(qb, orderBy))
|
||||
: this.addOrderByQuery(qb, orderBy);
|
||||
if (withTrashed) {
|
||||
qb.withDeleted();
|
||||
if (onlyTrashed) qb.where(`${this.qbName}.deletedAt IS NOT NULL`);
|
||||
}
|
||||
return qb.getCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计祖先元素数量
|
||||
* @param entity
|
||||
* @param options
|
||||
*/
|
||||
async countAncestors(entity: E, options?: FindTreeOptions & QueryParams<E>) {
|
||||
const { addQuery, orderBy, withTrashed, onlyTrashed } = options ?? {};
|
||||
let qb = this.createAncestorsQueryBuilder(this.qbName, 'treeClosure', entity);
|
||||
qb = addQuery
|
||||
? await addQuery(this.addOrderByQuery(qb, orderBy))
|
||||
: this.addOrderByQuery(qb, orderBy);
|
||||
if (withTrashed) {
|
||||
qb.withDeleted();
|
||||
if (onlyTrashed) qb.where(`${this.qbName}.deletedAt IS NOT NULL`);
|
||||
}
|
||||
return qb.getCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* 打平并展开树
|
||||
* @param trees
|
||||
* @param level
|
||||
*/
|
||||
async toFlatTrees(trees: E[], depth = 0, parent: E | null = null): Promise<E[]> {
|
||||
const data: Omit<E, 'children'>[] = [];
|
||||
for (const item of trees) {
|
||||
(item as any).depth = depth;
|
||||
(item as any).parent = parent;
|
||||
const { children } = item;
|
||||
unset(item, 'children');
|
||||
data.push(item);
|
||||
data.push(...(await this.toFlatTrees(children, depth + 1, item)));
|
||||
}
|
||||
return data as E[];
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,29 @@
|
||||
* 自定义 Repository 元数据
|
||||
*/
|
||||
export const CUSTOM_REPOSITORY_METADATA = 'CUSTOM_REPOSITORY_METADATA';
|
||||
|
||||
/**
|
||||
* 软删除数据查询类型
|
||||
*/
|
||||
export enum SelectTrashMode {
|
||||
ALL = 'all',
|
||||
ONLY = 'only',
|
||||
NONE = 'none',
|
||||
}
|
||||
|
||||
/**
|
||||
* 排序方式
|
||||
*/
|
||||
export enum OrderType {
|
||||
ASC = 'ASC',
|
||||
DESC = 'DESC',
|
||||
}
|
||||
|
||||
/**
|
||||
* 树形模型在删除父级后子级的处理方式
|
||||
*/
|
||||
export enum TreeChildrenResolve {
|
||||
DELETE = 'delete',
|
||||
UP = 'up',
|
||||
ROOT = 'root',
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { isNil } from 'lodash';
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
import { DataSource, ObjectLiteral, ObjectType, Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { PaginateOptions, PaginateReturn } from '@/modules/database/types';
|
||||
import { CUSTOM_REPOSITORY_METADATA } from '@/modules/database/constants';
|
||||
import { OrderQueryType, PaginateOptions, PaginateReturn } from '@/modules/database/types';
|
||||
|
||||
/**
|
||||
* 分页函数
|
||||
@@ -13,34 +14,101 @@ 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,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 为查询添加排序,默认排序规则为DESC
|
||||
* @param qb 原查询
|
||||
* @param alias 别名
|
||||
* @param orderBy 查询排序
|
||||
*/
|
||||
export const getOrderByQuery = <E extends ObjectLiteral>(
|
||||
qb: SelectQueryBuilder<E>,
|
||||
alias: string,
|
||||
orderBy?: OrderQueryType,
|
||||
) => {
|
||||
if (isNil(orderBy)) return qb;
|
||||
if (typeof orderBy === 'string') return qb.orderBy(`${alias}.${orderBy}`, 'DESC');
|
||||
if (Array.isArray(orderBy)) {
|
||||
for (const item of orderBy) {
|
||||
typeof item === 'string'
|
||||
? qb.addOrderBy(`${alias}.${item}`, 'DESC')
|
||||
: qb.addOrderBy(`${alias}.${item.name}`, item.order);
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
return qb.orderBy(`${alias}.${(orderBy as any).name}`, (orderBy as any).order);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取自定义Repository的实例
|
||||
* @param dataSource 数据连接池
|
||||
* @param Repo repository类
|
||||
*/
|
||||
export const getCustomRepository = <T extends Repository<E>, E extends ObjectLiteral>(
|
||||
dataSource: DataSource,
|
||||
Repo: ClassType<T>,
|
||||
): T => {
|
||||
if (isNil(Repo)) return null;
|
||||
const entity = Reflect.getMetadata(CUSTOM_REPOSITORY_METADATA, Repo);
|
||||
if (!entity) return null;
|
||||
const base = dataSource.getRepository<ObjectType<any>>(entity);
|
||||
return new Repo(base.target, base.manager, base.queryRunner) as T;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
import {
|
||||
FindTreeOptions,
|
||||
ObjectLiteral,
|
||||
Repository,
|
||||
SelectQueryBuilder,
|
||||
TreeRepository,
|
||||
} from 'typeorm';
|
||||
|
||||
import { BaseRepository, BaseTreeRepository } from '@/modules/database/base';
|
||||
import { OrderType, SelectTrashMode } from '@/modules/database/constants';
|
||||
|
||||
/**
|
||||
* 一个查询钩子,用于修改或增强给定的查询构建器。
|
||||
@@ -68,3 +77,57 @@ export interface PaginateReturn<E extends ObjectLiteral> {
|
||||
*/
|
||||
meta: PaginateMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* 排序类型,{字段名称: 排序方法}
|
||||
* 如果多个值则传入数组即可
|
||||
* 排序方法不设置,默认DESC
|
||||
*/
|
||||
export type OrderQueryType =
|
||||
| string
|
||||
| { name: string; order: `${OrderType}` }
|
||||
| Array<{ name: string; order: `${OrderType}` } | string>;
|
||||
|
||||
/**
|
||||
* 数据列表查询类型
|
||||
*/
|
||||
export interface QueryParams<E extends ObjectLiteral> {
|
||||
addQuery?: QueryHook<E>;
|
||||
orderBy?: OrderQueryType;
|
||||
withTrashed?: boolean;
|
||||
onlyTrashed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务类数据列表查询类型
|
||||
*/
|
||||
export type ServiceListQueryOption<E extends ObjectLiteral> =
|
||||
| ServiceListQueryOptionWithTrashed<E>
|
||||
| ServiceListQueryOptionNotWithTrashed<E>;
|
||||
|
||||
/**
|
||||
* 带有软删除的服务类数据列表查询类型
|
||||
*/
|
||||
type ServiceListQueryOptionWithTrashed<E extends ObjectLiteral> = Omit<
|
||||
FindTreeOptions & QueryParams<E>,
|
||||
'withTrashed'
|
||||
> & {
|
||||
trashed?: `${SelectTrashMode}`;
|
||||
} & Record<string, any>;
|
||||
|
||||
/**
|
||||
* 不带软删除的服务类数据列表查询类型
|
||||
*/
|
||||
type ServiceListQueryOptionNotWithTrashed<E extends ObjectLiteral> = Omit<
|
||||
ServiceListQueryOptionWithTrashed<E>,
|
||||
'trashed'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Repository类型
|
||||
*/
|
||||
export type RepositoryType<E extends ObjectLiteral> =
|
||||
| Repository<E>
|
||||
| TreeRepository<E>
|
||||
| BaseRepository<E>
|
||||
| BaseTreeRepository<E>;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Config } from 'meilisearch';
|
||||
|
||||
// MelliSearch模块的配置
|
||||
export type MelliConfig = MelliOption[];
|
||||
|
||||
// MeilliSearch的连接节点配置
|
||||
export type MelliOption = Config & { name: string };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[] = [];
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './delete-with-trash.dto';
|
||||
export * from './delete.dto';
|
||||
export * from './restore.dto';
|
||||
@@ -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
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"version": 2,
|
||||
"name": "ink-nestjs-api",
|
||||
"builds": [
|
||||
{
|
||||
"src": "dist/main.js",
|
||||
"use": "@vercel/node"
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"src": "/(.*)",
|
||||
"dest": "dist/main.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user