NestJS实战-后端开发-文章专栏功能模块

219 阅读8分钟

NestJS实战-后端开发-文章专栏功能模块

本文介绍 NestJS 实战的文章专栏功能模块:文章和专栏表接口构建、中间关联表及多表联查、关联表软删除、文章专栏异步遍历处理

供自己以后查漏补缺,也欢迎同道朋友交流学习。

引言

项目的全局配置及用户权限,上一章已经介绍过了,本章主要介绍几个功能模块的开发:文章管理、专栏管理

文章管理

文章表 article

我们先进行文章表结构设计,因为想做个简单的,就不建文章详情表了

  • id (主键)
  • title (文章名称)
  • description (文章描述)
  • content (文章内容)
  • createdBy (创建人id 外键 关联user表)
  • createdByAccount (创建人id 外键 关联user表)
  • createdTime (创建时间)
  • updatedBy (更新人Id 关联user表)
  • updatedByAccount (更新人账号 关联user表)
  • updatedTime (更新时间)
  • isDeleted (是否删除 0未删除 1已删除)

文章专栏关联表 article_column

多对多,一篇文章可以收录至多个专栏,一个专栏也可以收录多个文章

  • id (主键)
  • articleId (文章id 外键 关联article表)
  • columnId (专栏id 外键 关联column表)
  • isDeleted (是否删除 0未删除 1已删除)

文章模块开发

使用快捷键新建 article 模块:

nest g res article
# 默认选择 REST API

根据文章表配置实体 article.entity.ts 如下:

import {
  Column,
  CreateDateColumn,
  Entity,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';

@Entity()
export class Article {
  // 主键 唯一且自增长
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: 'varchar', length: 50, unique: true })
  title: string;

  @Column({ type: 'varchar', length: 200 })
  description: string;

  @Column({ type: 'text' })
  content: string;

  @Column({ default: 1 })
  createdBy: number; // 创建人id

  @Column()
  createdByAccount: string;

  @CreateDateColumn()
  createdTime: Date;

  @Column({ default: 1 })
  updatedBy: number; // 更新人Id

  @Column()
  updatedByAccount: string;

  @UpdateDateColumn()
  updatedTime: Date;

  @Column({ default: 0 })
  isDeleted: number; // 是否删除,0表示未删除,1表示已删除
}

根据文章关联表配置实体 article-column-related.entity.ts 如下:

import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { Article } from './article.entity';
import { SpecialColumn } from 'src/special-column/entities/special-column.entity';

@Entity()
export class ArticleColumnRelated {
  // 主键 唯一且自增长
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  articleId: number;

  @Column()
  articleTitle: string;

  @Column()
  columnId: number;

  @Column()
  columnTitle: string;

  @Column({ default: 0 })
  isDeleted: number; // 是否删除,0表示未删除,1表示已删除

  @ManyToOne(() => Article, (article) => article.id, { eager: false })
  article: Article;

  @ManyToOne(() => SpecialColumn, (column) => column.id, { eager: false })
  column: SpecialColumn;
}

定义文章数据传输对象

创建文章 CreateArticleDto

create-article.dto.ts 代码如下:

import { ApiProperty } from '@nestjs/swagger';
import {
  IsNotEmpty,
  IsString,
  IsNumber,
  IsOptional,
  IsArray,
} from 'class-validator';

export class CreateArticleDto {
  @IsString({ message: '文章标题必须是字符串' })
  @IsNotEmpty({ message: '文章标题不能为空' })
  @ApiProperty({
    description: '文章标题',
    example: '文章标题文章标题',
  })
  title: string;

  @IsString({ message: '文章描述必须是字符串' })
  @IsNotEmpty({ message: '文章描述不能为空' })
  @ApiProperty({
    description: '文章描述',
    example: '文章描述文章描述文章描述文章描述',
  })
  description: string;

  @IsString({ message: '文章内容必须是字符串' })
  @IsNotEmpty({ message: '文章内容不能为空' })
  @ApiProperty({
    description: '文章内容',
    example: '文章内容文章内容文章内容',
  })
  content: string;

  @IsArray()
  @IsNumber({}, { each: true }) // 验证数组中的每个元素是否为数字
  @IsOptional()
  @ApiProperty({
    description: '专栏id',
    example: [1, 2, 3],
  })
  columnIds: number[];
}
文章列表查询 ListArticleDto

list-article.dto.ts 代码如下:

import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsNotEmpty } from 'class-validator';

export class ListArticleDto {
  @IsOptional()
  @ApiProperty({
    description: '文章ID',
    example: 1,
  })
  id: number;

  @IsOptional()
  @ApiProperty({
    description: '文章标题',
    example: '文章标题',
  })
  title: string;

  @IsOptional()
  @ApiProperty({
    description: '专栏id',
    example: 1,
  })
  columnId: number;

  @ApiProperty({ description: '页码', example: 1 })
  @IsNotEmpty({ message: 'pageNum不能为空' })
  pageNum: number = 1;

  @ApiProperty({ description: '每页查询数量', example: 10 })
  @IsNotEmpty({ message: 'pageSize不能为空' })
  pageSize: number = 10;
}
修改文章 UpdateArticleDto

