NestJS 搭建博客系统(九)— 标签模块

1,936 阅读6分钟

NestJS 搭建博客系统(九)— 标签模块

前言

经过前面几章基建配置后,终于回到了业务开发上,继续开发博客系统功能。

有时开发过程就是这样,开发到一半,需要跳出来开发一些基础的东西,基础的东西搞好了,后面的开发才会越来越快,否则就是cvcvcv,最后开发出来的东西能跑,但是自己知道不优雅,而老板可是不管你优不优雅,老板只看你这个东西现在能不能跑,而不会特意给你任何一秒钟的时间去优化,另外在团队开发中,虽然有时也会组织重构,但凤毛麟角,而且测试或者其他人为你的个人重构花时间去测试或者新bug买单。

开发标签模块

标签模块是文章标签模块,标签和模块是一个聚合关系,通过标签可以搜索到相关的一些文章,而查看文章也可以知道这篇文章属于那几个标签。如果需要文章分类,我们可以单独开发一个树状分类系统,通过节点关联标签也就等同于给文章分类了。

标签模块的增删改查

一开始开发的标签模块在未关联文章之前和文章模块极像,我们可以参考着来开发

创建模块

nest g mo modules/tag

nest g co modules/tag nest g s modules/tag

同时在 tag 目录增加 entity dto vo 三个目录

创建 tagEntity

// src/modules/tag/entity

import { IsNotEmpty } from "class-validator";
import { Common } from "src/common/entity/common.entity";
import { Column, Entity } from "typeorm";

@Entity()
export class Tag extends Common{
  @Column()
  @IsNotEmpty()
  label: string
}

到 tagModule 引用 Entity

Controller DTO VO Service

先在Controller定义好方法和路由,再去 DTO 和 VO 定义传参和返回,填充好 Controller, 再到 service 实现具体方法,最终结果如下

Controller
// src/modules/tag/tag.controller.ts

import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth, ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { IdDTO } from 'src/common/dto/id.dto';
import { TagCreateDTO } from './dto/tag-create.dto';
import { TagUpdateDTO } from './dto/tag-edit.dto';
import { TagService } from './tag.service';
import { TagInfoSuccessVO, TagInfoVO } from './vo/tag-info.vo';
import { TagListSuccessVO, TagListVO } from './vo/tag-list.vo';

@ApiTags('标签模块')
@Controller('tag')
export class TagController {
  constructor(
    private tagService: TagService
  ) {}
  
  @ApiOkResponse({ description: '标签列表', type: TagListSuccessVO })
  @Get('list')
  getMore(): Promise<TagListVO> {
    return this.tagService.getMore()
  }
  
  @ApiBearerAuth()
  @ApiCreatedResponse({ description: '创建标签', type: TagInfoSuccessVO })
  @UseGuards(AuthGuard('jwt'))
  @Post('create')
  create(
    @Body() tagCreateDto: TagCreateDTO
  ): Promise<TagInfoVO> {
    return this.tagService.create(tagCreateDto)
  }

  @ApiBearerAuth()
  @ApiCreatedResponse({ description: '编辑标签', type: TagInfoSuccessVO })
  @UseGuards(AuthGuard('jwt'))
  @Post('update')
  update(
    @Body() tagUpdateDto: TagUpdateDTO
  ): Promise<TagInfoVO> {
    return this.tagService.update(tagUpdateDto)
  }

  @ApiBearerAuth()
  @ApiCreatedResponse({ description: '删除标签', type: TagInfoSuccessVO })
  @UseGuards(AuthGuard('jwt'))
  @Post('remove')
  remove(
    @Body() idDto: IdDTO
  ): Promise<TagInfoVO> {
    return this.tagService.remove(idDto)
  }
}

DTO
// src/modules/tag/dto/tag.dto.ts

import { IsNotEmpty } from "class-validator";

export class TagDTO {
  
  /**
   * 标签名称
   * @example 标签1
   */
  @IsNotEmpty()
  label: string
}
// src/modules/tag/dto/tag-create.dto.ts

import { TagDTO } from "./tag.dto";

export class TagCreateDTO extends TagDTO {}
// src/modules/tag/dto/tag-update.dto.ts

import { IntersectionType, PartialType } from "@nestjs/swagger";
import { IdDTO } from "src/common/dto/id.dto";
import { TagDTO } from "./tag.dto";

export class TagUpdateDTO extends IntersectionType(
  IdDTO,
  PartialType(TagDTO)
){}
VO
// src/modules/tag/vo/tag-info.dto.ts

import { TagDTO } from "../dto/tag.dto";

export class TagInfoVO {
  info: TagDTO
}

