diff --git a/package.json b/package.json index 33702915..a8b2ca48 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@nestjs/config": "^1.0.1", "@nestjs/core": "^8.0.6", "@nestjs/jwt": "^8.0.0", + "@nestjs/mapped-types": "*", "@nestjs/passport": "^8.0.1", "@nestjs/platform-fastify": "^8.0.6", "@nestjs/platform-socket.io": "8.0.6", @@ -59,12 +60,14 @@ "class-transformer": "^0.4.0", "class-validator": "^0.13.1", "dayjs": "^1.10.6", + "dotenv": "*", "ejs": "^3.1.6", "fastify-swagger": "^4.9.0", "image-size": "^1.0.0", + "inquirer": "*", "lodash": "*", "mongoose": "*", - "dotenv": "*", + "mongoose-lean-id": "^0.2.0", "mongoose-lean-virtuals": "^0.8.0", "mongoose-paginate-v2": "^1.4.2", "nanoid": "^3.1.25", @@ -76,8 +79,7 @@ "redis": "3.1.2", "reflect-metadata": "^0.1.13", "rxjs": "^7.3.0", - "snakecase-keys": "^4.0.2", - "inquirer": "*" + "snakecase-keys": "^4.0.2" }, "devDependencies": { "@innei-util/eslint-config-ts": "^0.2.3", diff --git a/patch/v2.0.0-alpha.1.ts b/patch/v2.0.0-alpha.1.ts index ad867756..cffa6ffe 100644 --- a/patch/v2.0.0-alpha.1.ts +++ b/patch/v2.0.0-alpha.1.ts @@ -12,6 +12,6 @@ patch(async ({ models: { note, post, category } }) => { }, ) }), - category.aggregate([{ $unset: 'count' }]), + category.updateMany({}, { $unset: { count: '' } }), ]) }) diff --git a/paw.paw b/paw.paw index 6be5f7f5..c0d04aa9 100644 Binary files a/paw.paw and b/paw.paw differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45ce7803..08560b17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,7 @@ specifiers: '@nestjs/config': ^1.0.1 '@nestjs/core': ^8.0.6 '@nestjs/jwt': ^8.0.0 + '@nestjs/mapped-types': '*' '@nestjs/passport': ^8.0.1 '@nestjs/platform-fastify': ^8.0.6 '@nestjs/platform-socket.io': 8.0.6 @@ -52,6 +53,7 @@ specifiers: lint-staged: ^11.1.2 lodash: '*' mongoose: '*' + mongoose-lean-id: ^0.2.0 mongoose-lean-virtuals: ^0.8.0 mongoose-paginate-v2: ^1.4.2 nanoid: ^3.1.25 @@ -82,6 +84,7 @@ dependencies: '@nestjs/config': 1.0.1_e2399148d990aa1f9a4b6ba2ac7a5b74 '@nestjs/core': 8.0.6_214ebf00327c8ed1d6618d61764e6a91 '@nestjs/jwt': 8.0.0_@nestjs+common@8.0.6 + '@nestjs/mapped-types': 1.0.0_98ff46db2ce9f8b87e20a19aa1e55592 '@nestjs/passport': 8.0.1_2c02db70fddcb59258fa0eed39c4b725 '@nestjs/platform-fastify': 8.0.6_67f7e5db8827badcb202b1d38f6b1aea '@nestjs/platform-socket.io': 8.0.6_875c1aa90becd3a53d7e39e33971fbfe @@ -105,6 +108,7 @@ dependencies: inquirer: 8.1.1 lodash: 4.17.21 mongoose: 5.13.8 + mongoose-lean-id: 0.2.0_mongoose@5.13.8 mongoose-lean-virtuals: 0.8.0_mongoose@5.13.8 mongoose-paginate-v2: 1.4.2 nanoid: 3.1.25 @@ -5130,6 +5134,14 @@ packages: saslprep: 1.0.3 dev: false + /mongoose-lean-id/0.2.0_mongoose@5.13.8: + resolution: {integrity: sha512-ioC++wVTfprE2wh7Baw4jdZ1mCIpK3uw74sWf53enNCKZHVhKsL7fMyB1ryb4SmGwOj4StebTGy7YwimjZ0Qlg==} + peerDependencies: + mongoose: 4.x || 5.x + dependencies: + mongoose: 5.13.8 + dev: false + /mongoose-lean-virtuals/0.8.0_mongoose@5.13.8: resolution: {integrity: sha512-pOdHnLPXtPoVJgx/Fjju2eejaoWjiE3OEaYosF0ELnpUv8KxoIOjNdHLgKyhD+7W7Nd0S755LzZJgSESE2/acg==} peerDependencies: diff --git a/src/modules/category/category.controller.ts b/src/modules/category/category.controller.ts index 7f959eaa..2807e4fb 100644 --- a/src/modules/category/category.controller.ts +++ b/src/modules/category/category.controller.ts @@ -1,7 +1,37 @@ -import { Controller, forwardRef, Get, Inject, Query } from '@nestjs/common' +import { + BadRequestException, + Body, + Controller, + Delete, + forwardRef, + Get, + HttpCode, + Inject, + Param, + Patch, + Post, + Put, + Query, +} from '@nestjs/common' +import { ApiQuery } from '@nestjs/swagger' +import { Types } from 'mongoose' +import { Auth } from '~/common/decorator/auth.decorator' import { ApiName } from '~/common/decorator/openapi.decorator' +import { IsMaster } from '~/common/decorator/role.decorator' +import { CannotFindException } from '~/common/exceptions/cant-find.exception' +import { MongoIdDto } from '~/shared/dto/id.dto' +import { addConditionToSeeHideContent } from '~/utils/query.util' import { PostService } from '../post/post.service' -import { CategoryType, MultiCategoriesQueryDto } from './category.dto' +import { + MultiCategoriesQueryDto, + MultiQueryTagAndCategoryDto, + SlugOrIdDto, +} from './category.dto' +import { + CategoryModel, + CategoryType, + PartialCategoryModel, +} from './category.model' import { CategoryService } from './category.service' @Controller({ path: 'categories' }) @@ -14,33 +44,35 @@ export class CategoryController { ) {} @Get('/') - async getCategories(@Query() query: MultiCategoriesQueryDto) { + async getCategories( + @Query() query: MultiCategoriesQueryDto, + @IsMaster() isMaster: boolean, + ) { const { ids, joint, type = CategoryType.Category } = query // categories is category's mongo id if (ids) { return joint ? await Promise.all( ids.map(async (id) => { - return await this.postService.model.find( - { categoryId: id }, - { - select: 'title slug _id categoryId created modified', - sort: { created: -1 }, - }, - ) + return await this.postService.model + .find( + { categoryId: id, ...addConditionToSeeHideContent(isMaster) }, + 'title slug _id categoryId created modified', + ) + .sort({ created: -1 }) + .lean() }), ) : await Promise.all( ids.map(async (id) => { - const posts = await this.postService.model.find( - { categoryId: id }, - { - select: 'title slug _id created modified', - sort: { created: -1 }, - }, - ) - const category = await this.categoryService.model - .findById(id) + const posts = await this.postService.model + .find( + { categoryId: id, ...addConditionToSeeHideContent(isMaster) }, + 'title slug _id created modified', + ) + .sort({ created: -1 }) .lean() + const category = await this.categoryService.findCategoryById(id) + return { category: { ...category, children: posts }, } @@ -48,7 +80,112 @@ export class CategoryController { ) } return type === CategoryType.Category - ? await this.categoryService.model.find({ type }).lean() + ? await this.categoryService.findAllCategory() : await this.categoryService.getPostTagsSum() } + + @Get('/:query') + @ApiQuery({ + description: '混合查询 分类 和 标签云', + name: 'tag', + enum: ['true', 'false'], + required: false, + }) + async getCategoryById( + @Param() { query }: SlugOrIdDto, + @Query() { tag }: MultiQueryTagAndCategoryDto, + @IsMaster() isMaster: boolean, + ) { + if (!query) { + throw new BadRequestException() + } + if (tag === true) { + return { + tag, + data: await this.categoryService.findArticleWithTag( + query, + addConditionToSeeHideContent(isMaster), + ), + } + } + + const isId = Types.ObjectId.isValid(query) + const res = isId + ? await this.categoryService.model + .findById(query) + .sort({ created: -1 }) + .lean() + : await this.categoryService.model + .findOne({ slug: query }) + .sort({ created: -1 }) + .lean() + + if (!res) { + throw new CannotFindException() + } + + const children = + (await this.categoryService.findCategoryPost(res._id, { + $and: [ + tag ? { tags: tag } : {}, + addConditionToSeeHideContent(isMaster), + ], + })) || [] + return { data: { ...res, children } } + } + + @Post('/') + @Auth() + async create(@Body() body: CategoryModel) { + const { name, slug } = body + return this.categoryService.model.create({ name, slug: slug ?? name }) + } + + @Put('/:id') + @Auth() + async modify(@Param() params: MongoIdDto, @Body() body: CategoryModel) { + const { type, slug, name } = body + const { id } = params + await this.categoryService.model.updateOne( + { _id: id }, + { + slug, + type, + name, + }, + ) + return await this.categoryService.model.findById(id) + } + + @Patch('/:id') + @HttpCode(204) + @Auth() + async patch(@Param() params: MongoIdDto, @Body() body: PartialCategoryModel) { + const { id } = params + await this.categoryService.model.updateOne({ _id: id }, body) + return + } + + @Delete('/:id') + @Auth() + async deleteCategory(@Param() params: MongoIdDto) { + const { id } = params + const category = await this.categoryService.model.findById(id) + if (!category) { + throw new CannotFindException() + } + const postsInCategory = await this.categoryService.findPostsInCategory( + category._id, + ) + if (postsInCategory.length > 0) { + throw new BadRequestException('该分类中有其他文章, 无法被删除') + } + const res = await this.categoryService.model.deleteOne({ + _id: category._id, + }) + if ((await this.categoryService.model.countDocuments({})) === 0) { + await this.categoryService.createDefaultCategory() + } + return res + } } diff --git a/src/modules/category/category.dto.ts b/src/modules/category/category.dto.ts index 4b56a278..d7d5f5db 100644 --- a/src/modules/category/category.dto.ts +++ b/src/modules/category/category.dto.ts @@ -3,7 +3,6 @@ import { ApiProperty } from '@nestjs/swagger' import { Transform } from 'class-transformer' import { IsBoolean, - IsEnum, IsMongoId, IsNotEmpty, IsOptional, @@ -11,28 +10,7 @@ import { } from 'class-validator' import { uniq } from 'lodash' import { IsBooleanOrString } from '~/utils/validator/isBooleanOrString' - -export enum CategoryType { - Category, - Tag, -} - -export class CategoryDto { - @IsString() - @IsNotEmpty() - @ApiProperty() - name: string - - @IsEnum(CategoryType) - @IsOptional() - @ApiProperty({ enum: [0, 1] }) - type?: CategoryType - - @IsString() - @IsNotEmpty() - @IsOptional() - slug?: string -} +import { CategoryType } from './category.model' export class SlugOrIdDto { @IsString() diff --git a/src/modules/category/category.model.ts b/src/modules/category/category.model.ts index 8f9dee60..c15b894c 100644 --- a/src/modules/category/category.model.ts +++ b/src/modules/category/category.model.ts @@ -1,4 +1,6 @@ +import { PartialType } from '@nestjs/mapped-types' import { DocumentType, index, modelOptions, prop } from '@typegoose/typegoose' +import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator' import { BaseModel } from '~/shared/model/base.model' export type CategoryDocument = DocumentType @@ -12,11 +14,20 @@ export enum CategoryType { @modelOptions({ options: { customName: 'Category' } }) export class CategoryModel extends BaseModel { @prop({ unique: true, trim: true, required: true }) + @IsString() + @IsNotEmpty() name!: string @prop({ default: CategoryType.Category }) + @IsEnum(CategoryType) + @IsOptional() type?: CategoryType @prop({ unique: true, required: true }) + @IsString() + @IsNotEmpty() + @IsOptional() slug!: string } + +export class PartialCategoryModel extends PartialType(CategoryModel) {} diff --git a/src/modules/category/category.service.ts b/src/modules/category/category.service.ts index 52ced48d..5ef2941f 100644 --- a/src/modules/category/category.service.ts +++ b/src/modules/category/category.service.ts @@ -1,6 +1,10 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common' -import { ReturnModelType } from '@typegoose/typegoose' +import { DocumentType, ReturnModelType } from '@typegoose/typegoose' +import { omit } from 'lodash' +import { FilterQuery } from 'mongoose' import { InjectModel } from 'nestjs-typegoose' +import { CannotFindException } from '~/common/exceptions/cant-find.exception' +import { PostModel } from '../post/post.model' import { PostService } from '../post/post.service' import { CategoryModel } from './category.model' @@ -11,10 +15,35 @@ export class CategoryService { private readonly categoryModel: ReturnModelType, @Inject(forwardRef(() => PostService)) private readonly postService: PostService, - ) {} + ) { + this.createDefaultCategory() + } - findCategoryById(categoryId: string) { - return this.categoryModel.findById(categoryId) + async findCategoryById(categoryId: string) { + const [category, count] = await Promise.all([ + this.model.findById(categoryId).lean(), + this.postService.model.countDocuments({ categoryId }), + ]) + return { + ...category, + count, + } + } + + async findAllCategory() { + const data = await this.model.find().lean() + const counts = await Promise.all( + data.map((item) => { + const id = item._id + return this.postService.model.countDocuments({ categoryId: id }) + }), + ) + + for (let i = 0; i < data.length; i++) { + Reflect.set(data[i], 'count', counts[i]) + } + + return data } get model() { @@ -38,4 +67,55 @@ export class CategoryService { ]) return data } + + async findArticleWithTag( + tag: string, + condition: FilterQuery> = {}, + ): Promise { + const posts = await this.postService.model + .find( + { + tags: tag, + ...condition, + }, + undefined, + { lean: true }, + ) + .populate('category') + if (!posts.length) { + throw new CannotFindException() + } + return posts.map(({ _id, title, slug, category, created }) => ({ + _id, + title, + slug, + category: omit(category, ['count', '__v', 'created', 'modified']), + created, + })) + } + + async findCategoryPost(categoryId: string, condition: any = {}) { + return await this.postService.model + .find({ + categoryId, + ...condition, + }) + .select('title created slug _id') + .sort({ created: -1 }) + } + + async findPostsInCategory(id: string) { + return await this.postService.model.find({ + categoryId: id, + }) + } + + async createDefaultCategory() { + if ((await this.model.countDocuments()) === 0) { + return await this.model.create({ + name: '默认分类', + slug: 'default', + }) + } + } } diff --git a/src/shared/model/base.model.ts b/src/shared/model/base.model.ts index 16918060..4c7d2ca9 100644 --- a/src/shared/model/base.model.ts +++ b/src/shared/model/base.model.ts @@ -1,11 +1,12 @@ import { ApiHideProperty } from '@nestjs/swagger' import { modelOptions, plugin, prop } from '@typegoose/typegoose' import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator' +import LeanId from 'mongoose-lean-id' import mongooseLeanVirtuals from 'mongoose-lean-virtuals' import Paginate from 'mongoose-paginate-v2' - @plugin(mongooseLeanVirtuals) @plugin(Paginate) +@plugin(LeanId) export class BaseModel { @ApiHideProperty() created?: Date