update-article.dto.ts 代码如下:

import { PartialType } from '@nestjs/swagger';
import { CreateArticleDto } from './create-article.dto';

export class UpdateArticleDto extends PartialType(CreateArticleDto) {}

文章CRUD相关操作 ArticleController

在控制器中编写文章增删改查相关操作,这个也是给前端的接口定义,我们主要实现如下几个接口:

接口请求定义描述备注
/articlePost创建文章创建文章title是唯一值,不可重复,可以修改
/articleGet分页查询文章列表分页查询文章列表根据query、pageSize、PageNum分页查询用户列表
/article/{id}Get根据ID查询文章详情根据ID查询文章详情-
/article/{id}Patch根据ID修改文章根据ID修改文章-
/article/{id}Delete删除文章根据id删除文章-

article.controller.ts 代码如下:

import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
  Req,
  UseGuards,
  Query,
} from '@nestjs/common';
import { ArticleService } from './article.service';
import { CreateArticleDto } from './dto/create-article.dto';
import { UpdateArticleDto } from './dto/update-article.dto';
import {
  ApiBody,
  ApiOperation,
  ApiResponse,
  ApiTags,
  ApiParam,
  ApiQuery,
} from '@nestjs/swagger';
import { ListArticleDto } from './dto/list-article.dto';
import { Roles } from 'src/user/roles.decorator';
import { RolesGuard } from 'src/user/roles.guard';

@UseGuards(RolesGuard)
@Controller('article')
@ApiTags('文章管理')
export class ArticleController {
  constructor(private readonly articleService: ArticleService) {}

  @Post()
  @Roles('systemAdmin', 'admin', 'user')
  @ApiOperation({
    summary: '创建文章',
    description: '创建文章',
  })
  @ApiBody({ type: CreateArticleDto })
  @ApiResponse({ status: 200, description: '创建成功' })
  create(@Body() createArticleDto: CreateArticleDto, @Req() req) {
    return this.articleService.create(createArticleDto, req);
  }

  @Get()
  @ApiOperation({
    summary: '分页查询文章列表',
    description: '分页查询文章列表',
  })
  @ApiQuery({ name: 'query', type: ListArticleDto })
  @ApiResponse({ status: 200, description: '查询用户成功' })
  findAll(@Query() query: ListArticleDto) {
    return this.articleService.findAllByPage(query);
  }

  @Get(':id')
  @ApiOperation({
    summary: '根据ID查询文章详情',
    description: '根据ID查询文章详情',
  })
  @ApiParam({ name: 'id', description: '文章id' })
  @ApiResponse({ status: 200, description: '查询成功' })
  findOne(@Param('id') id: string) {
    return this.articleService.findOne(+id);
  }

  @Patch(':id')
  @Roles('systemAdmin', 'admin', 'user')
  @ApiOperation({
    summary: '根据ID修改文章',
    description: '根据ID修改文章',
  })
  @ApiParam({ name: 'id', description: '文章id' })
  @ApiBody({ type: UpdateArticleDto })
  @ApiResponse({ status: 200, description: '修改文章成功' })
  update(
    @Param('id') id: string,
    @Body() updateArticleDto: UpdateArticleDto,
    @Req() req,
  ) {
    return this.articleService.update(+id, updateArticleDto, req);
  }

  @Delete(':id')
  @Roles('systemAdmin', 'admin', 'user')
  @ApiOperation({
    summary: '删除文章',
    description: '根据id删除文章',
  })
  @ApiParam({ name: 'id', description: '文章id' })
  @ApiResponse({ status: 200, description: '删除文章成功' })
  remove(@Param('id') id: string, @Req() req) {
    return this.articleService.softDeleteArticle(+id, req);
  }
}

文章服务 ArticleService

import {
  BadRequestException,
  Injectable,
  InternalServerErrorException,
  Logger,
  Req,
} from '@nestjs/common';
import { CreateArticleDto } from './dto/create-article.dto';
import { UpdateArticleDto } from './dto/update-article.dto';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Like, Repository } from 'typeorm';
import { Article } from './entities/article.entity';
import { ArticleColumnRelated } from './entities/article-column-related.entity';
import { SpecialColumn } from 'src/special-column/entities/special-column.entity';
import {
  removeUserData,
  setCreatedUser,
  removeUnnecessaryData,
  setUpdatedUser,
  setDeletedUser,
} from 'src/utils';
import { ListArticleDto } from './dto/list-article.dto';

@Injectable()
export class ArticleService {
  private logger = new Logger('ArticleService');

  constructor(
    @InjectRepository(Article)
    private readonly articleRepository: Repository<Article>,
    @InjectRepository(ArticleColumnRelated)
    private readonly articleColumnRelatedRepository: Repository<ArticleColumnRelated>,
    @InjectRepository(SpecialColumn)
    private readonly specialColumnRepository: Repository<SpecialColumn>,
  ) {}