export class TagInfoSuccessVO {
  data: {
    info: TagDTO
  }
}
// src/modules/tag/vo/tag-list.dto.ts

import { TagDTO } from "../dto/tag.dto";

export class TagListVO {
  list: TagDTO[]
}

export class TagListSuccessVO {
  data: TagListVO
}
Service
// src/modules/tag/tag.service.ts

import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IdDTO } from 'src/common/dto/id.dto';
import { Repository } from 'typeorm';
import { TagCreateDTO } from './dto/tag-create.dto';
import { TagUpdateDTO } from './dto/tag-edit.dto';
import { Tag } from './entity/tag.entity';
import { TagInfoVO } from './vo/tag-info.vo';
import { TagListVO } from './vo/tag-list.vo';

@Injectable()
export class TagService {
  constructor(
    @InjectRepository(Tag)
    private readonly tagRepository: Repository<Tag>,
  ) {}

  async getMore(): Promise<TagListVO> {
    const getList = this.tagRepository
      .createQueryBuilder('tag')
      .where({ isDelete: false })
      .select([
        'tag.id',
        'tag.label',
      ])
      .getMany()
    const result = await getList
    return {
      list: result
    }
  }

  async create(
    tagCreateDTO: TagCreateDTO
  ): Promise<TagInfoVO> {
    const { label } = tagCreateDTO
    const hasTag = await this.tagRepository.findOne({ label })
    if (hasTag) {
      throw new NotFoundException(`${label}标签已存在`)
    }
    const tag = new Tag();
    tag.label = tagCreateDTO.label
    const result = await this.tagRepository.save(tag);
    return {
      info: result
    }
  }
  
  async update(
    tagUpdateDto: TagUpdateDTO
  ): Promise<TagInfoVO> {
    const { id, label } = tagUpdateDto

    const tag = await this.tagRepository.findOne({ id })
    tag.label = label
    const result = await this.tagRepository.save(tag)
    return {
      info: result
    }
  }

  async remove(
    idDto: IdDTO
  ) {
    const { id } = idDto
    const tag = await this.tagRepository.findOne({ id })
    tag.isDelete = true
    const result = await this.tagRepository.save(tag)
    return {
      info: result
    }
  }

}

关联文章模块

本章的目标是把文章和标签关联起来,可以通过标签搜索到相关文章,同时文章列表也应该返回其对应的标签列表

修改 ArticleEntity 和 TagEntity

// src/modules/article/entity/articel.entity.ts

import { Common } from 'src/common/entity/common.entity';
import { Tag } from 'src/modules/tag/entity/tag.entity';
import { Entity, Column, ManyToMany, JoinTable } from 'typeorm';

@Entity()
export class Article extends Common{
  
    // 文章标题
    @Column('text')
    title: string;

    // 文章描述
    @Column('text')
    description: string;

    // 文章内容
    @Column('text')
    content: string;

    // 标签
    @ManyToMany(type => Tag, tag => tag.articles)
    @JoinTable()
    tags: Tag[];

}
// src/modules/tag/entity/tag.entity.ts

import { Common } from "src/common/entity/common.entity";
import { Article } from "src/modules/article/entity/article.entity";
import { Column, Entity, JoinTable, ManyToMany } from "typeorm";

@Entity()
export class Tag extends Common{
  // 标签名称
  @Column()
  label: string

  // 文章
  @ManyToMany(() => Article, article => article.tags)
  articles: Article[];
}

修改创建文章的 DTO

// src/modules/article/dto/article.dto.ts

import { IsNotEmpty } from "class-validator";
import { Tag } from "src/modules/tag/entity/tag.entity";

export class ArticleDTO {

  /**
   * 文章标题
   * @example 啊!美丽的大海
   */
  @IsNotEmpty({ message: '请输入文章标题' })
  readonly title: string;

  /**
   * 文章简述
   * @example 给你讲述美丽的大海
   */
  @IsNotEmpty({ message: '请输入文章描述' })
  readonly description: string;

  /**
   * 文章内容
   * @example 啊!美丽的大海,你是如此美丽
   */
  @IsNotEmpty({ message: '请输入文章内容' })
  readonly content: string;

  /**
   * 标签 格式 [{id: 1}, {id: 2}]
   * @example  [{id: 1}]
   */
  readonly tags?: Tag[]
}

修改 ArticleService

// src/modules/article/article.service.ts

import { Injectable, NotFoundException } from '@nestjs/common';
import { ArticleCreateDTO } from './dto/article-create.dto';
import { ArticleEditDTO } from './dto/article-edit.dto';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Article } from './entity/article.entity';
import { getPagination } from 'src/utils/index.util';
import { PageDTO } from 'src/common/dto/Page.dto';
import { IdDTO } from 'src/common/dto/id.dto';
import { ArticleListDTO } from './dto/article-list.dto';

