feat:数据关联与树形数据嵌套结构的分类和评论实现
- 实现分类、评论、标签实体 - 使用了Materialized Path实现树形嵌套 - 对分类、评论、标签的存储库进行自定义改造
This commit is contained in:
parent
cce9a6b179
commit
781962dce0
Binary file not shown.
Binary file not shown.
14
package.json
14
package.json
@ -25,7 +25,7 @@
|
|||||||
"@nestjs/platform-fastify": "^10.2.10",
|
"@nestjs/platform-fastify": "^10.2.10",
|
||||||
"@nestjs/swagger": "^7.1.16",
|
"@nestjs/swagger": "^7.1.16",
|
||||||
"@nestjs/typeorm": "^10.0.1",
|
"@nestjs/typeorm": "^10.0.1",
|
||||||
"better-sqlite3": "^9.1.1",
|
"better-sqlite3": "^9.2.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
"deepmerge": "^4.3.1",
|
"deepmerge": "^4.3.1",
|
||||||
@ -41,18 +41,18 @@
|
|||||||
"@nestjs/schematics": "^10.0.3",
|
"@nestjs/schematics": "^10.0.3",
|
||||||
"@nestjs/testing": "^10.2.10",
|
"@nestjs/testing": "^10.2.10",
|
||||||
"@swc/cli": "^0.1.63",
|
"@swc/cli": "^0.1.63",
|
||||||
"@swc/core": "^1.3.99",
|
"@swc/core": "^1.3.100",
|
||||||
"@types/jest": "^29.5.10",
|
"@types/jest": "^29.5.10",
|
||||||
"@types/lodash": "^4.14.202",
|
"@types/lodash": "^4.14.202",
|
||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.2",
|
||||||
"@types/sanitize-html": "^2.9.5",
|
"@types/sanitize-html": "^2.9.5",
|
||||||
"@types/supertest": "^2.0.16",
|
"@types/supertest": "^2.0.16",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.12.0",
|
"@typescript-eslint/eslint-plugin": "^6.13.1",
|
||||||
"@typescript-eslint/parser": "^6.12.0",
|
"@typescript-eslint/parser": "^6.13.1",
|
||||||
"eslint": "^8.54.0",
|
"eslint": "^8.55.0",
|
||||||
"eslint-config-airbnb-base": "^15.0.0",
|
"eslint-config-airbnb-base": "^15.0.0",
|
||||||
"eslint-config-airbnb-typescript": "^17.1.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-import": "^2.29.0",
|
||||||
"eslint-plugin-jest": "^27.6.0",
|
"eslint-plugin-jest": "^27.6.0",
|
||||||
"eslint-plugin-prettier": "^5.0.1",
|
"eslint-plugin-prettier": "^5.0.1",
|
||||||
|
700
pnpm-lock.yaml
700
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -17,7 +17,7 @@ export const database = (): TypeOrmModuleOptions => ({
|
|||||||
// database: 'ink_apps',
|
// database: 'ink_apps',
|
||||||
// 以下为sqlite配置
|
// 以下为sqlite配置
|
||||||
type: 'better-sqlite3',
|
type: 'better-sqlite3',
|
||||||
database: resolve(__dirname, '../../back/database4.db'),
|
database: resolve(__dirname, '../../back/database6.db'),
|
||||||
synchronize: true,
|
synchronize: true,
|
||||||
autoLoadEntities: true,
|
autoLoadEntities: true,
|
||||||
});
|
});
|
||||||
|
@ -13,5 +13,6 @@ export enum PostOrderType {
|
|||||||
CREATED = 'createdAt',
|
CREATED = 'createdAt',
|
||||||
UPDATED = 'updatedAt',
|
UPDATED = 'updatedAt',
|
||||||
PUBLISHED = 'publishedAt',
|
PUBLISHED = 'publishedAt',
|
||||||
|
COMMENTCOUNT = 'commentCount',
|
||||||
CUSTOM = 'custom',
|
CUSTOM = 'custom',
|
||||||
}
|
}
|
||||||
|
@ -2,21 +2,25 @@ import { Module } from '@nestjs/common';
|
|||||||
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { PostController } from '@/modules/content/controllers';
|
import { SanitizeService } from '@/modules/content/services/sanitize.service';
|
||||||
import { PostEntity } from '@/modules/content/entities';
|
|
||||||
|
|
||||||
import { PostRepository } from '@/modules/content/repositories';
|
|
||||||
import { PostService, SanitizeService } from '@/modules/content/services';
|
|
||||||
import { PostSubscriber } from '@/modules/content/subscribers';
|
import { PostSubscriber } from '@/modules/content/subscribers';
|
||||||
import { DatabaseModule } from '@/modules/database/database.module';
|
import { DatabaseModule } from '@/modules/database/database.module';
|
||||||
|
|
||||||
|
import * as controllers from './controllers';
|
||||||
|
import * as entities from './entities';
|
||||||
|
import * as repositories from './repositories';
|
||||||
|
import * as services from './services';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([PostEntity]),
|
TypeOrmModule.forFeature(Object.values(entities)),
|
||||||
DatabaseModule.forRepository([PostRepository]),
|
DatabaseModule.forRepository(Object.values(repositories)),
|
||||||
|
],
|
||||||
|
controllers: Object.values(controllers),
|
||||||
|
providers: [...Object.values(services), PostSubscriber, SanitizeService],
|
||||||
|
exports: [
|
||||||
|
...Object.values(services),
|
||||||
|
DatabaseModule.forRepository(Object.values(repositories)),
|
||||||
],
|
],
|
||||||
controllers: [PostController],
|
|
||||||
providers: [PostService, PostSubscriber, SanitizeService],
|
|
||||||
exports: [PostService, DatabaseModule.forRepository([PostRepository])],
|
|
||||||
})
|
})
|
||||||
export class ContentModule {}
|
export class ContentModule {}
|
||||||
|
95
src/modules/content/controllers/category.controller.ts
Normal file
95
src/modules/content/controllers/category.controller.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
SerializeOptions,
|
||||||
|
UseInterceptors,
|
||||||
|
ValidationPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
import { CreateCategoryDto, QueryCategoryDto, UpdateCategoryDto } from '@/modules/content/dtos';
|
||||||
|
import { CategoryService } from '@/modules/content/services';
|
||||||
|
import { AppIntercepter } from '@/modules/core/providers';
|
||||||
|
|
||||||
|
@UseInterceptors(AppIntercepter)
|
||||||
|
@Controller('categories')
|
||||||
|
export class CategoryController {
|
||||||
|
constructor(protected service: CategoryService) {}
|
||||||
|
|
||||||
|
@Get('tree')
|
||||||
|
@SerializeOptions({ groups: ['category-tree'] })
|
||||||
|
async tree() {
|
||||||
|
return this.service.findTress();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@SerializeOptions({ groups: ['category-list'] })
|
||||||
|
async list(
|
||||||
|
@Query(
|
||||||
|
new ValidationPipe({
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
forbidUnknownValues: true,
|
||||||
|
validationError: { target: false },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
new ValidationPipe({
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
forbidUnknownValues: true,
|
||||||
|
validationError: { target: false },
|
||||||
|
groups: ['create'],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
data: CreateCategoryDto,
|
||||||
|
) {
|
||||||
|
return this.service.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch()
|
||||||
|
@SerializeOptions({ groups: ['category-detail'] })
|
||||||
|
async update(
|
||||||
|
@Body(
|
||||||
|
new ValidationPipe({
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
forbidUnknownValues: true,
|
||||||
|
validationError: { target: false },
|
||||||
|
groups: ['update'],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
data: UpdateCategoryDto,
|
||||||
|
) {
|
||||||
|
return this.service.update(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@SerializeOptions({ groups: ['category-detail'] })
|
||||||
|
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||||
|
return this.service.delete(id);
|
||||||
|
}
|
||||||
|
}
|
78
src/modules/content/controllers/comment.controller.ts
Normal file
78
src/modules/content/controllers/comment.controller.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
SerializeOptions,
|
||||||
|
UseInterceptors,
|
||||||
|
ValidationPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
import { CreateCommentDto, QueryCommentDto, QueryCommentTreeDto } from '@/modules/content/dtos';
|
||||||
|
import { CommentService } from '@/modules/content/services';
|
||||||
|
import { AppIntercepter } from '@/modules/core/providers';
|
||||||
|
|
||||||
|
@UseInterceptors(AppIntercepter)
|
||||||
|
@Controller('comments')
|
||||||
|
export class CommentController {
|
||||||
|
constructor(protected service: CommentService) {}
|
||||||
|
|
||||||
|
@Get('tree')
|
||||||
|
@SerializeOptions({ groups: ['comment-tree'] })
|
||||||
|
async tree(
|
||||||
|
@Query(
|
||||||
|
new ValidationPipe({
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
forbidUnknownValues: true,
|
||||||
|
validationError: { target: false },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
query: QueryCommentTreeDto,
|
||||||
|
) {
|
||||||
|
return this.service.findTrees(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@SerializeOptions({ groups: ['comment-list'] })
|
||||||
|
async list(
|
||||||
|
@Query(
|
||||||
|
new ValidationPipe({
|
||||||
|
transform: true,
|
||||||
|
forbidUnknownValues: true,
|
||||||
|
validationError: { target: false },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
query: QueryCommentDto,
|
||||||
|
) {
|
||||||
|
return this.service.paginate(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@SerializeOptions({ groups: ['comment-detail'] })
|
||||||
|
async store(
|
||||||
|
@Body(
|
||||||
|
new ValidationPipe({
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
forbidUnknownValues: true,
|
||||||
|
validationError: { target: false },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
data: CreateCommentDto,
|
||||||
|
) {
|
||||||
|
return this.service.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@SerializeOptions({ groups: ['comment-detail'] })
|
||||||
|
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||||
|
return this.service.delete(id);
|
||||||
|
}
|
||||||
|
}
|
@ -1 +1,4 @@
|
|||||||
|
export * from './category.controller';
|
||||||
|
export * from './comment.controller';
|
||||||
export * from './post.controller';
|
export * from './post.controller';
|
||||||
|
export * from './tag.controller';
|
||||||
|
@ -13,10 +13,9 @@ import {
|
|||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
} from '@nestjs/common';
|
} 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 { PostService } from '@/modules/content/services';
|
||||||
import { AppIntercepter } from '@/modules/core/providers';
|
import { AppIntercepter } from '@/modules/core/providers';
|
||||||
import { PaginateOptions } from '@/modules/database/types';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文章控制器
|
* 文章控制器
|
||||||
@ -39,7 +38,7 @@ export class PostController {
|
|||||||
validationError: { target: false },
|
validationError: { target: false },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
options: PaginateOptions,
|
options: QueryPostDto,
|
||||||
) {
|
) {
|
||||||
return this.postService.paginate(options);
|
return this.postService.paginate(options);
|
||||||
}
|
}
|
||||||
|
92
src/modules/content/controllers/tag.controller.ts
Normal file
92
src/modules/content/controllers/tag.controller.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
SerializeOptions,
|
||||||
|
UseInterceptors,
|
||||||
|
ValidationPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
import { CreateTagDto, QueryCategoryDto, UpdateTagDto } from '@/modules/content/dtos';
|
||||||
|
import { TagService } from '@/modules/content/services';
|
||||||
|
import { AppIntercepter } from '@/modules/core/providers';
|
||||||
|
|
||||||
|
@UseInterceptors(AppIntercepter)
|
||||||
|
@Controller('tags')
|
||||||
|
export class TagController {
|
||||||
|
constructor(protected service: TagService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@SerializeOptions({})
|
||||||
|
async list(
|
||||||
|
@Query(
|
||||||
|
new ValidationPipe({
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
forbidUnknownValues: true,
|
||||||
|
validationError: { target: false },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
options: QueryCategoryDto,
|
||||||
|
) {
|
||||||
|
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(
|
||||||
|
new ValidationPipe({
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
forbidUnknownValues: true,
|
||||||
|
validationError: { target: false },
|
||||||
|
groups: ['create'],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
data: CreateTagDto,
|
||||||
|
) {
|
||||||
|
return this.service.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch()
|
||||||
|
@SerializeOptions({})
|
||||||
|
async update(
|
||||||
|
@Body(
|
||||||
|
new ValidationPipe({
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
forbidUnknownValues: true,
|
||||||
|
validationError: { target: false },
|
||||||
|
groups: ['update'],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
data: UpdateTagDto,
|
||||||
|
) {
|
||||||
|
return this.service.update(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@SerializeOptions({})
|
||||||
|
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||||
|
return this.service.delete(id);
|
||||||
|
}
|
||||||
|
}
|
63
src/modules/content/dtos/category.dto.ts
Normal file
63
src/modules/content/dtos/category.dto.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { PartialType } 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 { PaginateOptions } from '@/modules/database/types';
|
||||||
|
|
||||||
|
export class QueryCategoryDto 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分类新增验证
|
||||||
|
*/
|
||||||
|
export class CreateCategoryDto {
|
||||||
|
@MaxLength(25, {
|
||||||
|
always: true,
|
||||||
|
message: '分类名称长度最大为$constraint1',
|
||||||
|
})
|
||||||
|
@IsNotEmpty({ groups: ['create'], message: '分类名称不能为空' })
|
||||||
|
@IsOptional({ groups: ['update'] })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分类更新验证
|
||||||
|
*/
|
||||||
|
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {
|
||||||
|
@IsUUID(undefined, { groups: ['update'], message: '分类ID格式不正确' })
|
||||||
|
@IsDefined({ groups: ['update'], message: '分类ID必须指定' })
|
||||||
|
id: string;
|
||||||
|
}
|
61
src/modules/content/dtos/comment.dto.ts
Normal file
61
src/modules/content/dtos/comment.dto.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
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 { PaginateOptions } from '@/modules/database/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评论分页查询验证
|
||||||
|
*/
|
||||||
|
export class QueryCommentDto implements PaginateOptions {
|
||||||
|
@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;
|
||||||
|
|
||||||
|
@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 './post.dto';
|
||||||
|
export * from './tag.dto';
|
||||||
|
@ -46,6 +46,14 @@ export class QueryPostDto implements PaginateOptions {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
limit: 10;
|
limit: 10;
|
||||||
|
|
||||||
|
@IsUUID(undefined, { message: '分类ID必须是UUID' })
|
||||||
|
@IsOptional()
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
@IsUUID(undefined, { message: '标签ID必须是UUID' })
|
||||||
|
@IsOptional()
|
||||||
|
tag?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -93,6 +101,19 @@ export class CreatePostDto {
|
|||||||
@IsNumber(undefined, { always: true })
|
@IsNumber(undefined, { always: true })
|
||||||
@IsOptional({ always: true })
|
@IsOptional({ always: true })
|
||||||
customOrder = 0;
|
customOrder = 0;
|
||||||
|
|
||||||
|
@IsUUID(undefined, { message: '分类ID必须是UUID', each: true, always: true })
|
||||||
|
@IsOptional({ groups: ['update'] })
|
||||||
|
category: string;
|
||||||
|
|
||||||
|
@IsUUID(undefined, {
|
||||||
|
each: true,
|
||||||
|
always: true,
|
||||||
|
message: '每个标签ID必须是UUID',
|
||||||
|
})
|
||||||
|
@IsNotEmpty({ groups: ['create'], message: '至少需要一个标签' })
|
||||||
|
@IsOptional({ always: true })
|
||||||
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
60
src/modules/content/dtos/tag.dto.ts
Normal file
60
src/modules/content/dtos/tag.dto.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsDefined,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { toNumber } from 'lodash';
|
||||||
|
|
||||||
|
import { PaginateOptions } from '@/modules/database/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标签分页查询验证
|
||||||
|
*/
|
||||||
|
export class QueryTagsDto 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标签新增验证
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标签更新验证
|
||||||
|
*/
|
||||||
|
export class UpdateTagDto extends PartialType(CreateTagDto) {
|
||||||
|
@IsUUID(undefined, { groups: ['update'], message: '标签ID格式不正确' })
|
||||||
|
@IsDefined({ groups: ['update'], message: '标签ID必须指定' })
|
||||||
|
id: string;
|
||||||
|
}
|
57
src/modules/content/entities/category.entity.ts
Normal file
57
src/modules/content/entities/category.entity.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { Exclude, Expose, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
BaseEntity,
|
||||||
|
Column,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
OneToMany,
|
||||||
|
PrimaryColumn,
|
||||||
|
Relation,
|
||||||
|
Tree,
|
||||||
|
TreeChildren,
|
||||||
|
TreeParent,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
import { PostEntity } from './post.entity';
|
||||||
|
|
||||||
|
@Exclude()
|
||||||
|
@Tree('materialized-path')
|
||||||
|
@Entity('content_categories')
|
||||||
|
export class CategoryEntity extends BaseEntity {
|
||||||
|
@Expose()
|
||||||
|
@PrimaryColumn({ type: 'varchar', generated: 'uuid', length: 36 })
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@Column({ comment: '分类名称' })
|
||||||
|
@Index({ fulltext: true })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Expose({ groups: ['category-tree', 'category-list', 'category-detail'] })
|
||||||
|
@Column({ comment: '分类排序', default: 0 })
|
||||||
|
customOrder: number;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@Type(() => Date)
|
||||||
|
@DeleteDateColumn({
|
||||||
|
comment: '删除时间',
|
||||||
|
})
|
||||||
|
deletedAt: Date;
|
||||||
|
|
||||||
|
@Expose({ groups: ['category-list'] })
|
||||||
|
depth = 0;
|
||||||
|
|
||||||
|
@Expose({ groups: ['category-detail', 'category-list'] })
|
||||||
|
@TreeParent({ onDelete: 'NO ACTION' })
|
||||||
|
parent: Relation<CategoryEntity> | null;
|
||||||
|
|
||||||
|
@Expose({ groups: ['category-tree'] })
|
||||||
|
@TreeChildren({ cascade: true })
|
||||||
|
children: Relation<CategoryEntity>[];
|
||||||
|
|
||||||
|
@OneToMany(() => PostEntity, (post) => post.category, {
|
||||||
|
cascade: true,
|
||||||
|
})
|
||||||
|
posts: Relation<PostEntity[]>;
|
||||||
|
}
|
53
src/modules/content/entities/comment.entity.ts
Normal file
53
src/modules/content/entities/comment.entity.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { Exclude, Expose } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
BaseEntity,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
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' })
|
||||||
|
body: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@CreateDateColumn({
|
||||||
|
comment: '创建时间',
|
||||||
|
})
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@ManyToOne(() => PostEntity, (post) => post.comments, {
|
||||||
|
nullable: false,
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
})
|
||||||
|
post: Relation<PostEntity>;
|
||||||
|
|
||||||
|
@Expose({ groups: ['comment-list'] })
|
||||||
|
depth = 0;
|
||||||
|
|
||||||
|
@Expose({ groups: ['comment-detail', 'comment-list'] })
|
||||||
|
@TreeParent({ onDelete: 'NO ACTION' })
|
||||||
|
parent: Relation<CommentEntity> | null;
|
||||||
|
|
||||||
|
@Expose({ groups: ['comment-tree'] })
|
||||||
|
@TreeChildren({ cascade: true })
|
||||||
|
children: Relation<CommentEntity[]>;
|
||||||
|
}
|
@ -1 +1,4 @@
|
|||||||
|
export * from './category.entity';
|
||||||
|
export * from './comment.entity';
|
||||||
export * from './post.entity';
|
export * from './post.entity';
|
||||||
|
export * from './tag.entity';
|
||||||
|
@ -1,55 +1,115 @@
|
|||||||
import { Exclude, Expose } from 'class-transformer';
|
import { Exclude, Expose, Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
BaseEntity,
|
BaseEntity,
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
Entity,
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinTable,
|
||||||
|
ManyToMany,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
|
Relation,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} 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()
|
@Exclude()
|
||||||
@Entity('content_posts')
|
@Entity('content_posts')
|
||||||
export class PostEntity extends BaseEntity {
|
export class PostEntity extends BaseEntity {
|
||||||
@Expose()
|
@Expose()
|
||||||
@PrimaryColumn({ type: 'varchar', generated: 'uuid', length: '36' })
|
@PrimaryColumn({ type: 'varchar', generated: 'uuid', length: 36 })
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Column({ comment: '文章标题' })
|
@Column({ comment: '文章标题' })
|
||||||
|
@Index({ fulltext: true })
|
||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
@Expose({ groups: ['post-detail'] })
|
@Expose({ groups: ['post-detail'] })
|
||||||
@Column({ comment: '文章内容', type: 'text' })
|
@Column({ comment: '文章内容', type: 'text' })
|
||||||
|
@Index({ fulltext: true })
|
||||||
body: string;
|
body: string;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Column({ comment: '文章摘要', nullable: true })
|
@Column({ comment: '文章描述', nullable: true })
|
||||||
summary: string;
|
@Index({ fulltext: true })
|
||||||
|
summary?: string;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Column({ comment: '关键字', type: 'simple-array', nullable: true })
|
@Column({ comment: '关键字', type: 'simple-array', nullable: true })
|
||||||
keywords?: string[];
|
keywords?: string[];
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Column({ comment: '文章类型', type: 'varchar', default: PostBodyType.MD })
|
@Column({
|
||||||
|
comment: '文章类型',
|
||||||
|
type: 'varchar',
|
||||||
|
// 如果是mysql或者postgresql你可以使用enum类型
|
||||||
|
// enum: PostBodyType,
|
||||||
|
default: PostBodyType.MD,
|
||||||
|
})
|
||||||
type: PostBodyType;
|
type: PostBodyType;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Column({ comment: '发布时间', type: 'varchar', nullable: true })
|
@Column({
|
||||||
|
comment: '发布时间',
|
||||||
|
type: 'varchar',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
publishedAt?: Date | null;
|
publishedAt?: Date | null;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@CreateDateColumn({ comment: '创建时间' })
|
@Column({ comment: '自定义文章排序', default: 0 })
|
||||||
|
customOrder: number;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@CreateDateColumn({
|
||||||
|
comment: '创建时间',
|
||||||
|
})
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@UpdateDateColumn({ comment: '更新时间' })
|
@UpdateDateColumn({
|
||||||
|
comment: '更新时间',
|
||||||
|
})
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Column({ comment: '文章自定义排序', default: 0 })
|
@Type(() => Date)
|
||||||
customOrder: number;
|
@DeleteDateColumn({
|
||||||
|
comment: '删除时间',
|
||||||
|
})
|
||||||
|
deletedAt: Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过queryBuilder生成的评论数量(虚拟字段)
|
||||||
|
*/
|
||||||
|
@Expose()
|
||||||
|
commentCount: number;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@ManyToOne(() => CategoryEntity, (category) => category.posts, {
|
||||||
|
nullable: true,
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
})
|
||||||
|
category: Relation<CategoryEntity>;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@ManyToMany(() => TagEntity, (tag) => tag.posts, {
|
||||||
|
cascade: true,
|
||||||
|
})
|
||||||
|
@JoinTable()
|
||||||
|
tags: Relation<TagEntity>[];
|
||||||
|
|
||||||
|
@OneToMany(() => CommentEntity, (comment) => comment.post, {
|
||||||
|
cascade: true,
|
||||||
|
})
|
||||||
|
comments: Relation<CommentEntity>[];
|
||||||
}
|
}
|
||||||
|
29
src/modules/content/entities/tag.entity.ts
Normal file
29
src/modules/content/entities/tag.entity.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { Exclude, Expose } from 'class-transformer';
|
||||||
|
import { Column, Entity, 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: '标签名称' })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@Column({ comment: '标签描述', nullable: true })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ManyToMany(() => PostEntity, (post) => post.tags)
|
||||||
|
posts: Relation<PostEntity[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过QueryBuilder生成的文章数量(虚拟字段)
|
||||||
|
*/
|
||||||
|
@Expose()
|
||||||
|
postCount: number;
|
||||||
|
}
|
81
src/modules/content/repositories/category.repository.ts
Normal file
81
src/modules/content/repositories/category.repository.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { unset } from 'lodash';
|
||||||
|
import { FindOptionsUtils, FindTreeOptions, TreeRepository } from 'typeorm';
|
||||||
|
|
||||||
|
import { CategoryEntity } from '@/modules/content/entities';
|
||||||
|
import { CustomRepository } from '@/modules/database/decorators';
|
||||||
|
|
||||||
|
@CustomRepository(CategoryEntity)
|
||||||
|
export class CategoryRepository extends TreeRepository<CategoryEntity> {
|
||||||
|
/**
|
||||||
|
* 构建基础查询器
|
||||||
|
*/
|
||||||
|
buildBaseQB() {
|
||||||
|
return this.createQueryBuilder('category').leftJoinAndSelect('category.parent', 'parent');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询顶级分类
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
findRoots(options?: FindTreeOptions) {
|
||||||
|
const escapeAlias = (alias: string) => this.manager.connection.driver.escape(alias);
|
||||||
|
const escapeColumn = (column: string) => this.manager.connection.driver.escape(column);
|
||||||
|
|
||||||
|
const joinColumn = this.metadata.treeParentRelation!.joinColumns[0];
|
||||||
|
const parentPropertyName = joinColumn.givenDatabaseName || joinColumn.databaseName;
|
||||||
|
|
||||||
|
const qb = this.buildBaseQB().orderBy('category.customOrder', 'ASC');
|
||||||
|
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options);
|
||||||
|
|
||||||
|
return qb
|
||||||
|
.where(`${escapeAlias('category')}.${escapeColumn(parentPropertyName)} IS NULL`)
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询后代分类
|
||||||
|
* @param entity
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
findDescendants(entity: CategoryEntity, options?: FindTreeOptions) {
|
||||||
|
const qb = this.createDescendantsQueryBuilder('category', 'treeClosure', entity);
|
||||||
|
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options);
|
||||||
|
qb.orderBy('category.customOrder', 'ASC');
|
||||||
|
|
||||||
|
return qb.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询祖先分类
|
||||||
|
* @param entity
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
findAncestors(entity: CategoryEntity, options?: FindTreeOptions) {
|
||||||
|
const qb = this.createAncestorsQueryBuilder('category', 'treeClosure', entity);
|
||||||
|
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, options);
|
||||||
|
qb.orderBy('category.customOrder', 'ASC');
|
||||||
|
|
||||||
|
return qb.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打平并展开树
|
||||||
|
* @param trees
|
||||||
|
* @param depth
|
||||||
|
* @param parent
|
||||||
|
*/
|
||||||
|
async toFlatTrees(trees: CategoryEntity[], depth = 0, parent: CategoryEntity | null = null) {
|
||||||
|
const data: Omit<CategoryEntity, 'children'>[] = [];
|
||||||
|
|
||||||
|
for (const tree of trees) {
|
||||||
|
tree.depth = depth;
|
||||||
|
tree.parent = parent;
|
||||||
|
const { children } = tree;
|
||||||
|
unset(tree, 'children');
|
||||||
|
data.push(tree);
|
||||||
|
data.push(...(await this.toFlatTrees(children, depth + 1, tree)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as CategoryEntity[];
|
||||||
|
}
|
||||||
|
}
|
137
src/modules/content/repositories/comment.repository.ts
Normal file
137
src/modules/content/repositories/comment.repository.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { pick, unset } from 'lodash';
|
||||||
|
import {
|
||||||
|
FindOptionsUtils,
|
||||||
|
FindTreeOptions,
|
||||||
|
SelectQueryBuilder,
|
||||||
|
TreeRepository,
|
||||||
|
TreeRepositoryUtils,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
import { CommentEntity } from '@/modules/content/entities';
|
||||||
|
import { CustomRepository } from '@/modules/database/decorators';
|
||||||
|
|
||||||
|
type FindCommentTreeOptions = FindTreeOptions & {
|
||||||
|
addQuery?: (query: SelectQueryBuilder<CommentEntity>) => SelectQueryBuilder<CommentEntity>;
|
||||||
|
};
|
||||||
|
|
||||||
|
@CustomRepository(CommentEntity)
|
||||||
|
export class CommentRepository extends TreeRepository<CommentEntity> {
|
||||||
|
/**
|
||||||
|
* 构建基础查询器
|
||||||
|
*/
|
||||||
|
buildBaseQB(qb: SelectQueryBuilder<CommentEntity>): SelectQueryBuilder<CommentEntity> {
|
||||||
|
return qb
|
||||||
|
.leftJoinAndSelect(`comment.parent`, 'parent')
|
||||||
|
.leftJoinAndSelect(`comment.post`, 'post')
|
||||||
|
.orderBy('comment.createdAt', 'DESC');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询树
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
async findTrees(options: FindCommentTreeOptions = {}) {
|
||||||
|
options.relations = ['parent', 'children'];
|
||||||
|
|
||||||
|
const roots = await this.findRoots(options);
|
||||||
|
await Promise.all(roots.map((root) => this.findDescendantsTree(root, options)));
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询顶级评论
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
findRoots(options: FindCommentTreeOptions = {}) {
|
||||||
|
const { addQuery, ...rest } = options;
|
||||||
|
const escapeAlias = (alias: string) => this.manager.connection.driver.escape(alias);
|
||||||
|
const escapeColumn = (column: string) => this.manager.connection.driver.escape(column);
|
||||||
|
|
||||||
|
const joinColumn = this.metadata.treeParentRelation!.joinColumns[0];
|
||||||
|
const parentPropertyName = joinColumn.givenDatabaseName || joinColumn.databaseName;
|
||||||
|
|
||||||
|
let qb = this.buildBaseQB(this.createQueryBuilder('comment'));
|
||||||
|
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, rest);
|
||||||
|
qb.where(`${escapeAlias('comment')}.${escapeColumn(parentPropertyName)} IS NULL`);
|
||||||
|
qb = addQuery ? addQuery(qb) : qb;
|
||||||
|
|
||||||
|
return qb.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建后代查询器
|
||||||
|
* @param closureTableAlias
|
||||||
|
* @param entity
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
createDtsQueryBuilder(
|
||||||
|
closureTableAlias: string,
|
||||||
|
entity: CommentEntity,
|
||||||
|
options: FindCommentTreeOptions = {},
|
||||||
|
): SelectQueryBuilder<CommentEntity> {
|
||||||
|
const { addQuery } = options;
|
||||||
|
const qb = this.buildBaseQB(
|
||||||
|
super.createDescendantsQueryBuilder('comment', closureTableAlias, entity),
|
||||||
|
);
|
||||||
|
|
||||||
|
return addQuery ? addQuery(qb) : qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询后代树
|
||||||
|
* @param entity
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
async findDescendantsTree(
|
||||||
|
entity: CommentEntity,
|
||||||
|
options: FindCommentTreeOptions = {},
|
||||||
|
): Promise<CommentEntity> {
|
||||||
|
const qb: SelectQueryBuilder<CommentEntity> = this.createDtsQueryBuilder(
|
||||||
|
'treeClosure',
|
||||||
|
entity,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, pick(options, ['relations', 'depth']));
|
||||||
|
|
||||||
|
const entities = await qb.getRawAndEntities();
|
||||||
|
const relationMaps = TreeRepositoryUtils.createRelationMaps(
|
||||||
|
this.manager,
|
||||||
|
this.metadata,
|
||||||
|
'comment',
|
||||||
|
entities.raw,
|
||||||
|
);
|
||||||
|
|
||||||
|
TreeRepositoryUtils.buildChildrenEntityTree(
|
||||||
|
this.metadata,
|
||||||
|
entity,
|
||||||
|
entities.entities,
|
||||||
|
relationMaps,
|
||||||
|
{
|
||||||
|
depth: -1,
|
||||||
|
...pick(options, ['relations']),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打平并展开树
|
||||||
|
* @param trees
|
||||||
|
* @param depth
|
||||||
|
*/
|
||||||
|
async toFlatTrees(trees: CommentEntity[], depth = 0) {
|
||||||
|
const data: Omit<CommentEntity, 'children'>[] = [];
|
||||||
|
|
||||||
|
for (const tree of trees) {
|
||||||
|
tree.depth = depth;
|
||||||
|
const { children } = tree;
|
||||||
|
unset(tree, 'children');
|
||||||
|
data.push(tree);
|
||||||
|
data.push(...(await this.toFlatTrees(children, depth + 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as CommentEntity[];
|
||||||
|
}
|
||||||
|
}
|
@ -1 +1,4 @@
|
|||||||
|
export * from './category.repository';
|
||||||
|
export * from './comment.repository';
|
||||||
export * from './post.repository';
|
export * from './post.repository';
|
||||||
|
export * from './tag.repository';
|
||||||
|
@ -1,11 +1,21 @@
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
import { PostEntity } from '@/modules/content/entities';
|
import { CommentEntity, PostEntity } from '@/modules/content/entities';
|
||||||
import { CustomRepository } from '@/modules/database/decorators';
|
import { CustomRepository } from '@/modules/database/decorators';
|
||||||
|
|
||||||
@CustomRepository(PostEntity)
|
@CustomRepository(PostEntity)
|
||||||
export class PostRepository extends Repository<PostEntity> {
|
export class PostRepository extends Repository<PostEntity> {
|
||||||
buildBaseQB() {
|
buildBaseQB() {
|
||||||
return this.createQueryBuilder('post');
|
// 在查询之前先查询出评论数量在添加到commentCount字段上
|
||||||
|
return this.createQueryBuilder('post')
|
||||||
|
.leftJoinAndSelect('post.category', 'category')
|
||||||
|
.leftJoinAndSelect('post.tags', 'tags')
|
||||||
|
.addSelect((subQuery) => {
|
||||||
|
return subQuery
|
||||||
|
.select('COUNT(c.id)', 'count')
|
||||||
|
.from(CommentEntity, 'c')
|
||||||
|
.where('c.post.id = post.id');
|
||||||
|
}, 'commentCount')
|
||||||
|
.loadRelationCountAndMap('post.commentCount', 'post.comments');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
18
src/modules/content/repositories/tag.repository.ts
Normal file
18
src/modules/content/repositories/tag.repository.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { PostEntity, TagEntity } from '@/modules/content/entities';
|
||||||
|
import { CustomRepository } from '@/modules/database/decorators';
|
||||||
|
|
||||||
|
@CustomRepository(TagEntity)
|
||||||
|
export class TagRepository extends Repository<TagEntity> {
|
||||||
|
buildBaseQB() {
|
||||||
|
return this.createQueryBuilder('tag')
|
||||||
|
.leftJoinAndSelect('tag.posts', 'posts')
|
||||||
|
.addSelect(
|
||||||
|
(subQuery) => subQuery.select('COUNT(p.id)', 'count').from(PostEntity, 'p'),
|
||||||
|
'postCount',
|
||||||
|
)
|
||||||
|
.orderBy('postCount', 'DESC')
|
||||||
|
.loadRelationCountAndMap('tag.postCount', 'tag.posts');
|
||||||
|
}
|
||||||
|
}
|
129
src/modules/content/services/category.service.ts
Normal file
129
src/modules/content/services/category.service.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { isNil, omit } from 'lodash';
|
||||||
|
import { EntityNotFoundError } from 'typeorm';
|
||||||
|
|
||||||
|
import { CreateCategoryDto, QueryCategoryDto, UpdateCategoryDto } from '@/modules/content/dtos';
|
||||||
|
import { CategoryEntity } from '@/modules/content/entities';
|
||||||
|
import { CategoryRepository } from '@/modules/content/repositories';
|
||||||
|
import { treePaginate } from '@/modules/database/helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分类数据操作
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CategoryService {
|
||||||
|
constructor(protected repository: CategoryRepository) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询分类树
|
||||||
|
*/
|
||||||
|
async findTress() {
|
||||||
|
return this.repository.findTrees();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分页数据
|
||||||
|
* @param options 分页选项
|
||||||
|
*/
|
||||||
|
async paginate(options: QueryCategoryDto) {
|
||||||
|
const tree = await this.repository.findTrees();
|
||||||
|
const data = await this.repository.toFlatTrees(tree);
|
||||||
|
|
||||||
|
return treePaginate(options, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据详情
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
async detail(id: string) {
|
||||||
|
return this.repository.findOneOrFail({
|
||||||
|
where: { id },
|
||||||
|
relations: ['parent'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增分类
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
async create(data: CreateCategoryDto) {
|
||||||
|
const item = await this.repository.save({
|
||||||
|
...data,
|
||||||
|
parent: await this.getParent(undefined, data.parent),
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.detail(item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新分类
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
async update(data: UpdateCategoryDto) {
|
||||||
|
await this.repository.update(data.id, omit(data, ['id', 'parent']));
|
||||||
|
const item = await this.detail(data.id);
|
||||||
|
const parent = await this.getParent(item.parent?.id, data.parent);
|
||||||
|
const sholdUpdateParent =
|
||||||
|
(!isNil(item.parent) && !isNil(parent) && item.parent.id !== parent.id) ||
|
||||||
|
(isNil(item.parent) && !isNil(parent)) ||
|
||||||
|
(!isNil(item.parent) && isNil(parent));
|
||||||
|
|
||||||
|
// 父分类单独更新
|
||||||
|
if (parent !== undefined && sholdUpdateParent) {
|
||||||
|
item.parent = parent;
|
||||||
|
await this.repository.save(item, { reload: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除分类
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
async delete(id: string) {
|
||||||
|
const item = await this.repository.findOneOrFail({
|
||||||
|
where: { id },
|
||||||
|
relations: ['parent', 'children'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 把子分类提升一级
|
||||||
|
if (!isNil(item.children) && item.children.length > 0) {
|
||||||
|
const nchildren = [...item.children].map((c) => {
|
||||||
|
c.parent = item.parent;
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.repository.save(nchildren, { reload: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.repository.remove(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取请求传入的父分类
|
||||||
|
* @param current 当前分类的ID
|
||||||
|
* @param parentId
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
123
src/modules/content/services/comment.service.ts
Normal file
123
src/modules/content/services/comment.service.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { isNil } from 'lodash';
|
||||||
|
|
||||||
|
import { EntityNotFoundError, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
|
import { CreateCommentDto, QueryCommentDto, QueryCommentTreeDto } from '@/modules/content/dtos';
|
||||||
|
import { CommentEntity } from '@/modules/content/entities';
|
||||||
|
import { CommentRepository, PostRepository } from '@/modules/content/repositories';
|
||||||
|
import { treePaginate } from '@/modules/database/helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评论数据操作
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CommentService {
|
||||||
|
constructor(
|
||||||
|
protected repository: CommentRepository,
|
||||||
|
protected postRepository: PostRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直接查询评论树
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
async findTrees(options: QueryCommentTreeDto = {}) {
|
||||||
|
return this.repository.findTrees({
|
||||||
|
addQuery: (qb) => {
|
||||||
|
return isNil(options.post) ? qb : qb.where('post.id = :id', { id: options.post });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找一篇文章的评论并分页
|
||||||
|
* @param dto
|
||||||
|
*/
|
||||||
|
async paginate(dto: QueryCommentDto) {
|
||||||
|
const { post, ...query } = dto;
|
||||||
|
const addQuery = (qb: SelectQueryBuilder<CommentEntity>) => {
|
||||||
|
const condition: Record<string, string> = {};
|
||||||
|
if (!isNil(post)) condition.post = post;
|
||||||
|
return Object.keys(condition).length > 0 ? qb.andWhere(condition) : qb;
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await this.repository.findRoots({ addQuery });
|
||||||
|
|
||||||
|
let comments: CommentEntity[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const c = data[i];
|
||||||
|
comments.push(
|
||||||
|
await this.repository.findDescendantsTree(c, {
|
||||||
|
addQuery,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
comments = await this.repository.toFlatTrees(comments);
|
||||||
|
return treePaginate(query, comments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增评论
|
||||||
|
* @param data
|
||||||
|
* @param user
|
||||||
|
*/
|
||||||
|
async create(data: CreateCommentDto) {
|
||||||
|
const parent = await this.getParent(undefined, data.parent);
|
||||||
|
|
||||||
|
if (!isNil(parent) && parent.post.id !== data.post) {
|
||||||
|
throw new ForbiddenException('Parent comment and child comment must belong same post!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await this.repository.save({
|
||||||
|
...data,
|
||||||
|
parent,
|
||||||
|
post: await this.getPost(data.post),
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.repository.findOneOrFail({ where: { id: item.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除评论
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
async delete(id: string) {
|
||||||
|
const comment = await this.repository.findOneOrFail({ where: { id: id ?? null } });
|
||||||
|
return this.repository.remove(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取评论所属文章实例
|
||||||
|
* @parem id
|
||||||
|
*/
|
||||||
|
protected async getPost(id: string) {
|
||||||
|
return !isNil(id) ? this.postRepository.findOneOrFail({ where: { id } }) : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取请求传入的父分类
|
||||||
|
* @param current 当前分类的ID
|
||||||
|
* @param parentId
|
||||||
|
*/
|
||||||
|
protected async getParent(current?: string, parentId?: string) {
|
||||||
|
if (current === parentId) return undefined;
|
||||||
|
let parent: CommentEntity | undefined;
|
||||||
|
if (parentId !== undefined) {
|
||||||
|
if (parentId === null) return null;
|
||||||
|
parent = await this.repository.findOne({
|
||||||
|
relations: ['parent', 'post'],
|
||||||
|
where: { id: parentId },
|
||||||
|
});
|
||||||
|
if (!parent)
|
||||||
|
throw new EntityNotFoundError(
|
||||||
|
CommentEntity,
|
||||||
|
`Parent comment ${parentId} not exists !`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
}
|
@ -1,2 +1,4 @@
|
|||||||
|
export * from './category.service';
|
||||||
|
export * from './comment.service';
|
||||||
export * from './post.service';
|
export * from './post.service';
|
||||||
export * from './sanitize.service';
|
export * from './tag.service';
|
||||||
|
@ -1,27 +1,37 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { isFunction, isNil, omit } from 'lodash';
|
import { isArray, isFunction, isNil, omit } from 'lodash';
|
||||||
|
|
||||||
import { EntityNotFoundError, IsNull, Not, SelectQueryBuilder } from 'typeorm';
|
import { EntityNotFoundError, In, IsNull, Not, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
import { PostOrderType } from '@/modules/content/constants';
|
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 { PostEntity } from '@/modules/content/entities';
|
||||||
import { PostRepository } from '@/modules/content/repositories';
|
import { CategoryRepository, PostRepository, TagRepository } from '@/modules/content/repositories';
|
||||||
|
|
||||||
|
import { CategoryService } from '@/modules/content/services/category.service';
|
||||||
import { paginate } from '@/modules/database/helpers';
|
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()
|
@Injectable()
|
||||||
export class PostService {
|
export class PostService {
|
||||||
constructor(protected repository: PostRepository) {}
|
constructor(
|
||||||
|
protected repository: PostRepository,
|
||||||
|
protected categoryRepository: CategoryRepository,
|
||||||
|
protected categoryService: CategoryService,
|
||||||
|
protected tagRepository: TagRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取分页数据
|
* 获取分页数据
|
||||||
* @param options 分页选项
|
* @param options 分页选项
|
||||||
* @param callback 添加额外的查询
|
* @param callback 添加额外的查询
|
||||||
*/
|
*/
|
||||||
async paginate(options: PaginateOptions, callback?: QueryHook<PostEntity>) {
|
async paginate(options: QueryPostDto, callback?: QueryHook<PostEntity>) {
|
||||||
const qb = await this.buildListQuery(this.repository.buildBaseQB(), options, callback);
|
const qb = await this.buildListQuery(this.repository.buildBaseQB(), options, callback);
|
||||||
return paginate(qb, options);
|
return paginate(qb, options);
|
||||||
}
|
}
|
||||||
@ -45,7 +55,21 @@ export class PostService {
|
|||||||
* @param data
|
* @param data
|
||||||
*/
|
*/
|
||||||
async create(data: CreatePostDto) {
|
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);
|
||||||
|
|
||||||
return this.detail(item.id);
|
return this.detail(item.id);
|
||||||
}
|
}
|
||||||
@ -55,7 +79,28 @@ export class PostService {
|
|||||||
* @param data
|
* @param data
|
||||||
*/
|
*/
|
||||||
async update(data: UpdatePostDto) {
|
async update(data: UpdatePostDto) {
|
||||||
await this.repository.update(data.id, omit(data, ['id']));
|
const post = await this.detail(data.id);
|
||||||
|
|
||||||
|
if (data.category !== undefined) {
|
||||||
|
// 更新分类
|
||||||
|
const category = isNil(data.category)
|
||||||
|
? null
|
||||||
|
: await this.categoryRepository.findOneByOrFail({ id: data.category });
|
||||||
|
post.category = category;
|
||||||
|
this.repository.save(post, { reload: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isArray(data.tags)) {
|
||||||
|
// 更新文章关联标签
|
||||||
|
await this.repository
|
||||||
|
.createQueryBuilder('post')
|
||||||
|
.relation(PostEntity, 'tags')
|
||||||
|
.of(post)
|
||||||
|
.addAndRemove(data.tags, post.tags ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.repository.update(data.id, omit(data, ['id', 'tags', 'category']));
|
||||||
|
|
||||||
return this.detail(data.id);
|
return this.detail(data.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,23 +121,26 @@ export class PostService {
|
|||||||
*/
|
*/
|
||||||
protected async buildListQuery(
|
protected async buildListQuery(
|
||||||
qb: SelectQueryBuilder<PostEntity>,
|
qb: SelectQueryBuilder<PostEntity>,
|
||||||
options: Record<string, any>,
|
options: FindParams,
|
||||||
callback?: QueryHook<PostEntity>,
|
callback?: QueryHook<PostEntity>,
|
||||||
) {
|
) {
|
||||||
const { orderBy, isPublished } = options;
|
const { category, tag, orderBy, isPublished } = options;
|
||||||
let newQb = qb;
|
|
||||||
if (typeof isPublished === 'boolean') {
|
if (typeof isPublished === 'boolean') {
|
||||||
newQb = isPublished
|
isPublished
|
||||||
? newQb.where({
|
? qb.where({
|
||||||
publishedAt: Not(IsNull()),
|
publishedAt: Not(IsNull()),
|
||||||
})
|
})
|
||||||
: newQb.where({
|
: qb.where({
|
||||||
publishedAt: IsNull(),
|
publishedAt: IsNull(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
newQb = this.queryOrderBy(newQb, orderBy);
|
|
||||||
if (callback) return callback(newQb);
|
this.queryOrderBy(qb, orderBy);
|
||||||
return newQb;
|
if (category) await this.queryByCategory(category, qb);
|
||||||
|
// 查询某个标签关联的文章
|
||||||
|
if (tag) qb.where('tags.id = :id', { id: tag });
|
||||||
|
if (callback) return callback(qb);
|
||||||
|
return qb;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -108,13 +156,31 @@ export class PostService {
|
|||||||
return qb.orderBy('post.updatedAt', 'DESC');
|
return qb.orderBy('post.updatedAt', 'DESC');
|
||||||
case PostOrderType.PUBLISHED:
|
case PostOrderType.PUBLISHED:
|
||||||
return qb.orderBy('post.publishedAt', 'DESC');
|
return qb.orderBy('post.publishedAt', 'DESC');
|
||||||
|
case PostOrderType.COMMENTCOUNT:
|
||||||
|
return qb.orderBy('commentCount', 'DESC');
|
||||||
case PostOrderType.CUSTOM:
|
case PostOrderType.CUSTOM:
|
||||||
return qb.orderBy('customOrder', 'DESC');
|
return qb.orderBy('customOrder', 'DESC');
|
||||||
default:
|
default:
|
||||||
return qb
|
return qb
|
||||||
.orderBy('post.createdAt', 'DESC')
|
.orderBy('post.createdAt', 'DESC')
|
||||||
.addOrderBy('post.updatedAt', 'DESC')
|
.addOrderBy('post.updatedAt', 'DESC')
|
||||||
.addOrderBy('post.publishedAt', 'DESC');
|
.addOrderBy('post.publishedAt', 'DESC')
|
||||||
|
.addOrderBy('post.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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
63
src/modules/content/services/tag.service.ts
Normal file
63
src/modules/content/services/tag.service.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { omit } from 'lodash';
|
||||||
|
|
||||||
|
import { CreateTagDto, QueryTagsDto, UpdateTagDto } from '@/modules/content/dtos';
|
||||||
|
import { TagRepository } from '@/modules/content/repositories';
|
||||||
|
import { paginate } from '@/modules/database/helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标签数据操作
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TagService {
|
||||||
|
constructor(protected repository: TagRepository) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取标签数据
|
||||||
|
* @param options 分页选项
|
||||||
|
* @param callback 添加额外的查询
|
||||||
|
*/
|
||||||
|
async paginate(options: QueryTagsDto) {
|
||||||
|
const qb = this.repository.buildBaseQB();
|
||||||
|
return paginate(qb, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询单个标签信息
|
||||||
|
* @param id
|
||||||
|
* @param callback 添加额外的查询
|
||||||
|
*/
|
||||||
|
async detail(id: string) {
|
||||||
|
const qb = this.repository.buildBaseQB();
|
||||||
|
qb.where(`tag.id = :id`, { id });
|
||||||
|
return qb.getOneOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建标签
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
async create(data: CreateTagDto) {
|
||||||
|
const item = await this.repository.save(data);
|
||||||
|
return this.detail(item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新标签
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
async update(data: UpdateTagDto) {
|
||||||
|
await this.repository.update(data.id, omit(data, ['id']));
|
||||||
|
return this.detail(data.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除标签
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
async delete(id: string) {
|
||||||
|
const item = await this.repository.findOneByOrFail({ id });
|
||||||
|
return this.repository.remove(item);
|
||||||
|
}
|
||||||
|
}
|
@ -3,7 +3,7 @@ import { DataSource, EventSubscriber } from 'typeorm';
|
|||||||
import { PostBodyType } from '@/modules/content/constants';
|
import { PostBodyType } from '@/modules/content/constants';
|
||||||
import { PostEntity } from '@/modules/content/entities';
|
import { PostEntity } from '@/modules/content/entities';
|
||||||
import { PostRepository } from '@/modules/content/repositories';
|
import { PostRepository } from '@/modules/content/repositories';
|
||||||
import { SanitizeService } from '@/modules/content/services';
|
import { SanitizeService } from '@/modules/content/services/sanitize.service';
|
||||||
|
|
||||||
@EventSubscriber()
|
@EventSubscriber()
|
||||||
export class PostSubscriber {
|
export class PostSubscriber {
|
||||||
|
@ -13,34 +13,61 @@ export const paginate = async <E extends ObjectLiteral>(
|
|||||||
options: PaginateOptions,
|
options: PaginateOptions,
|
||||||
): Promise<PaginateReturn<E>> => {
|
): Promise<PaginateReturn<E>> => {
|
||||||
const limit = isNil(options.limit) || options.limit < 1 ? 1 : options.limit;
|
const limit = isNil(options.limit) || options.limit < 1 ? 1 : options.limit;
|
||||||
|
|
||||||
const page = isNil(options.page) || options.page < 1 ? 1 : options.page;
|
const page = isNil(options.page) || options.page < 1 ? 1 : options.page;
|
||||||
|
|
||||||
const start = page >= 1 ? page - 1 : 0;
|
const start = page >= 1 ? page - 1 : 0;
|
||||||
|
|
||||||
const totalItems = await qb.getCount();
|
const totalItems = await qb.getCount();
|
||||||
|
|
||||||
qb.take(limit).skip(start * limit);
|
qb.take(limit).skip(start * limit);
|
||||||
|
|
||||||
const items = await qb.getMany();
|
const items = await qb.getMany();
|
||||||
|
|
||||||
const totalPages =
|
const totalPages =
|
||||||
totalItems % limit === 0
|
totalItems % limit === 0
|
||||||
? Math.floor(totalItems / limit)
|
? Math.floor(totalItems / limit)
|
||||||
: Math.floor(totalItems / limit) + 1;
|
: Math.floor(totalItems / limit) + 1;
|
||||||
|
|
||||||
const remainder = totalItems % limit !== 0 ? totalItems % limit : limit;
|
const remainder = totalItems % limit !== 0 ? totalItems % limit : limit;
|
||||||
|
|
||||||
const itemCount = page < totalPages ? limit : remainder;
|
const itemCount = page < totalPages ? limit : remainder;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
meta: {
|
meta: {
|
||||||
totalItems,
|
totalItems,
|
||||||
totalPages,
|
|
||||||
itemCount,
|
itemCount,
|
||||||
perPage: limit,
|
perPage: limit,
|
||||||
|
totalPages,
|
||||||
currentPage: page,
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user