feat:数据关联与树形数据嵌套结构的分类和评论实现

- 实现分类、评论、标签实体
- 使用了Materialized Path实现树形嵌套
- 对分类、评论、标签的存储库进行自定义改造
This commit is contained in:
3R-喜东东 2023-12-03 18:06:47 +08:00
parent cce9a6b179
commit 781962dce0
34 changed files with 1756 additions and 415 deletions

Binary file not shown.

Binary file not shown.

View File

@ -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",

File diff suppressed because it is too large Load Diff

View File

@ -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,
});

View File

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

View File

@ -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 {}

View 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);
}
}

View 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);
}
}

View File

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

View File

@ -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);
}

View 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);
}
}

View 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;
}

View 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;
}

View File

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

View File

@ -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[];
}
/**

View 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;
}

View File

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

View File

@ -0,0 +1,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[]>;
}

View File

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

View File

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

View File

@ -0,0 +1,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;
}

View 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[];
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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;
}
}

View 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;
}
}

View File

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

View File

@ -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,
});
}
}

View 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);
}
}

View File

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

View File

@ -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,
};
};