NestJS实战-文章专栏功能模块

文章模块开发

使用快捷键新建 article 模块:

css 复制代码
nest g res article
# 默认选择 REST API

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

less 复制代码
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 如下:

typescript 复制代码
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 代码如下:

less 复制代码
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 代码如下:

less 复制代码
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 代码如下:

scala 复制代码
import { PartialType } from '@nestjs/swagger';
import { CreateArticleDto } from './create-article.dto';

export class UpdateArticleDto extends PartialType(CreateArticleDto) {}

文章CRUD相关操作 ArticleController

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

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

article.controller.ts 代码如下:

less 复制代码
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

kotlin 复制代码
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

python 复制代码
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 {}
相关推荐
洛阳泰山1 小时前
从 0 到 1.6K Star:一个 Java 开源项目的增长复盘
人工智能·后端·开源
铁皮饭盒3 小时前
Bun执行python代码
前端·javascript·后端
菜鸟谢3 小时前
Rust 枚举 (enum) 完整核心知识点
后端
晓杰在写后端3 小时前
从0到1实现Balatro游戏后端(9):Blind奖励结算与金币系统实现
后端·游戏开发
Patrick_Wilson3 小时前
幂等到底是什么?从前端视角讲透 SQL、HTTP 与 POST 接口的幂等设计
前端·后端·架构
凌览3 小时前
一人公司别再上 Jenkins,真不值
前端·后端
菜鸟谢4 小时前
Rust 元组与数组内存管理笔记
后端
oil欧哟4 小时前
Codex 最佳实践(超级长文):先搞懂 AI,再用好 AI
前端·人工智能·后端
AskHarries4 小时前
把一个外部系统接成 MCP 工具
后端·程序员