Compare commits

...

10 Commits

Author SHA1 Message Date
db465b70c8 add controller 2025-05-21 23:24:28 +08:00
dca8ed67af add controller 2025-05-21 23:23:54 +08:00
e3f3497406 add controller 2025-05-21 23:15:35 +08:00
af6906f232 add controller 2025-05-21 23:14:37 +08:00
552cbf2e57 add controller 2025-05-21 22:47:16 +08:00
619a59e33e add vscode config 2025-05-21 22:29:41 +08:00
b25a85bf06 add controller 2025-05-21 22:29:18 +08:00
4c063515ba add service 2025-05-21 21:20:25 +08:00
8ab109ce26 add service 2025-05-21 18:50:11 +08:00
388ab50718 add service 2025-05-21 16:14:36 +08:00
22 changed files with 568 additions and 42 deletions

18
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,18 @@
{
// 使 IntelliSense
//
// 访: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "debug 3r",
"request": "launch",
"runtimeArgs": ["run-script", "start:debug"],
"autoAttachChildProcesses": true,
"console": "integratedTerminal",
"runtimeExecutable": "pnpm",
"skipFiles": ["<node_internals>/**"],
"type": "node"
}
]
}

13
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,13 @@
{
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"javascript.preferences.importModuleSpecifier": "project-relative",
"typescript.suggest.jsdoc.generateReturns": false,
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
"typescript.tsdk": "node_modules/typescript/lib",
"eslint.packageManager": "pnpm",
"stylelint.packageManager": "pnpm",
"npm.packageManager": "pnpm"
}

View File

@ -7,5 +7,14 @@ export enum PostOrder {
CREATED = 'createdAt',
UPDATED = 'updatedAt',
PUBLISHED = 'publishedAt',
COMMENTCOUNT = 'commentCount',
CUSTOM = 'custom',
}
export const DEFAULT_VALIDATION_CONFIG = Object.freeze({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
validationError: { target: false },
});

View File