  // 异步获取文章专栏关联数据
  async getArticleColumnData(columnIds, savedArticle) {
    return await Promise.all(
      columnIds.map(async (columnId) => {
        const articleColumn = new ArticleColumnRelated();
        // 文章信息
        articleColumn.articleId = savedArticle.id;
        articleColumn.articleTitle = savedArticle.title;
        // 专栏信息
        const column = await this.specialColumnRepository.findOne({
          where: { id: columnId, isDeleted: 0 },
        });
        articleColumn.columnId = columnId;
        articleColumn.columnTitle = column?.title;

        return articleColumn;
      }),
    );
  }

  async createArticleWithColumns(
    createArticleDto: CreateArticleDto,
    req,
  ): Promise<any> {
    return await this.articleRepository.manager.transaction(async (manager) => {
      // 创建文章
      const article = new Article();
      article.title = createArticleDto.title;
      article.description = createArticleDto.description;
      article.content = createArticleDto.content;

      // 设置创建用户信息
      const createdArticle = setCreatedUser(req, article);

      const savedArticle = await manager.save(createdArticle);

      // 创建文章专栏关联记录
      if (createArticleDto?.columnIds?.length) {
        const articleColumns = await this.getArticleColumnData(
          createArticleDto?.columnIds,
          savedArticle,
        );

        await manager.save(articleColumns);
      }

      return savedArticle;
    });
  }

  async validateHasColumnIds(createArticleDto) {
    if (createArticleDto?.columnIds?.length) {
      const columnIds = await this.specialColumnRepository.find({
        select: ['id'],
        where: {
          id: In(createArticleDto?.columnIds),
          isDeleted: 0,
        },
      });

      const columnIdArr = columnIds.map((item) => item.id);

      for (const id of createArticleDto?.columnIds) {
        if (!columnIdArr.includes(id)) {
          throw new BadRequestException(`专栏ID ${id} 不存在`);
        }
      }
    }
  }

  // 新建文章
  async create(createArticleDto: CreateArticleDto, @Req() req) {
    // 先查询文章的专栏id是否存在
    if (createArticleDto?.columnIds?.length) {
      await this.validateHasColumnIds(createArticleDto);
    }

    // 新建文章
    try {
      const article = await this.createArticleWithColumns(
        createArticleDto,
        req,
      );

      return {
        id: article.id,
        title: article.title,
        description: article.description,
      };
    } catch (error) {
      this.logger.error('文章新建失败:', error);
      throw new InternalServerErrorException('文章新建失败');
    }
  }

  // 分页查询文章
  async findAllByPage(query: ListArticleDto) {
    try {
      // 分页查询
      const [data, total] = await this.articleRepository.findAndCount({
        where: {
          id: query.id,
          title: query.title ? Like(`%${query.title}%`) : null,
          isDeleted: 0,
        },
        order: {
          id: 'DESC',
        },
        skip: (query.pageNum - 1) * query.pageSize,
        take: query.pageSize,
      });

      this.logger.log('@@@ 分页查询文章:', data);

      return {
        list: removeUnnecessaryData(data),
        pageNum: Number(query.pageNum),
        pageSize: Number(query.pageSize),
        total,
      };
    } catch (error) {
      this.logger.error('@@@@ 账号列表查询失败:', error);
      throw new InternalServerErrorException('账号列表查询失败');
    }
  }

  async queryColumnIdsByArticleId(articleId) {
    const columns = await this.articleColumnRelatedRepository.find({
      select: ['columnId'],
      where: { articleId },
    });

    return columns.map((item) => item.columnId);
  }

  // 查询文章详情
  async findOne(id: number) {
    if (!id) {
      throw new BadRequestException('id必填');
    }
    try {
      const data = await this.articleRepository.findOne({
        where: { id, isDeleted: 0 },
      });
      const filterData = removeUserData(data);

      this.logger.log('@@@ 查询文章详情', filterData);

      // 关联查询返回专栏id
      const columnIds = await this.queryColumnIdsByArticleId(id);

      return {
        ...filterData,
        columnIds,
      };
    } catch (error) {
      this.logger.error('@@@@ 查询文章详情失败:', error);
      throw new InternalServerErrorException('查询文章详情失败');
    }
  }