@Injectable()
export class ArticleService {  
  constructor(
    @InjectRepository(Article)
    private readonly articleRepository: Repository<Article>,
  ) {}

  async getMore(
    pageDTO: PageDTO,
  ) {
		const { page = 1, pageSize = 10 } = pageDTO
    const getList = this.articleRepository
      .createQueryBuilder('article')
      .where({ isDelete: false })
      .leftJoin("article.tags","tag")
      .select([
        'article.id',
        'article.title', 
        'article.description',
        'article.createTime',
        'article.updateTime',
      ])
      .addSelect([
        'tag.id',
        'tag.label'
      ])
      .skip((page - 1) * pageSize)
      .take(pageSize)
      .getManyAndCount()

    const [list, total] = await getList
    const pagination = getPagination(total, pageSize, page)

    return {
      list,
      pagination,
    }
  }

  async getMoreByTagId(
    articleListDto: ArticleListDTO
  ) {
		const { page = 1, pageSize = 10, tagId } = articleListDto
    const getList = this.articleRepository
      .createQueryBuilder('article')
      .where({ isDelete: 0 })
      .andWhere('tag.id = :id', { id: tagId })
      .andWhere('tag.isDelete = :isDelete', { isDelete: false })
      .leftJoin("article.tags","tag")
      .select([
        'article.id',
        'article.title', 
        'article.description',
        'article.createTime',
        'article.updateTime',
      ])
      .addSelect([
        'tag.id',
        'tag.label'
      ])
      .skip((page - 1) * pageSize)
      .take(pageSize)
      .getManyAndCount()

      const [list, total] = await getList
      const pagination = getPagination(total, pageSize, page)
  
      return {
        list,
        pagination,
      }
  }

  async getOne(
    idDto: IdDTO
  ) {
    const { id } = idDto
		const articleDetial = await this.articleRepository
      .createQueryBuilder('article')
      .where('article.id = :id', { id })
      .leftJoin("article.tags","tag")
      .select([
        'article.id',
        'article.title', 
        'article.description',
        'article.createTime',
        'article.updateTime',
      ])
      .addSelect([
        'tag.id',
        'tag.label'
      ])
      .getOne()

    if (!articleDetial) {
      throw new NotFoundException('找不到文章')
    }

		return {
      info: articleDetial
    }
  }

  /**
   * 
   * @param articleCreateDTO 
   * @returns 
   */
  async create(
    articleCreateDTO: ArticleCreateDTO
  ){
    const article = new Article()
    for (let key in articleCreateDTO) {
      article[key] = articleCreateDTO[key]
    }
    const result = await this.articleRepository.save(article);
    
    return {
      info: result
    }
  }

  /**
   * 
   * @param articleEditDTO 
   * @returns 
   */
  async update(
    articleEditDTO: ArticleEditDTO
  ) {
    const { id } = articleEditDTO
    let articleToUpdate = await this.articleRepository.findOne({ id })

    for (let key in articleEditDTO) {
      if (key !== 'id') {
        articleToUpdate[key] = articleEditDTO[key]
      }
    }

    const result = await this.articleRepository.save(articleToUpdate)

    return {
      info: result,
    }
  }
  
  /**
   * 
   * @param idDTO 
   * @returns 
   */
  async remove (
    idDTO: IdDTO,
  ) {
    const { id } = idDTO
    let articleToUpdate = await this.articleRepository.findOne({ id })
    articleToUpdate.isDelete = true
    const result = await this.articleRepository.save(articleToUpdate)
    
    return {
      info: result
    }
  }

}

可以看到我们还增加了一个 getMoreByTagId 方法,修改下 article/list 的入参

// src/modules/article/article.controller.ts

  @ApiOkResponse({ description: '文章列表', type: ArticleListSuccessVO })
  @Get('list')
  async getMore(
    @Query() articleListDto: ArticleListDTO,
  ): Promise<ArticleListVO> {
    const { tagId } = articleListDto
    if (tagId) {
      return await this.articleService.getMoreByTagId(articleListDto)
    }
    return await this.articleService.getMore(articleListDto)
  }

其中 ArticleListDTO 如下

// src/modules/article/dto/article-list.dto.ts

import { PageDTO } from "src/common/dto/Page.dto";

export class ArticleListDTO extends PageDTO {
  /**
   * tagId 
   * @example 1
   */
  tagId?: number
}

至此,本章实现了 tag 模块的 curd,并且关联到 article 模块,/article/list 可以根据 tagId 查询到相关文章。

参考

系列