@ -2,23 +2,25 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PostEntity } from '@/modules/content/entities/post.entity';
import { PostRepository } from '@/modules/content/repositories/post.repository';
import * as controllers from '@/modules/content/controllers';
import * as entities from '@/modules/content/entities';
import * as repositories from '@/modules/content/repositories';
import * as services from '@/modules/content/services';
import { SanitizeService } from '@/modules/content/services/SanitizeService';
import { PostService } from '@/modules/content/services/post.service';
import { PostSubscriber } from '@/modules/content/subscribers/post.subscriber';
import { DatabaseModule } from '@/modules/database/database.module';
import { PostController } from './controllers/post.controller';
@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,81 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
SerializeOptions,
UseInterceptors,
ValidationPipe,
} from '@nestjs/common';
import { AppInterceptor } from '@/modules/core/providers/app.interceptor';
import { DEFAULT_VALIDATION_CONFIG } from '../constants';
import { CreateCategoryDto, QueryCategoryDto, UpdateCategoryDto } from '../dtos/category.dto';
import { CategoryService } from '../services';
@UseInterceptors(AppInterceptor)
@Controller('category')
export class CategoryController {
constructor(protected service: CategoryService) {}
@Get('tree')
@SerializeOptions({ groups: ['category-tree'] })
async tree() {
return this.service.findTrees();
}
@Get()
@SerializeOptions({ groups: ['category-list'] })
async list(
@Query(new ValidationPipe(DEFAULT_VALIDATION_CONFIG))
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 store(
@Body(
new ValidationPipe({
...DEFAULT_VALIDATION_CONFIG,
groups: ['create'],
}),
)
data: CreateCategoryDto,
) {
return this.service.create(data);
}
@Patch()
@SerializeOptions({ groups: ['category-detail'] })
async update(
@Body(
new ValidationPipe({
...DEFAULT_VALIDATION_CONFIG,
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,58 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
SerializeOptions,
UseInterceptors,
ValidationPipe,
} from '@nestjs/common';
import { pick } from 'lodash';
import { AppInterceptor } from '@/modules/core/providers/app.interceptor';
import { DEFAULT_VALIDATION_CONFIG } from '../constants';
import { CreateCommentDto, QueryCommentDto, QueryCommentTreeDto } from '../dtos/comment.dto';
import { CommentService } from '../services';
@Controller('comment')
@UseInterceptors(AppInterceptor)
export class CommentController {
constructor(protected service: CommentService) {}
@Get('tree')
@SerializeOptions({ groups: ['comment-tree'] })
async tree(@Query(new ValidationPipe(DEFAULT_VALIDATION_CONFIG)) options: QueryCommentTreeDto) {
return this.service.findTrees(options);
}
@Get()
@SerializeOptions({ groups: ['comment-list'] })
async list(
@Query(
new ValidationPipe({
...pick(DEFAULT_VALIDATION_CONFIG, ['forbidNonWhitelisted', 'whitelist']),
}),
)
options: QueryCommentDto,
) {
return this.service.paginate(options);
}
@Post()
@SerializeOptions({ groups: ['comment-detail'] })
async store(@Body(new ValidationPipe(DEFAULT_VALIDATION_CONFIG)) 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

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

View File

@ -17,6 +17,8 @@ import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dt
import { PostService } from '@/modules/content/services/post.service';
import { AppInterceptor } from '@/modules/core/providers/app.interceptor';
import { DEFAULT_VALIDATION_CONFIG } from '../constants';
@UseInterceptors(AppInterceptor)
@Controller('posts')
export class PostController {
@ -25,15 +27,7 @@ export class PostController {
@Get()
@SerializeOptions({ groups: ['post-list'] })
async list(
@Query(
new ValidationPipe({
transform: true,
whitelist: true,
forbidUnknownValues: true,
forbidNonWhitelisted: true,
validationError: { target: false },
}),
)
@Query(new ValidationPipe(DEFAULT_VALIDATION_CONFIG))
options: QueryPostDto,
) {
return this.postService.paginate(options);
@ -50,11 +44,7 @@ export class PostController {
async store(
@Body(
new ValidationPipe({
transform: true,
whitelist: true,
forbidUnknownValues: true,
forbidNonWhitelisted: true,
validationError: { target: false },
...DEFAULT_VALIDATION_CONFIG,
groups: ['create'],
}),
)
@ -68,11 +58,7 @@ export class PostController {
async update(
@Body(
new ValidationPipe({
transform: true,
whitelist: true,
forbidUnknownValues: true,
forbidNonWhitelisted: true,
validationError: { target: false },
...DEFAULT_VALIDATION_CONFIG,
groups: ['update'],
}),
)

View File

@ -0,0 +1,65 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
SerializeOptions,
UseInterceptors,
ValidationPipe,
} from '@nestjs/common';
import { AppInterceptor } from '@/modules/core/providers/app.interceptor';
import { DEFAULT_VALIDATION_CONFIG } from '../constants';
import { CreateTagDto, QueryTagDto, UpdateTagDto } from '../dtos/tag.dto';
import { TagService } from '../services';
@Controller('tag')
@UseInterceptors(AppInterceptor)
export class TagController {
constructor(protected service: TagService) {}
@Get()
@SerializeOptions({})
async list(
@Query(new ValidationPipe(DEFAULT_VALIDATION_CONFIG))
options: QueryTagDto,
) {
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({ ...DEFAULT_VALIDATION_CONFIG, groups: ['create'] }))
data: CreateTagDto,
) {
return this.service.create(data);
}
@Patch()
@SerializeOptions({})
async update(
@Body(new ValidationPipe({ ...DEFAULT_VALIDATION_CONFIG, groups: ['update'] }))
date: UpdateTagDto,
) {
return this.service.update(date);
}
@Delete(':id')
@SerializeOptions({})
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
return this.service.delete(id);
}
}

View File

@ -43,7 +43,7 @@ export class CreateCategoryDto {
})
@ValidateIf((value) => value.parent !== null && value.parent)
@IsOptional({ always: true })
@Transform((value) => (value === 'null' ? null : value))
@Transform(({ value }) => (value === 'null' ? null : value))
parent?: string;
@Transform(({ value }) => toNumber(value))

View File

@ -11,6 +11,8 @@ import {
} from 'class-validator';
import { toNumber } from 'lodash';
import { PaginateOptions } from '@/modules/database/types';
export class QueryTagDto implements PaginateOptions {
@Transform(({ value }) => toNumber(value))
@Min(1, { message: 'The current page must be greater than 1.' })

View File

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

View File

@ -13,7 +13,7 @@ import {
} from 'typeorm';
import { PostBodyType } from '@/modules/content/constants';
import { CategoryEntity } from '@/modules/content/entities/CategoryEntity';
import { CategoryEntity } from '@/modules/content/entities/category.entity';
import { CommentEntity } from '@/modules/content/entities/comment.entity';
import { TagEntity } from '@/modules/content/entities/tag.entity';

View File

@ -1,7 +1,7 @@
import { pick, unset } from 'lodash';
import { FindOptionsUtils, FindTreeOptions, TreeRepository, TreeRepositoryUtils } from 'typeorm';
import { CategoryEntity } from '@/modules/content/entities/CategoryEntity';
import { CategoryEntity } from '@/modules/content/entities/category.entity';
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
@CustomRepository(CategoryEntity)

View File

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

View File

@ -0,0 +1,93 @@
import { Injectable } from '@nestjs/common';
import { isNil, omit } from 'lodash';
import { EntityNotFoundError } from 'typeorm';
import {
CreateCategoryDto,
QueryCategoryDto,
UpdateCategoryDto,
} from '@/modules/content/dtos/category.dto';
import { CategoryEntity } from '@/modules/content/entities/category.entity';
import { CategoryRepository } from '@/modules/content/repositories/category.repository';
import { treePaginate } from '@/modules/database/utils';
@Injectable()
export class CategoryService {
constructor(protected repository: CategoryRepository) {}
async findTrees() {
return this.repository.findTrees();
}
async paginate(options?: QueryCategoryDto) {
const tree = await this.findTrees();
const data = await this.repository.toFlatTrees(tree);
return treePaginate(options, data);
}
async detail(id: string) {
return this.repository.findOneOrFail({ where: { id }, relations: ['parent'] });
}
async create(data: CreateCategoryDto) {
const item = await this.repository.save({
...data,
parent: await this.getParent(undefined, data.parent),
});
return this.detail(item.id);
}
async update(data: UpdateCategoryDto) {
await this.repository.update(data.id, omit(data, ['id', 'parent']));
const item = await this.repository.findOneOrFail({
where: { id: data.id },
relations: ['parent'],
});
const parent = await this.getParent(item.parent?.id, data.parent);
const shouldUpdateParent =
(!isNil(item.parent) && !isNil(parent) && item.parent.id !== parent.id) ||
(!isNil(item.parent) && !isNil(parent)) ||
(!isNil(item.parent) && isNil(parent));
if (shouldUpdateParent && parent !== undefined) {
item.parent = parent;
await this.repository.save(item, { reload: true });
}
return item;
}
async delete(id: string) {
const item = await this.repository.findOneOrFail({
where: { id },
relations: ['parent', 'children'],
});
if (!isNil(item.children) && item.children.length > 0) {
const childrenCategories = [...item.children].map((c) => {
c.parent = item.parent;
return item;
});
await this.repository.save(childrenCategories, { reload: true });
}
return this.repository.remove(item);
}
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 with id ${parentId} not exists!`,
);
}
}
return parent;
}
}

View File

@ -0,0 +1,93 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { isNil } from 'lodash';
import { EntityNotFoundError, SelectQueryBuilder } from 'typeorm';
import {
CreateCommentDto,
QueryCommentDto,
QueryCommentTreeDto,
} from '@/modules/content/dtos/comment.dto';
import { CommentEntity } from '@/modules/content/entities/comment.entity';
import { treePaginate } from '@/modules/database/utils';
import { CommentRepository } from '../repositories/comment.repository';
import { PostRepository } from '../repositories/post.repository';
@Injectable()
export class CommentService {
constructor(
protected repository: CommentRepository,
protected postRepository: PostRepository,
) {}
async findTrees(options: QueryCommentTreeDto = {}) {
return this.repository.findTrees({
addQuery: (qb) => {
return isNil(options.post) ? qb : qb.where('post.id = :id', { id: options.post });
},
});
}
async paginate(options: QueryCommentDto) {
const { post, ...query } = options;
const addQuery = (qb: SelectQueryBuilder<CommentEntity>) => {
const condition: RecordString = {};
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 comment = data[i];
comments.push(await this.repository.findDescendantsTree(comment, { addQuery }));
}
comments = await this.repository.toFlatTrees(comments);
return treePaginate(query, comments);
}
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 } });
}
async delete(id: string) {
const comment = await this.repository.findOneOrFail({ where: { id: id ?? null } });
return this.repository.remove(comment);
}
protected async getPost(id: string) {
return isNil(id) ? null : this.postRepository.findOneOrFail({ where: { id } });
}
protected async getParent(current?: string, id?: string) {
if (current === id) {
return undefined;
}
let parent: CommentEntity | undefined;
if (id !== undefined) {
if (id === null) {
return null;
}
parent = await this.repository.findOne({
where: { id },
relations: ['parent', 'children'],
});
if (!parent) {
throw new EntityNotFoundError(CommentEntity, `Parent Comment ${id} not found`);
}
}
return parent;
}
}

View File

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

View File

@ -1,21 +1,35 @@
import { Injectable } from '@nestjs/common';
import { isNil } from '@nestjs/common/utils/shared.utils';
import { isFunction, omit } from 'lodash';
import { EntityNotFoundError, IsNull, Not, SelectQueryBuilder } from 'typeorm';
import { isArray, isFunction, omit } from 'lodash';
import { EntityNotFoundError, In, IsNull, Not, SelectQueryBuilder } from 'typeorm';
import { PostOrder } from '@/modules/content/constants';
import { CreatePostDto, UpdatePostDto } from '@/modules/content/dtos/post.dto';
import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos/post.dto';
import { PostEntity } from '@/modules/content/entities/post.entity';
import { CategoryRepository } from '@/modules/content/repositories';
import { PostRepository } from '@/modules/content/repositories/post.repository';
import { PaginateOptions, QueryHook } from '@/modules/database/types';
import { QueryHook } from '@/modules/database/types';
import { paginate } from '@/modules/database/utils';
import { TagRepository } from '../repositories/tag.repository';
import { CategoryService } from './category.service';
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,
) {}
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);
}
@ -36,7 +50,15 @@ export class PostService {
if (!isNil(data.publish)) {
publishedAt = data.publish ? new Date() : null;
}
const item = await this.repository.save({ ...omit(data, ['publish']), publishedAt });
const createPostDto = {
...omit(data, ['publish']),
category: isNil(data.category)
? null
: await this.categoryRepository.findOneOrFail({ where: { id: data.category } }),
tags: isArray(data.tags) ? await this.tagRepository.findBy({ id: In(data.tags) }) : [],
publishedAt,
};
const item = await this.repository.save(createPostDto);
return this.detail(item.id);
}
@ -45,8 +67,22 @@ export class PostService {
if (!isNil(data.publish)) {
publishedAt = data.publish ? new Date() : null;
}
const post = await this.detail(data.id);
if (data.category !== undefined) {
post.category = isNil(data.category)
? null
: await this.categoryRepository.findOneByOrFail({ id: data.category });
await 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', 'publish']),
...omit(data, ['id', 'publish', 'tags', 'category']),
publishedAt,
});
return this.detail(data.id);
@ -59,16 +95,22 @@ export class PostService {
protected async buildListQuery(
qb: SelectQueryBuilder<PostEntity>,
options: RecordAny,
options: FindParams,
callback?: QueryHook<PostEntity>,
) {
const { orderBy, isPublished } = options;
const { orderBy, isPublished, category, tag } = options;
if (typeof isPublished === 'boolean') {
isPublished
? qb.where({ publishedAt: Not(IsNull()) })
: qb.where({ publishedAt: IsNull() });
}
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);
}
@ -85,6 +127,8 @@ export class PostService {
return qb.orderBy('post.publishedAt', 'DESC');
case PostOrder.CUSTOM:
return qb.orderBy('post.customOrder', 'DESC');
case PostOrder.COMMENTCOUNT:
return qb.orderBy('post.commentCount', 'DESC');
default:
return qb
.orderBy('post.createdAt', 'DESC')
@ -92,4 +136,12 @@ export class PostService {
.addOrderBy('post.publishedAt', 'DESC');
}
}
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('categoryRepository.id IN (:...ids)', { ids });
}
}

View File

@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { omit } from 'lodash';
import { CreateTagDto, QueryTagDto, UpdateTagDto } from '@/modules/content/dtos/tag.dto';
import { TagRepository } from '@/modules/content/repositories/tag.repository';
import { paginate } from '@/modules/database/utils';
@Injectable()
export class TagService {
constructor(protected repository: TagRepository) {}
async paginate(options: QueryTagDto) {
const qb = this.repository.buildBaseQB();
return paginate(qb, options);
}
async detail(id: string) {
const qb = this.repository.buildBaseQB();
qb.where(`tag.id = :id`, { id });
return qb.getOneOrFail();
}
async create(data: CreateTagDto) {
const item = await this.repository.save(data);
return this.detail(item.id);
}
async update(data: UpdateTagDto) {
await this.repository.update(data.id, omit(data, ['id']));
return this.detail(data.id);
}
async delete(id: string) {
const item = await this.repository.findOneByOrFail({ id });
return this.repository.remove(item);
}
}

1
typings/global.d.ts vendored
View File

@ -1,5 +1,6 @@
declare type RecordAny = Record<string, any>;
declare type RecordNever = Record<never, never>;
declare type RecordString = Record<string, string>;
declare type RecordAnyOrNever = RecordAny | RecordNever;
declare type BaseType = boolean | number | string | undefined | null;