  async updateArticleWithColumns(
    id,
    updateArticleDto: UpdateArticleDto,
    req,
  ): Promise<any> {
    return await this.articleRepository.manager.transaction(async (manager) => {
      // 获取原始文章
      const article = await manager.findOne(Article, {
        where: { id, isDeleted: 0 },
      });

      if (!article) {
        throw new BadRequestException(`文章ID ${id} 没有找到`);
      }

      // 更新文章基本信息
      article.title = updateArticleDto.title;
      article.description = updateArticleDto.description;
      article.content = updateArticleDto.content;

      // 设置更新用户信息
      const updatedArticle = setUpdatedUser(req, article);

      const savedArticle = await manager.save(updatedArticle);

      // 处理文章专栏关联记录
      const existingRelations = await manager.find(ArticleColumnRelated, {
        where: { articleId: id },
      });

      this.logger.log('@@@@ existingRelations', existingRelations);

      // 需要添加的新关联
      const newRelationsToAdd = updateArticleDto.columnIds.filter(
        (columnId) =>
          !existingRelations.some((item) => item.columnId === columnId),
      );

      this.logger.log('@@@@ newRelationsToAdd', newRelationsToAdd);

      // 需要标记为已删除的旧关联
      const oldRelationsToMarkDeleted = existingRelations.filter(
        (rel) => !updateArticleDto.columnIds.includes(rel.columnId),
      );

      this.logger.log(
        '@@@@ oldRelationsToMarkDeleted',
        oldRelationsToMarkDeleted,
      );

      // 添加新的关联记录
      for (const columnId of newRelationsToAdd) {
        const column = await this.specialColumnRepository.findOneBy({
          id: columnId,
          isDeleted: 0,
        });
        const newRelation = new ArticleColumnRelated();
        newRelation.articleId = id;
        newRelation.articleTitle = savedArticle.title;
        newRelation.columnId = column.id;
        newRelation.columnTitle = column.title;
        await manager.save(newRelation);
      }

      // 标记旧的关联记录为已删除
      for (const relation of oldRelationsToMarkDeleted) {
        relation.isDeleted = 1; // 标记为已删除
        await manager.save(relation);
      }

      // 给所有记录修改标题
      for (const relation of existingRelations) {
        relation.articleTitle = savedArticle.title; // 文章标题修改
        await manager.save(relation);
      }

      return savedArticle;
    });
  }

  // 修改文章
  async update(id: number, updateArticleDto: UpdateArticleDto, @Req() req) {
    if (!id) {
      throw new BadRequestException('id必填');
    }

    this.logger.log('@@@@ 修改文章 updateArticleDto', updateArticleDto);

    // 先查询文章的专栏id是否存在
    if (updateArticleDto?.columnIds?.length) {
      await this.validateHasColumnIds(updateArticleDto);
    }

    // 更新文章
    try {
      const article = await this.updateArticleWithColumns(
        id,
        updateArticleDto,
        req,
      );

      return {
        id: article.id,
        title: article.title,
        description: article.description,
      };
    } catch (error) {
      this.logger.error('文章更新失败:', error);
      throw new InternalServerErrorException('文章更新失败');
    }
  }

  // 软删除文章专栏关联表
  async softDeleteArticleColumnRelations(articleId: number) {
    const relations = await this.articleColumnRelatedRepository.find({
      where: { articleId, isDeleted: 0 },
    });

    for (const relation of relations) {
      relation.isDeleted = 1; // 标记为已删除
      await this.articleColumnRelatedRepository.save(relation);
    }
  }

  // 软删除单个文章
  async softDeleteArticle(id: number, @Req() req) {
    if (!id) {
      throw new BadRequestException('id必填');
    }

    try {
      const article = new Article();
      const removedArticle = setDeletedUser(req, article);

      this.logger.log('@@@ 软删除单个文章 removedArticle', removedArticle);

      await this.articleRepository.update(id, removedArticle);

      // 软删除文章专栏关联表
      await this.softDeleteArticleColumnRelations(id);

      return { message: '删除成功' };
    } catch (error) {
      this.logger.error('@@@@ 删除文章失败:', error);
      throw new InternalServerErrorException('删除文章失败');
    }
  }
}

文章模块 ArticleModule

import { Module } from '@nestjs/common';
import { ArticleService } from './article.service';
import { ArticleController } from './article.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Article } from './entities/article.entity';
import { ArticleColumnRelated } from './entities/article-column-related.entity';
import { SpecialColumn } from 'src/special-column/entities/special-column.entity';
import { Role } from 'src/user/entities/role.entity';

@Module({
  imports: [
    TypeOrmModule.forFeature([
      Article,
      ArticleColumnRelated,
      SpecialColumn,
      Role,
    ]),
  ],
  controllers: [ArticleController],
  providers: [ArticleService],
  exports: [ArticleService],
})
export class ArticleModule {}

文章管理功能接口完成

目前完成了5个文章相关的接口

article-module

专栏管理

专栏表 special-column

  • id (主键)
  • title (专栏标题)
  • description (专栏描述)
  • createdBy (创建人id 外键 关联user表)
  • createdByAccount (创建人id 外键 关联user表)
  • createdTime (创建时间)
  • updatedBy (更新人Id 关联user表)
  • updatedByAccount (更新人账号 关联user表)
  • updatedTime (更新时间)
  • isDeleted (是否删除 0未删除 1已删除)

专栏模块开发

使用快捷键新建 special-column 模块:

nest g res special-column
# 默认选择 REST API

根据专栏表结构配置 special-column.entity.ts

import {
  Column,
  CreateDateColumn,
  Entity,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';

@Entity()
export class SpecialColumn {
  // 主键 唯一且自增长
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: 'varchar', length: 50, unique: true })
  title: string;

  @Column({ type: 'varchar', length: 200 })
  description: string;

  @Column({ default: 1 })
  createdBy: number; // 创建人id

  @Column()
  createdByAccount: string;

  @CreateDateColumn()
  createdTime: Date;

  @Column({ default: 1 })
  updatedBy: number; // 更新人Id

  @Column()
  updatedByAccount: string;

  @UpdateDateColumn()
  updatedTime: Date;

  @Column({ default: 0 })
  isDeleted: number; // 是否删除,0表示未删除,1表示已删除
}

