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/swagger": "^7.1.16",
|
||||
"@nestjs/typeorm": "^10.0.1",
|
||||
"better-sqlite3": "^9.1.1",
|
||||
"better-sqlite3": "^9.2.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"deepmerge": "^4.3.1",
|
||||
@ -41,18 +41,18 @@
|
||||
"@nestjs/schematics": "^10.0.3",
|
||||
"@nestjs/testing": "^10.2.10",
|
||||
"@swc/cli": "^0.1.63",
|
||||
"@swc/core": "^1.3.99",
|
||||
"@swc/core": "^1.3.100",
|
||||
"@types/jest": "^29.5.10",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/node": "^20.10.2",
|
||||
"@types/sanitize-html": "^2.9.5",
|
||||
"@types/supertest": "^2.0.16",
|
||||
"@typescript-eslint/eslint-plugin": "^6.12.0",
|
||||
"@typescript-eslint/parser": "^6.12.0",
|
||||
"eslint": "^8.54.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.13.1",
|
||||
"@typescript-eslint/parser": "^6.13.1",
|
||||
"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",
|
||||
|
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',
|
||||
// 以下为sqlite配置
|
||||
type: 'better-sqlite3',
|
||||
database: resolve(__dirname, '../../back/database4.db'),
|
||||
database: resolve(__dirname, '../../back/database6.db'),
|
||||
synchronize: true,
|
||||
autoLoadEntities: true,
|
||||
});
|
||||
|
@ -13,5 +13,6 @@ export enum PostOrderType {
|
||||
CREATED = 'createdAt',
|
||||
UPDATED = 'updatedAt',
|
||||
PUBLISHED = 'publishedAt',
|
||||
COMMENTCOUNT = 'commentCount',
|
||||
CUSTOM = 'custom',
|
||||
}
|
||||
|
@ -2,21 +2,25 @@ import { Module } 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 { 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({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([PostEntity]),
|
||||
DatabaseModule.forRepository([PostRepository]),
|
||||
TypeOrmModule.forFeature(Object.values(entities)),
|
||||
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 {}
|
||||
|
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 './tag.controller';
|
||||
|
@ -13,10 +13,9 @@ import {
|
||||
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';
|
||||
|
||||
/**
|
||||
* 文章控制器
|
||||
@ -39,7 +38,7 @@ export class PostController {
|
||||
validationError: { target: false },
|
||||
}),
|
||||
)
|
||||
options: PaginateOptions,
|
||||
options: QueryPostDto,
|
||||
) {
|
||||
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 './tag.dto';
|
||||
|
@ -46,6 +46,14 @@ export class QueryPostDto implements PaginateOptions {
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
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 })
|
||||
@IsOptional({ always: true })
|
||||
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 './tag.entity';
|
||||
|
@ -1,55 +1,115 @@
|
||||
import { Exclude, Expose } from 'class-transformer';
|
||||
import { Exclude, Expose, Type } from 'class-transformer';
|
||||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryColumn,
|
||||
Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { PostBodyType } from '@/modules/content/constants';
|
||||
import { PostBodyType } from '../constants';
|
||||
|
||||
import { CategoryEntity } from './category.entity';
|
||||
import { CommentEntity } from './comment.entity';
|
||||
import { TagEntity } from './tag.entity';
|
||||
|
||||
@Exclude()
|
||||
@Entity('content_posts')
|
||||
export class PostEntity extends BaseEntity {
|
||||
@Expose()
|
||||
@PrimaryColumn({ type: 'varchar', generated: 'uuid', length: '36' })
|
||||
@PrimaryColumn({ type: 'varchar', generated: 'uuid', length: 36 })
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
@Column({ comment: '文章标题' })
|
||||
@Index({ fulltext: true })
|
||||
title: string;
|
||||
|
||||
@Expose({ groups: ['post-detail'] })
|
||||
@Column({ comment: '文章内容', type: 'text' })
|
||||
@Index({ fulltext: true })
|
||||
body: string;
|
||||
|
||||
@Expose()
|
||||
@Column({ comment: '文章摘要', nullable: true })
|
||||
summary: string;
|
||||
@Column({ comment: '文章描述', nullable: true })
|
||||
@Index({ fulltext: true })
|
||||
summary?: string;
|
||||
|
||||
@Expose()
|
||||
@Column({ comment: '关键字', type: 'simple-array', nullable: true })
|
||||
keywords?: string[];
|
||||
|
||||
@Expose()
|
||||
@Column({ comment: '文章类型', type: 'varchar', default: PostBodyType.MD })
|
||||
@Column({
|
||||
comment: '文章类型',
|
||||
type: 'varchar',
|
||||
// 如果是mysql或者postgresql你可以使用enum类型
|
||||
// enum: PostBodyType,
|
||||
default: PostBodyType.MD,
|
||||
})
|
||||
type: PostBodyType;
|
||||
|
||||
@Expose()
|
||||
@Column({ comment: '发布时间', type: 'varchar', nullable: true })
|
||||
@Column({
|
||||
comment: '发布时间',
|
||||
type: 'varchar',
|
||||
nullable: true,
|
||||
})
|
||||
publishedAt?: Date | null;
|
||||
|
||||
@Expose()
|
||||
@CreateDateColumn({ comment: '创建时间' })
|
||||
@Column({ comment: '自定义文章排序', default: 0 })
|
||||
customOrder: number;
|
||||
|
||||
@Expose()
|
||||
@CreateDateColumn({
|
||||
comment: '创建时间',
|
||||
})
|
||||
createdAt: Date;
|
||||
|
||||
@Expose()
|
||||
@UpdateDateColumn({ comment: '更新时间' })
|
||||
@UpdateDateColumn({
|
||||
comment: '更新时间',
|
||||
})
|
||||
updatedAt: Date;
|
||||
|
||||
@Expose()
|
||||
@Column({ comment: '文章自定义排序', default: 0 })
|
||||
customOrder: number;
|
||||
@Type(() => Date)
|
||||
@DeleteDateColumn({
|
||||
comment: '删除时间',
|
||||
})
|
||||
deletedAt: Date;
|
||||
|
||||
/**
|
||||
* 通过queryBuilder生成的评论数量(虚拟字段)
|
||||
*/
|
||||
@Expose()
|
||||
commentCount: number;
|
||||
|
||||
@Expose()
|
||||
@ManyToOne(() => CategoryEntity, (category) => category.posts, {
|
||||
nullable: true,
|
||||
onDelete: 'SET NULL',
|
||||
})
|
||||
category: Relation<CategoryEntity>;
|
||||
|
||||
@Expose()
|
||||
@ManyToMany(() => TagEntity, (tag) => tag.posts, {
|
||||
cascade: true,
|
||||
})
|
||||
@JoinTable()
|
||||
tags: Relation<TagEntity>[];
|
||||
|
||||
@OneToMany(() => CommentEntity, (comment) => comment.post, {
|
||||
cascade: true,
|
||||
})
|
||||
comments: Relation<CommentEntity>[];
|
||||
}
|
||||
|
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 './tag.repository';
|
||||
|
@ -1,11 +1,21 @@
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { PostEntity } from '@/modules/content/entities';
|
||||
import { CommentEntity, PostEntity } from '@/modules/content/entities';
|
||||
import { CustomRepository } from '@/modules/database/decorators';
|
||||
|
||||
@CustomRepository(PostEntity)
|
||||
export class PostRepository extends Repository<PostEntity> {
|
||||
buildBaseQB() {
|
||||
return this.createQueryBuilder('post');
|
||||
// 在查询之前先查询出评论数量在添加到commentCount字段上
|
||||
return this.createQueryBuilder('post')
|
||||
.leftJoinAndSelect('post.category', 'category')
|
||||
.leftJoinAndSelect('post.tags', 'tags')
|
||||
.addSelect((subQuery) => {
|
||||
return subQuery
|
||||
.select('COUNT(c.id)', 'count')
|
||||
.from(CommentEntity, 'c')
|
||||
.where('c.post.id = post.id');
|
||||
}, 'commentCount')
|
||||
.loadRelationCountAndMap('post.commentCount', 'post.comments');
|
||||
}
|
||||
}
|
||||
|
18
src/modules/content/repositories/tag.repository.ts
Normal file
18
src/modules/content/repositories/tag.repository.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { PostEntity, TagEntity } from '@/modules/content/entities';
|
||||
import { CustomRepository } from '@/modules/database/decorators';
|
||||
|
||||
@CustomRepository(TagEntity)
|
||||
export class TagRepository extends Repository<TagEntity> {
|
||||
buildBaseQB() {
|
||||
return this.createQueryBuilder('tag')
|
||||
.leftJoinAndSelect('tag.posts', 'posts')
|
||||
.addSelect(
|
||||
(subQuery) => subQuery.select('COUNT(p.id)', 'count').from(PostEntity, 'p'),
|
||||
'postCount',
|
||||
)
|
||||
.orderBy('postCount', 'DESC')
|
||||
.loadRelationCountAndMap('tag.postCount', 'tag.posts');
|
||||
}
|
||||
}
|
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 './sanitize.service';
|
||||
export * from './tag.service';
|
||||
|
@ -1,27 +1,37 @@
|
||||
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 { CreatePostDto, UpdatePostDto } from '@/modules/content/dtos';
|
||||
import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos';
|
||||
import { PostEntity } from '@/modules/content/entities';
|
||||
import { PostRepository } from '@/modules/content/repositories';
|
||||
import { CategoryRepository, PostRepository, TagRepository } from '@/modules/content/repositories';
|
||||
|
||||
import { CategoryService } from '@/modules/content/services/category.service';
|
||||
import { paginate } from '@/modules/database/helpers';
|
||||
import { PaginateOptions, QueryHook } from '@/modules/database/types';
|
||||
import { QueryHook } from '@/modules/database/types';
|
||||
|
||||
type FindParams = {
|
||||
[key in keyof Omit<QueryPostDto, 'limit' | 'page'>]: QueryPostDto[key];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PostService {
|
||||
constructor(protected repository: PostRepository) {}
|
||||
constructor(
|
||||
protected repository: PostRepository,
|
||||
protected categoryRepository: CategoryRepository,
|
||||
protected categoryService: CategoryService,
|
||||
protected tagRepository: TagRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取分页数据
|
||||
* @param options 分页选项
|
||||
* @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);
|
||||
return paginate(qb, options);
|
||||
}
|
||||
@ -45,7 +55,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);
|
||||
|
||||
return this.detail(item.id);
|
||||
}
|
||||
@ -55,7 +79,28 @@ export class PostService {
|
||||
* @param data
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
@ -76,23 +121,26 @@ 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 } = options;
|
||||
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 (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');
|
||||
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('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 { PostEntity } from '@/modules/content/entities';
|
||||
import { PostRepository } from '@/modules/content/repositories';
|
||||
import { SanitizeService } from '@/modules/content/services';
|
||||
import { SanitizeService } from '@/modules/content/services/sanitize.service';
|
||||
|
||||
@EventSubscriber()
|
||||
export class PostSubscriber {
|
||||
|
@ -13,34 +13,61 @@ export const paginate = async <E extends ObjectLiteral>(
|
||||
options: PaginateOptions,
|
||||
): Promise<PaginateReturn<E>> => {
|
||||
const limit = isNil(options.limit) || options.limit < 1 ? 1 : options.limit;
|
||||
|
||||
const page = isNil(options.page) || options.page < 1 ? 1 : options.page;
|
||||
|
||||
const start = page >= 1 ? page - 1 : 0;
|
||||
|
||||
const totalItems = await qb.getCount();
|
||||
|
||||
qb.take(limit).skip(start * limit);
|
||||
|
||||
const items = await qb.getMany();
|
||||
|
||||
const totalPages =
|
||||
totalItems % limit === 0
|
||||
? Math.floor(totalItems / limit)
|
||||
: Math.floor(totalItems / limit) + 1;
|
||||
|
||||
const remainder = totalItems % limit !== 0 ? totalItems % limit : limit;
|
||||
|
||||
const itemCount = page < totalPages ? limit : remainder;
|
||||
|
||||
return {
|
||||
items,
|
||||
meta: {
|
||||
totalItems,
|
||||
totalPages,
|
||||
itemCount,
|
||||
perPage: limit,
|
||||
totalPages,
|
||||
currentPage: page,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 数据手动分页函数
|
||||
* @param options 分页选项
|
||||
* @param data 数据列表
|
||||
*/
|
||||
export const treePaginate = <E extends ObjectLiteral>(
|
||||
options: PaginateOptions,
|
||||
data: E[],
|
||||
): PaginateReturn<E> => {
|
||||
const { page, limit } = options;
|
||||
let items: E[] = [];
|
||||
const totalItems = data.length;
|
||||
const totalRst = totalItems / limit;
|
||||
const totalPages =
|
||||
totalRst > Math.floor(totalRst) ? Math.floor(totalRst) + 1 : Math.floor(totalRst);
|
||||
|
||||
let itemCount = 0;
|
||||
|
||||
if (page <= totalPages) {
|
||||
itemCount = page === totalPages ? totalItems - (totalPages - 1) * limit : limit;
|
||||
const start = (page - 1) * limit;
|
||||
items = data.slice(start, start + itemCount);
|
||||
}
|
||||
|
||||
return {
|
||||
meta: {
|
||||
itemCount,
|
||||
totalItems,
|
||||
perPage: limit,
|
||||
totalPages,
|
||||
currentPage: page,
|
||||
},
|
||||
items,
|
||||
};
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user