定义专栏数据传输对象

创建专栏 CreateSpecialColumnDto
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';

export class CreateSpecialColumnDto {
  @IsString({ message: '专栏标题必须是字符串' })
  @IsNotEmpty({ message: '专栏标题不能为空' })
  @ApiProperty({
    description: '专栏标题',
    example: '专栏标题专栏标题',
  })
  title: string;

  @IsString({ message: '专栏描述必须是字符串' })
  @IsNotEmpty({ message: '专栏描述不能为空' })
  @ApiProperty({
    description: '专栏描述',
    example: '专栏描述专栏描述专栏描述专栏描述',
  })
  description: string;
}
列表查询 ListArticleColumnDto
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsNotEmpty } from 'class-validator';

export class ListArticleColumnDto {
  @IsOptional()
  @ApiProperty({
    description: '文章标题',
    example: '文章标题',
  })
  articleTitle: string;

  @ApiProperty({ description: '页码', example: 1 })
  @IsNotEmpty({ message: 'pageNum不能为空' })
  pageNum: number = 1;

  @ApiProperty({ description: '每页查询数量', example: 10 })
  @IsNotEmpty({ message: 'pageSize不能为空' })
  pageSize: number = 10;
}
更新专栏 UpdateSpecialColumnDto
import { PartialType } from '@nestjs/swagger';
import { CreateSpecialColumnDto } from './create-special-column.dto';

export class UpdateSpecialColumnDto extends PartialType(
  CreateSpecialColumnDto,
) {}
文章专栏联表查询
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from 'class-validator';

export class ArticleColumnInclusionDto {
  @IsNotEmpty({ message: '专栏ID不能为空' })
  @ApiProperty({
    description: '专栏ID',
    example: 1,
  })
  columnId: number;

  @IsNotEmpty({ message: '文章ids不能为空' })
  @ApiProperty({
    description: '文章ids',
    example: [1, 2],
  })
  articleIds: number[];
}

专栏CRUD相关操作 SpecialColumnController

在控制器中编写用户增删改查相关操作,这个也是给前端的接口定义,我们主要实现如下几个接口:

接口请求定义描述备注
/specialColumnPost创建专栏创建专栏title是唯一值,不能重复、但可修改
/specialColumnGet专栏列表查询专栏列表查询-
/specialColumn/columnListForSelectGet专栏列表下拉项专栏列表下拉项-
/specialColumn/{id}Get根据ID查询专栏根据ID查询专栏-
/specialColumn/{id}Patch根据ID修改专栏根据ID修改专栏-
/specialColumn/{id}Delete删除专栏根据id删除用户-
/specialColumn/queryArticleListByColumnId/{id}Get根据专栏id查询文章列表根据专栏id查询文章列表-
/specialColumn/queryOtherArticleListByColumnId/{id}Post查询非该专栏的文章列表根据专栏id查询非该专栏的文章列表-
/specialColumn/cancelArticleInclusionPost批量取消收录批量取消收录至该专栏-
/specialColumn/addArticleInclusionPost文章批量收录文章批量收录至该专栏-

special-column.controller.ts如下:

import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
  Req,
  UseGuards,
  Query,
} from '@nestjs/common';
import { SpecialColumnService } from './special-column.service';
import { CreateSpecialColumnDto } from './dto/create-special-column.dto';
import { UpdateSpecialColumnDto } from './dto/update-special-column.dto';
import {
  ApiOperation,
  ApiTags,
  ApiBody,
  ApiResponse,
  ApiParam,
} from '@nestjs/swagger';
import { ListArticleColumnDto } from './dto/list-article-column.dto';
import { ArticleColumnInclusionDto } from './dto/article-column-inclusion.dto';
import { Roles } from 'src/user/roles.decorator';
import { RolesGuard } from 'src/user/roles.guard';

@Controller('specialColumn')
@UseGuards(RolesGuard)
@ApiTags('专栏管理')
export class SpecialColumnController {
  constructor(private readonly specialColumnService: SpecialColumnService) {}

  @Post()
  @Roles('systemAdmin', 'admin', 'user')
  @ApiOperation({
    summary: '创建专栏',
    description: '创建专栏',
  })
  @ApiBody({ type: CreateSpecialColumnDto })
  @ApiResponse({ status: 200, description: '创建成功' })
  create(@Body() createSpecialColumnDto: CreateSpecialColumnDto, @Req() req) {
    return this.specialColumnService.create(createSpecialColumnDto, req);
  }

  @Get()
  @ApiOperation({
    summary: '专栏列表查询',
    description: '专栏列表查询',
  })
  @ApiResponse({ status: 200, description: '查询成功' })
  findAll() {
    return this.specialColumnService.findAll();
  }

  @Get('columnListForSelect')
  @ApiOperation({
    summary: '专栏列表下拉项',
    description: '专栏列表下拉项',
  })
  @ApiResponse({ status: 200, description: '查询成功' })
  findAllForSelect() {
    return this.specialColumnService.findAllForSelect();
  }

  @Get(':id')
  @ApiOperation({
    summary: '根据ID查询专栏',
    description: '根据ID查询专栏',
  })
  @ApiParam({ name: 'id', description: '专栏id' })
  @ApiResponse({ status: 200, description: '查询成功' })
  findOne(@Param('id') id: string) {
    return this.specialColumnService.findOne(+id);
  }

  @Patch(':id')
  @ApiOperation({
    summary: '根据ID修改专栏',
    description: '根据ID修改专栏',
  })
  @ApiParam({ name: 'id', description: '专栏id' })
  @ApiBody({ type: UpdateSpecialColumnDto })
  @ApiResponse({ status: 200, description: '修改专栏成功' })
  update(
    @Param('id') id: string,
    @Body() updateSpecialColumnDto: UpdateSpecialColumnDto,
    @Req() req,
  ) {
    return this.specialColumnService.update(+id, updateSpecialColumnDto, req);
  }

  @Delete(':id')
  @Roles('systemAdmin', 'admin', 'user')
  @ApiOperation({
    summary: '删除专栏',
    description: '根据id删除专栏',
  })
  @ApiParam({ name: 'id', description: '专栏id' })
  @ApiResponse({ status: 200, description: '删除专栏成功' })
  remove(@Param('id') id: string, @Req() req) {
    return this.specialColumnService.softDeleteColumn(+id, req);
  }

  @Get('queryArticleListByColumnId/:id')
  @ApiOperation({
    summary: '根据专栏id查询文章列表',
    description: '根据专栏id查询文章列表',
  })
  @ApiParam({ name: 'id', description: '专栏id' })
  @ApiBody({ type: ListArticleColumnDto })
  @ApiResponse({ status: 200, description: '查询成功' })
  queryArticleListByColumnId(
    @Param('id') id: string,
    @Query() query: ListArticleColumnDto,
  ) {
    return this.specialColumnService.queryArticleListByColumnId(+id, query);
  }

  @Get('queryOtherArticleListByColumnId/:id')
  @ApiOperation({
    summary: '查询非该专栏的文章列表',
    description: '根据专栏id查询非该专栏的文章列表',
  })
  @ApiParam({ name: 'id', description: '专栏id' })
  @ApiBody({ type: ListArticleColumnDto })
  @ApiResponse({ status: 200, description: '查询成功' })
  queryOtherArticleListByColumnId(
    @Param('id') id: string,
    @Query() query: ListArticleColumnDto,
  ) {
    return this.specialColumnService.queryOtherArticleListByColumnId(
      +id,
      query,
    );
  }

  @Post('cancelArticleInclusion')
  @Roles('systemAdmin', 'admin', 'user')
  @ApiOperation({
    summary: '批量取消收录',
    description: '批量取消收录至该专栏',
  })
  @ApiBody({ type: ArticleColumnInclusionDto })
  @ApiResponse({ status: 200, description: '查询成功' })
  cancelArticleInclusion(@Body() body: ArticleColumnInclusionDto) {
    return this.specialColumnService.cancelArticleInclusion(body);
  }

  @Post('addArticleInclusion')
  @Roles('systemAdmin', 'admin', 'user')
  @ApiOperation({
    summary: '文章批量收录',
    description: '文章批量收录至该专栏',
  })
  @ApiBody({ type: ArticleColumnInclusionDto })
  @ApiResponse({ status: 200, description: '查询成功' })
  addArticleInclusion(@Body() body: ArticleColumnInclusionDto) {
    return this.specialColumnService.addArticleInclusion(body);
  }
}

专栏服务 SpecialColumnService

import {
  BadRequestException,
  Injectable,
  InternalServerErrorException,
  Logger,
  Req,
} from '@nestjs/common';
import { CreateSpecialColumnDto } from './dto/create-special-column.dto';
import { UpdateSpecialColumnDto } from './dto/update-special-column.dto';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Like, Not, Repository } from 'typeorm';
import { Article } from 'src/article/entities/article.entity';
import { ArticleColumnRelated } from 'src/article/entities/article-column-related.entity';
import { SpecialColumn } from './entities/special-column.entity';
import {
  removeUnnecessaryData,
  setCreatedUser,
  setUpdatedUser,
  setDeletedUser,
} from 'src/utils';
import { ListArticleColumnDto } from './dto/list-article-column.dto';
import { ArticleColumnInclusionDto } from './dto/article-column-inclusion.dto';

@Injectable()
export class SpecialColumnService {
  private logger = new Logger('SpecialColumnService');

  constructor(
    @InjectRepository(Article)
    private readonly articleRepository: Repository<Article>,
    @InjectRepository(ArticleColumnRelated)
    private readonly articleColumnRelatedRepository: Repository<ArticleColumnRelated>,
    @InjectRepository(SpecialColumn)
    private readonly specialColumnRepository: Repository<SpecialColumn>,
  ) {}

  // 新建专栏
  async create(createSpecialColumnDto: CreateSpecialColumnDto, @Req() req) {
    try {
      // 创建专栏实体
      const column = this.specialColumnRepository.create(
        createSpecialColumnDto,
      );
      const createdColumn = setCreatedUser(req, column);

      this.logger.log('@@@@ 新建专栏 createdColumn', createdColumn);

      return await this.specialColumnRepository.save(createdColumn);
    } catch (error) {
      this.logger.error('@@@@ 新建专栏失败:', error);
      throw new InternalServerErrorException('新建专栏失败');
    }
  }

  // 查询所有专栏
  async findAll() {
    try {
      const data = await this.specialColumnRepository.find({
        where: { isDeleted: 0 },
      });

      // 分别查询每个专栏的文章数量
      const specialColumnsWithCount = await Promise.all(
        data.map(async (column) => {
          const count = await this.articleColumnRelatedRepository.count({
            where: { columnId: column.id, isDeleted: 0 },
          });
          return { ...column, count };
        }),
      );

      const filterData = removeUnnecessaryData(specialColumnsWithCount);

      this.logger.log('@@@ 查询专栏列表', filterData);

      return filterData;
    } catch (error) {
      this.logger.error('@@@@ 查询专栏列表失败:', error);
      throw new InternalServerErrorException('查询专栏列表失败');
    }
  }

  // 查询专栏列表下拉选择
  async findAllForSelect() {
    try {
      const data = await this.specialColumnRepository.find({
        select: ['id', 'title'],
        where: { isDeleted: 0 },
      });

      this.logger.log('@@@ 查询专栏列表下拉选择', data);

      return data;
    } catch (error) {
      this.logger.error('@@@@ 查询专栏列表下拉选择失败:', error);
      throw new InternalServerErrorException('查询专栏列表下拉选择失败');
    }
  }

  // 查询专栏详情
  async findOne(id: number) {
    if (!id) {
      throw new BadRequestException('id必填');
    }
    try {
      const data = await this.specialColumnRepository.findOne({
        where: { id, isDeleted: 0 },
      });
      delete data.isDeleted;

      this.logger.log('@@@ 查询专栏详情', data);

      return data;
    } catch (error) {
      this.logger.error('@@@@ 查询专栏详情失败:', error);
      throw new InternalServerErrorException('查询专栏详情失败');
    }
  }

  // 修改专栏
  async update(
    id: number,
    updateSpecialColumnDto: UpdateSpecialColumnDto,
    @Req() req,
  ) {
    if (!id) {
      throw new BadRequestException('id必填');
    }

    try {
      // 专栏实体
      const column = Object.assign(
        new UpdateSpecialColumnDto(),
        updateSpecialColumnDto,
      );
      const updatedColumn = setUpdatedUser(req, column);

      this.logger.log('@@@ 修改专栏 updatedColumn', updatedColumn);

      // 更新专栏表
      await this.specialColumnRepository.update(id, updatedColumn);

      // 更新文章专栏关联表
      const relations = await this.articleColumnRelatedRepository.find({
        where: { columnId: id },
      });

      for (const relation of relations) {
        relation.columnTitle = updateSpecialColumnDto.title;
        await this.articleColumnRelatedRepository.save(relation);
      }

      return { message: '修改成功' };
    } catch (error) {
      this.logger.error('@@@@ 修改专栏失败:', error);
      throw new InternalServerErrorException('修改专栏失败');
    }
  }

  // 软删除文章专栏关联表
  async softDeleteArticleColumnRelations(columnId: number) {
    const relations = await this.articleColumnRelatedRepository.find({
      where: { columnId, isDeleted: 0 },
    });

    for (const relation of relations) {
      relation.isDeleted = 1; // 标记为已删除
      await this.articleColumnRelatedRepository.save(relation);
    }
  }

  // 软删除单个专栏
  async softDeleteColumn(id: number, @Req() req) {
    if (!id) {
      throw new BadRequestException('id必填');
    }

    try {
      const column = new SpecialColumn();
      const removedColumn = setDeletedUser(req, column);

      this.logger.log('@@@ 软删除单个专栏 removedColumn', removedColumn);

      await this.specialColumnRepository.update(id, removedColumn);

      // 软删除文章专栏关联表
      await this.softDeleteArticleColumnRelations(id);

      return { message: '删除成功' };
    } catch (error) {
      this.logger.error('@@@@ 删除专栏失败:', error);
      throw new InternalServerErrorException('删除专栏失败');
    }
  }

  // 根据专栏id查询文章列表
  async queryArticleListByColumnId(id: number, query: ListArticleColumnDto) {
    if (!id) {
      throw new BadRequestException('专栏ID不能为空');
    }

    try {
      // 查询关联中间表对应的articleIds
      const articleIdsMap = await this.articleColumnRelatedRepository.find({
        select: ['articleId'],
        where: {
          columnId: id,
          isDeleted: 0,
        },
      });

      const articleIds = articleIdsMap.map((item) => item.articleId);

      // 分页查询文章表
      const [data, total] = await this.articleRepository.findAndCount({
        where: {
          id: In(articleIds),
          title: query.articleTitle ? Like(`%${query.articleTitle}%`) : null,
          isDeleted: 0,
        },
        order: {
          id: 'DESC',
        },
        skip: (query.pageNum - 1) * query.pageSize,
        take: query.pageSize,
      });

      return {
        list: removeUnnecessaryData(data),
        pageNum: Number(query.pageNum),
        pageSize: Number(query.pageSize),
        total,
      };
    } catch (error) {
      this.logger.error('@@@@ 根据专栏id查询文章列表:', error);
      throw new InternalServerErrorException('列表查询失败');
    }
  }

  // 查询非该专栏的文章列表
  async queryOtherArticleListByColumnId(
    id: number,
    query: ListArticleColumnDto,
  ) {
    if (!id) {
      throw new BadRequestException('专栏ID不能为空');
    }

    try {
      // 查询关联中间表对应的articleIds 取反逻辑
      const articleIdsMap = await this.articleColumnRelatedRepository.find({
        select: ['articleId'],
        where: {
          columnId: id,
          isDeleted: 0,
        },
      });

      const articleIds = articleIdsMap.map((item) => item.articleId);

      // 分页查询文章表
      const [data, total] = await this.articleRepository.findAndCount({
        where: {
          id: Not(In(articleIds)),
          title: query.articleTitle ? Like(`%${query.articleTitle}%`) : null,
          isDeleted: 0,
        },
        order: {
          id: 'DESC',
        },
        skip: (query.pageNum - 1) * query.pageSize,
        take: query.pageSize,
      });

      return {
        list: removeUnnecessaryData(data),
        pageNum: Number(query.pageNum),
        pageSize: Number(query.pageSize),
        total,
      };
    } catch (error) {
      this.logger.error('@@@@ 查询非该专栏的文章列表:', error);
      throw new InternalServerErrorException('列表查询失败');
    }
  }

  // 批量取消收录至该专栏
  async cancelArticleInclusion(body: ArticleColumnInclusionDto) {
    try {
      await this.articleColumnRelatedRepository.update(
        {
          columnId: body.columnId,
          articleId: In(body.articleIds),
        },
        {
          isDeleted: 1,
        },
      );

      return {
        message: '取消收录成功',
      };
    } catch (error) {
      this.logger.error('取消收录失败:', error);
      throw new InternalServerErrorException('取消收录失败');
    }
  }

  // 批量添加新的文章至专栏
  async batchAddNewInclusion(articleIds, columnId) {
    return await Promise.all(
      articleIds.map(async (articleId) => {
        const column = await this.specialColumnRepository.findOneBy({
          id: columnId,
        });
        const article = await this.articleRepository.findOneBy({
          id: articleId,
        });
        const newRelation = new ArticleColumnRelated();
        newRelation.articleId = articleId;
        newRelation.articleTitle = article.title;
        newRelation.columnId = columnId;
        newRelation.columnTitle = column.title;
        return await this.articleColumnRelatedRepository.save(newRelation);
      }),
    );
  }

  // 文章批量收录至该专栏
  async addArticleInclusion(body: ArticleColumnInclusionDto) {
    try {
      // 判断是否已经收录过了但被软删除过的
      const hasInclusionArticles =
        await this.articleColumnRelatedRepository.find({
          select: ['articleId'],
          where: {
            columnId: body.columnId,
          },
        });

      // 已经收录的文章ID
      const hasInclusionArticleIds = hasInclusionArticles.map(
        (item) => item.articleId,
      );

      // 判断收录的文章id,哪些是新的(执行添加),哪些是已经收录的(修改isDeleted)
      // 新添加的
      const newArticleIds = body.articleIds.filter(
        (id) => !hasInclusionArticleIds.includes(id),
      );

      const oldArticleIds = body.articleIds.filter((id) =>
        hasInclusionArticleIds.includes(id),
      );

      // 修改isDeleted为0
      await this.articleColumnRelatedRepository.update(
        {
          columnId: body.columnId,
          articleId: In(oldArticleIds),
        },
        {
          isDeleted: 0,
        },
      );

      // 添加新
      await this.batchAddNewInclusion(newArticleIds, body.columnId);

      return {
        message: '文章批量收录成功',
      };
    } catch (error) {
      this.logger.error('文章批量收录至该专栏失败:', error);
      throw new InternalServerErrorException('文章批量收录至该专栏失败');
    }
  }
}

专栏模块 SpecialColumnModule

import { Module } from '@nestjs/common';
import { SpecialColumnService } from './special-column.service';
import { SpecialColumnController } from './special-column.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Article } from 'src/article/entities/article.entity';
import { ArticleColumnRelated } from 'src/article/entities/article-column-related.entity';
import { SpecialColumn } from './entities/special-column.entity';
import { Role } from 'src/user/entities/role.entity';

@Module({
  imports: [
    TypeOrmModule.forFeature([
      Article,
      ArticleColumnRelated,
      SpecialColumn,
      Role,
    ]),
  ],
  controllers: [SpecialColumnController],
  providers: [SpecialColumnService],
  exports: [SpecialColumnService],
})
export class SpecialColumnModule {}

专栏管理功能接口完成

目前完成了10个专栏相关的接口

column-module

实战合集地址

仓库地址