Skip to content

第5章:文章管理模块

🎯 本章目标

在这一章中,我们将:

  • 创建文章管理模块
  • 实现文章的 CRUD 操作
  • 创建分类和标签管理
  • 实现文章搜索和分页
  • 创建评论系统
  • 实现文章状态管理

📁 创建模块结构

1. 生成文章相关模块

bash
# 生成文章模块
nest g mo modules/articles
nest g s modules/articles
nest g co modules/articles

# 生成分类模块
nest g mo modules/categories
nest g s modules/categories
nest g co modules/categories

# 生成标签模块
nest g mo modules/tags
nest g s modules/tags
nest g co modules/tags

# 生成评论模块
nest g mo modules/comments
nest g s modules/comments
nest g co modules/comments

2. 创建目录结构

src/modules/
├── articles/
│   ├── dto/
│   │   ├── create-article.dto.ts
│   │   ├── update-article.dto.ts
│   │   └── query-article.dto.ts
│   ├── entities/
│   │   └── article.entity.ts
│   ├── articles.controller.ts
│   ├── articles.service.ts
│   └── articles.module.ts
├── categories/
│   ├── dto/
│   ├── entities/
│   ├── categories.controller.ts
│   ├── categories.service.ts
│   └── categories.module.ts
├── tags/
│   ├── dto/
│   ├── entities/
│   ├── tags.controller.ts
│   ├── tags.service.ts
│   └── tags.module.ts
└── comments/
    ├── dto/
    ├── entities/
    ├── comments.controller.ts
    ├── comments.service.ts
    └── comments.module.ts

📝 创建文章 DTO

1. 文章创建和更新 DTO

typescript
// src/modules/articles/dto/create-article.dto.ts
import {
  IsString,
  IsNotEmpty,
  IsOptional,
  IsBoolean,
  IsArray,
  IsUUID,
  MaxLength,
  MinLength,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArticleStatus } from '@prisma/client';

export class CreateArticleDto {
  @ApiProperty({ 
    description: '文章标题',
    example: 'NestJS 实战指南',
    maxLength: 255
  })
  @IsString({ message: '标题必须是字符串' })
  @IsNotEmpty({ message: '标题不能为空' })
  @MaxLength(255, { message: '标题最多255个字符' })
  title: string;

  @ApiProperty({ 
    description: '文章内容',
    example: '这是一篇关于 NestJS 的详细教程...'
  })
  @IsString({ message: '内容必须是字符串' })
  @IsNotEmpty({ message: '内容不能为空' })
  content: string;

  @ApiPropertyOptional({ 
    description: '文章摘要',
    maxLength: 500
  })
  @IsOptional()
  @IsString({ message: '摘要必须是字符串' })
  @MaxLength(500, { message: '摘要最多500个字符' })
  excerpt?: string;

  @ApiPropertyOptional({ 
    description: '封面图片URL'
  })
  @IsOptional()
  @IsString({ message: '封面图片URL必须是字符串' })
  coverImage?: string;

  @ApiProperty({ 
    description: '分类ID',
    format: 'uuid'
  })
  @IsUUID(4, { message: '分类ID必须是有效的UUID' })
  @IsNotEmpty({ message: '分类ID不能为空' })
  categoryId: string;

  @ApiPropertyOptional({ 
    description: '标签ID列表',
    type: [String]
  })
  @IsOptional()
  @IsArray({ message: '标签ID必须是数组' })
  @IsUUID(4, { each: true, message: '每个标签ID必须是有效的UUID' })
  tagIds?: string[];

  @ApiPropertyOptional({ 
    description: '文章状态',
    enum: ArticleStatus,
    default: ArticleStatus.DRAFT
  })
  @IsOptional()
  status?: ArticleStatus;

  @ApiPropertyOptional({ 
    description: '是否允许评论',
    default: true
  })
  @IsOptional()
  @IsBoolean({ message: '允许评论必须是布尔值' })
  allowComments?: boolean;

  @ApiPropertyOptional({ 
    description: '是否置顶',
    default: false
  })
  @IsOptional()
  @IsBoolean({ message: '置顶必须是布尔值' })
  isPinned?: boolean;

  @ApiPropertyOptional({ 
    description: '是否推荐',
    default: false
  })
  @IsOptional()
  @IsBoolean({ message: '推荐必须是布尔值' })
  isFeatured?: boolean;

  // SEO 相关字段
  @ApiPropertyOptional({ 
    description: 'SEO标题',
    maxLength: 255
  })
  @IsOptional()
  @IsString({ message: 'SEO标题必须是字符串' })
  @MaxLength(255, { message: 'SEO标题最多255个字符' })
  metaTitle?: string;

  @ApiPropertyOptional({ 
    description: 'SEO描述',
    maxLength: 500
  })
  @IsOptional()
  @IsString({ message: 'SEO描述必须是字符串' })
  @MaxLength(500, { message: 'SEO描述最多500个字符' })
  metaDescription?: string;

  @ApiPropertyOptional({ 
    description: 'SEO关键词'
  })
  @IsOptional()
  @IsString({ message: 'SEO关键词必须是字符串' })
  metaKeywords?: string;
}
typescript
// src/modules/articles/dto/update-article.dto.ts
import { PartialType } from '@nestjs/swagger';
import { CreateArticleDto } from './create-article.dto';

export class UpdateArticleDto extends PartialType(CreateArticleDto) {}
typescript
// src/modules/articles/dto/query-article.dto.ts
import {
  IsOptional,
  IsString,
  IsEnum,
  IsInt,
  Min,
  Max,
  IsBoolean,
  IsUUID,
} from 'class-validator';
import { Type, Transform } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { ArticleStatus } from '@prisma/client';

export class QueryArticleDto {
  @ApiPropertyOptional({ 
    description: '页码', 
    default: 1, 
    minimum: 1 
  })
  @IsOptional()
  @Type(() => Number)
  @IsInt({ message: '页码必须是整数' })
  @Min(1, { message: '页码最小为1' })
  page?: number = 1;

  @ApiPropertyOptional({ 
    description: '每页数量', 
    default: 10, 
    minimum: 1, 
    maximum: 100 
  })
  @IsOptional()
  @Type(() => Number)
  @IsInt({ message: '每页数量必须是整数' })
  @Min(1, { message: '每页数量最小为1' })
  @Max(100, { message: '每页数量最大为100' })
  limit?: number = 10;

  @ApiPropertyOptional({ description: '搜索关键词' })
  @IsOptional()
  @IsString({ message: '搜索关键词必须是字符串' })
  search?: string;

  @ApiPropertyOptional({ 
    description: '文章状态', 
    enum: ArticleStatus 
  })
  @IsOptional()
  @IsEnum(ArticleStatus, { message: '无效的文章状态' })
  status?: ArticleStatus;

  @ApiPropertyOptional({ 
    description: '分类ID',
    format: 'uuid'
  })
  @IsOptional()
  @IsUUID(4, { message: '分类ID必须是有效的UUID' })
  categoryId?: string;

  @ApiPropertyOptional({ 
    description: '标签ID',
    format: 'uuid'
  })
  @IsOptional()
  @IsUUID(4, { message: '标签ID必须是有效的UUID' })
  tagId?: string;

  @ApiPropertyOptional({ 
    description: '作者ID',
    format: 'uuid'
  })
  @IsOptional()
  @IsUUID(4, { message: '作者ID必须是有效的UUID' })
  authorId?: string;

  @ApiPropertyOptional({ 
    description: '是否只显示置顶文章'
  })
  @IsOptional()
  @Transform(({ value }) => value === 'true')
  @IsBoolean({ message: '置顶状态必须是布尔值' })
  isPinned?: boolean;

  @ApiPropertyOptional({ 
    description: '是否只显示推荐文章'
  })
  @IsOptional()
  @Transform(({ value }) => value === 'true')
  @IsBoolean({ message: '推荐状态必须是布尔值' })
  isFeatured?: boolean;

  @ApiPropertyOptional({ 
    description: '排序字段',
    default: 'createdAt',
    enum: ['createdAt', 'updatedAt', 'publishedAt', 'viewCount', 'likeCount', 'title']
  })
  @IsOptional()
  @IsString({ message: '排序字段必须是字符串' })
  sortBy?: string = 'createdAt';

  @ApiPropertyOptional({ 
    description: '排序方向',
    enum: ['asc', 'desc'],
    default: 'desc'
  })
  @IsOptional()
  @IsString({ message: '排序方向必须是字符串' })
  sortOrder?: 'asc' | 'desc' = 'desc';
}

🏗️ 创建文章实体

typescript
// src/modules/articles/entities/article.entity.ts
import { Article, ArticleStatus } from '@prisma/client';
import { ApiProperty } from '@nestjs/swagger';
import { UserEntity } from '../../users/entities/user.entity';

export class ArticleEntity implements Article {
  @ApiProperty()
  id: string;

  @ApiProperty()
  title: string;

  @ApiProperty()
  slug: string;

  @ApiProperty()
  content: string;

  @ApiProperty({ required: false })
  excerpt: string | null;

  @ApiProperty({ required: false })
  coverImage: string | null;

  @ApiProperty({ enum: ArticleStatus })
  status: ArticleStatus;

  @ApiProperty()
  viewCount: number;

  @ApiProperty()
  likeCount: number;

  @ApiProperty({ required: false })
  metaTitle: string | null;

  @ApiProperty({ required: false })
  metaDescription: string | null;

  @ApiProperty({ required: false })
  metaKeywords: string | null;

  @ApiProperty({ required: false })
  publishedAt: Date | null;

  @ApiProperty()
  allowComments: boolean;

  @ApiProperty()
  isPinned: boolean;

  @ApiProperty()
  isFeatured: boolean;

  @ApiProperty()
  authorId: string;

  @ApiProperty()
  categoryId: string;

  @ApiProperty()
  createdAt: Date;

  @ApiProperty()
  updatedAt: Date;

  // 关联数据(可选)
  @ApiProperty({ type: () => UserEntity, required: false })
  author?: UserEntity;

  @ApiProperty({ required: false })
  category?: any;

  @ApiProperty({ required: false })
  tags?: any[];

  @ApiProperty({ required: false })
  comments?: any[];

  constructor(article: Article & { author?: any; category?: any; tags?: any[]; comments?: any[] }) {
    Object.assign(this, article);
  }
}

🔧 实现文章服务

typescript
// src/modules/articles/articles.service.ts
import {
  Injectable,
  NotFoundException,
  ForbiddenException,
  BadRequestException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateArticleDto } from './dto/create-article.dto';
import { UpdateArticleDto } from './dto/update-article.dto';
import { QueryArticleDto } from './dto/query-article.dto';
import { ArticleEntity } from './entities/article.entity';
import { Article, ArticleStatus, Prisma, UserRole } from '@prisma/client';
import { UserEntity } from '../users/entities/user.entity';

@Injectable()
export class ArticlesService {
  constructor(private prisma: PrismaService) {}

  async create(
    createArticleDto: CreateArticleDto,
    authorId: string,
  ): Promise<ArticleEntity> {
    const { tagIds, ...articleData } = createArticleDto;

    // 生成 slug
    const slug = await this.generateUniqueSlug(createArticleDto.title);

    // 验证分类是否存在
    const category = await this.prisma.category.findUnique({
      where: { id: createArticleDto.categoryId },
    });

    if (!category) {
      throw new BadRequestException('指定的分类不存在');
    }

    // 验证标签是否存在
    if (tagIds && tagIds.length > 0) {
      const existingTags = await this.prisma.tag.findMany({
        where: { id: { in: tagIds } },
      });

      if (existingTags.length !== tagIds.length) {
        throw new BadRequestException('部分标签不存在');
      }
    }

    try {
      const article = await this.prisma.article.create({
        data: {
          ...articleData,
          slug,
          authorId,
          publishedAt: articleData.status === ArticleStatus.PUBLISHED ? new Date() : null,
          // 创建文章标签关联
          articleTags: tagIds
            ? {
                create: tagIds.map((tagId) => ({
                  tag: { connect: { id: tagId } },
                })),
              }
            : undefined,
        },
        include: {
          author: true,
          category: true,
          articleTags: {
            include: {
              tag: true,
            },
          },
        },
      });

      // 更新标签使用次数
      if (tagIds && tagIds.length > 0) {
        await this.prisma.tag.updateMany({
          where: { id: { in: tagIds } },
          data: { useCount: { increment: 1 } },
        });
      }

      return new ArticleEntity({
        ...article,
        tags: article.articleTags.map((at) => at.tag),
      });
    } catch (error) {
      if (error instanceof Prisma.PrismaClientKnownRequestError) {
        if (error.code === 'P2002') {
          throw new BadRequestException('文章标题已存在');
        }
      }
      throw error;
    }
  }

  async findAll(queryDto: QueryArticleDto) {
    const {
      page = 1,
      limit = 10,
      search,
      status,
      categoryId,
      tagId,
      authorId,
      isPinned,
      isFeatured,
      sortBy = 'createdAt',
      sortOrder = 'desc',
    } = queryDto;

    const skip = (page - 1) * limit;

    // 构建查询条件
    const where: Prisma.ArticleWhereInput = {};

    if (search) {
      where.OR = [
        { title: { contains: search } },
        { content: { contains: search } },
        { excerpt: { contains: search } },
      ];
    }

    if (status) {
      where.status = status;
    }

    if (categoryId) {
      where.categoryId = categoryId;
    }

    if (tagId) {
      where.articleTags = {
        some: {
          tagId: tagId,
        },
      };
    }

    if (authorId) {
      where.authorId = authorId;
    }

    if (isPinned !== undefined) {
      where.isPinned = isPinned;
    }

    if (isFeatured !== undefined) {
      where.isFeatured = isFeatured;
    }

    // 构建排序条件
    const orderBy: Prisma.ArticleOrderByWithRelationInput = {};
    orderBy[sortBy] = sortOrder;

    const [articles, total] = await Promise.all([
      this.prisma.article.findMany({
        where,
        skip,
        take: limit,
        orderBy,
        include: {
          author: true,
          category: true,
          articleTags: {
            include: {
              tag: true,
            },
          },
          _count: {
            select: {
              comments: true,
            },
          },
        },
      }),
      this.prisma.article.count({ where }),
    ]);

    return {
      data: articles.map(
        (article) =>
          new ArticleEntity({
            ...article,
            tags: article.articleTags.map((at) => at.tag),
            commentsCount: article._count.comments,
          }),
      ),
      meta: {
        total,
        page,
        limit,
        totalPages: Math.ceil(total / limit),
        hasNextPage: page < Math.ceil(total / limit),
        hasPrevPage: page > 1,
      },
    };
  }

  async findBySlug(slug: string): Promise<ArticleEntity | null> {
    const article = await this.prisma.article.findUnique({
      where: { slug },
      include: {
        author: true,
        category: true,
        articleTags: {
          include: {
            tag: true,
          },
        },
        comments: {
          where: { status: 'APPROVED' },
          include: {
            author: true,
            replies: {
              include: {
                author: true,
              },
            },
          },
          orderBy: { createdAt: 'desc' },
        },
      },
    });

    if (!article) {
      return null;
    }

    // 增加浏览次数
    await this.prisma.article.update({
      where: { id: article.id },
      data: { viewCount: { increment: 1 } },
    });

    return new ArticleEntity({
      ...article,
      tags: article.articleTags.map((at) => at.tag),
    });
  }

  async findById(id: string): Promise<ArticleEntity | null> {
    const article = await this.prisma.article.findUnique({
      where: { id },
      include: {
        author: true,
        category: true,
        articleTags: {
          include: {
            tag: true,
          },
        },
      },
    });

    return article
      ? new ArticleEntity({
          ...article,
          tags: article.articleTags.map((at) => at.tag),
        })
      : null;
  }

  async update(
    id: string,
    updateArticleDto: UpdateArticleDto,
    currentUser: UserEntity,
  ): Promise<ArticleEntity> {
    const article = await this.findById(id);
    if (!article) {
      throw new NotFoundException('文章不存在');
    }

    // 权限检查:只有作者或管理员可以编辑
    if (
      article.authorId !== currentUser.id &&
      !['ADMIN', 'MODERATOR'].includes(currentUser.role)
    ) {
      throw new ForbiddenException('无权编辑此文章');
    }

    const { tagIds, ...articleData } = updateArticleDto;

    // 如果状态改为已发布且之前未发布,设置发布时间
    if (
      articleData.status === ArticleStatus.PUBLISHED &&
      article.status !== ArticleStatus.PUBLISHED
    ) {
      articleData.publishedAt = new Date();
    }

    try {
      // 更新文章标签关联
      if (tagIds !== undefined) {
        // 删除现有标签关联
        await this.prisma.articleTag.deleteMany({
          where: { articleId: id },
        });

        // 创建新的标签关联
        if (tagIds.length > 0) {
          await this.prisma.articleTag.createMany({
            data: tagIds.map((tagId) => ({
              articleId: id,
              tagId,
            })),
          });

          // 更新标签使用次数
          await this.prisma.tag.updateMany({
            where: { id: { in: tagIds } },
            data: { useCount: { increment: 1 } },
          });
        }
      }

      const updatedArticle = await this.prisma.article.update({
        where: { id },
        data: articleData,
        include: {
          author: true,
          category: true,
          articleTags: {
            include: {
              tag: true,
            },
          },
        },
      });

      return new ArticleEntity({
        ...updatedArticle,
        tags: updatedArticle.articleTags.map((at) => at.tag),
      });
    } catch (error) {
      if (error instanceof Prisma.PrismaClientKnownRequestError) {
        if (error.code === 'P2002') {
          throw new BadRequestException('文章标题已存在');
        }
      }
      throw error;
    }
  }

  async remove(id: string, currentUser: UserEntity): Promise<void> {
    const article = await this.findById(id);
    if (!article) {
      throw new NotFoundException('文章不存在');
    }

    // 权限检查:只有作者或管理员可以删除
    if (
      article.authorId !== currentUser.id &&
      currentUser.role !== UserRole.ADMIN
    ) {
      throw new ForbiddenException('无权删除此文章');
    }

    await this.prisma.article.delete({
      where: { id },
    });
  }

  async toggleLike(id: string): Promise<{ liked: boolean; likeCount: number }> {
    const article = await this.prisma.article.findUnique({
      where: { id },
    });

    if (!article) {
      throw new NotFoundException('文章不存在');
    }

    // 简单的点赞实现(实际项目中应该记录用户点赞状态)
    const updatedArticle = await this.prisma.article.update({
      where: { id },
      data: {
        likeCount: { increment: 1 },
      },
    });

    return {
      liked: true,
      likeCount: updatedArticle.likeCount,
    };
  }

  private async generateUniqueSlug(title: string): Promise<string> {
    let baseSlug = title
      .toLowerCase()
      .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
      .replace(/^-+|-+$/g, '');

    let slug = baseSlug;
    let counter = 1;

    while (await this.prisma.article.findUnique({ where: { slug } })) {
      slug = `${baseSlug}-${counter}`;
      counter++;
    }

    return slug;
  }

  async getArticleStats(authorId?: string) {
    const where: Prisma.ArticleWhereInput = authorId ? { authorId } : {};

    const [total, published, draft, archived] = await Promise.all([
      this.prisma.article.count({ where }),
      this.prisma.article.count({
        where: { ...where, status: ArticleStatus.PUBLISHED },
      }),
      this.prisma.article.count({
        where: { ...where, status: ArticleStatus.DRAFT },
      }),
      this.prisma.article.count({
        where: { ...where, status: ArticleStatus.ARCHIVED },
      }),
    ]);

    return {
      total,
      published,
      draft,
      archived,
    };
  }
}

🎮 实现文章控制器

typescript
// src/modules/articles/articles.controller.ts
import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
  Query,
  UseGuards,
  ParseUUIDPipe,
  HttpCode,
  HttpStatus,
} from '@nestjs/common';
import {
  ApiTags,
  ApiOperation,
  ApiResponse,
  ApiBearerAuth,
  ApiParam,
} from '@nestjs/swagger';
import { ArticlesService } from './articles.service';
import { CreateArticleDto } from './dto/create-article.dto';
import { UpdateArticleDto } from './dto/update-article.dto';
import { QueryArticleDto } from './dto/query-article.dto';
import { ArticleEntity } from './entities/article.entity';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Public } from '../auth/decorators/public.decorator';
import { UserRole } from '@prisma/client';
import { UserEntity } from '../users/entities/user.entity';

@ApiTags('文章管理')
@Controller('articles')
@UseGuards(RolesGuard)
export class ArticlesController {
  constructor(private readonly articlesService: ArticlesService) {}

  @ApiBearerAuth()
  @Post()
  @ApiOperation({ summary: '创建文章' })
  @ApiResponse({
    status: 201,
    description: '文章创建成功',
    type: ArticleEntity
  })
  async create(
    @Body() createArticleDto: CreateArticleDto,
    @CurrentUser('id') authorId: string,
  ): Promise<ArticleEntity> {
    return this.articlesService.create(createArticleDto, authorId);
  }

  @Public()
  @Get()
  @ApiOperation({ summary: '获取文章列表' })
  @ApiResponse({
    status: 200,
    description: '获取成功',
    schema: {
      type: 'object',
      properties: {
        data: {
          type: 'array',
          items: { $ref: '#/components/schemas/ArticleEntity' }
        },
        meta: {
          type: 'object',
          properties: {
            total: { type: 'number' },
            page: { type: 'number' },
            limit: { type: 'number' },
            totalPages: { type: 'number' },
            hasNextPage: { type: 'boolean' },
            hasPrevPage: { type: 'boolean' },
          }
        }
      }
    }
  })
  async findAll(@Query() queryDto: QueryArticleDto) {
    return this.articlesService.findAll(queryDto);
  }

  @Public()
  @Get('slug/:slug')
  @ApiOperation({ summary: '根据 slug 获取文章详情' })
  @ApiParam({ name: 'slug', description: '文章 slug' })
  @ApiResponse({
    status: 200,
    description: '获取成功',
    type: ArticleEntity
  })
  @ApiResponse({ status: 404, description: '文章不存在' })
  async findBySlug(@Param('slug') slug: string): Promise<ArticleEntity> {
    const article = await this.articlesService.findBySlug(slug);
    if (!article) {
      throw new NotFoundException('文章不存在');
    }
    return article;
  }

  @Public()
  @Get('stats')
  @ApiOperation({ summary: '获取文章统计信息' })
  @ApiResponse({ status: 200, description: '获取成功' })
  async getStats(@Query('authorId') authorId?: string) {
    return this.articlesService.getArticleStats(authorId);
  }

  @ApiBearerAuth()
  @Get('my')
  @ApiOperation({ summary: '获取当前用户的文章列表' })
  @ApiResponse({ status: 200, description: '获取成功' })
  async getMyArticles(
    @CurrentUser('id') authorId: string,
    @Query() queryDto: QueryArticleDto,
  ) {
    return this.articlesService.findAll({ ...queryDto, authorId });
  }

  @Public()
  @Get(':id')
  @ApiOperation({ summary: '根据ID获取文章详情' })
  @ApiParam({ name: 'id', description: '文章ID', format: 'uuid' })
  @ApiResponse({
    status: 200,
    description: '获取成功',
    type: ArticleEntity
  })
  @ApiResponse({ status: 404, description: '文章不存在' })
  async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<ArticleEntity> {
    const article = await this.articlesService.findById(id);
    if (!article) {
      throw new NotFoundException('文章不存在');
    }
    return article;
  }

  @ApiBearerAuth()
  @Patch(':id')
  @ApiOperation({ summary: '更新文章' })
  @ApiParam({ name: 'id', description: '文章ID', format: 'uuid' })
  @ApiResponse({
    status: 200,
    description: '更新成功',
    type: ArticleEntity
  })
  @ApiResponse({ status: 404, description: '文章不存在' })
  @ApiResponse({ status: 403, description: '无权编辑此文章' })
  async update(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() updateArticleDto: UpdateArticleDto,
    @CurrentUser() currentUser: UserEntity,
  ): Promise<ArticleEntity> {
    return this.articlesService.update(id, updateArticleDto, currentUser);
  }

  @ApiBearerAuth()
  @Post(':id/like')
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: '点赞文章' })
  @ApiParam({ name: 'id', description: '文章ID', format: 'uuid' })
  @ApiResponse({ status: 200, description: '点赞成功' })
  @ApiResponse({ status: 404, description: '文章不存在' })
  async toggleLike(@Param('id', ParseUUIDPipe) id: string) {
    return this.articlesService.toggleLike(id);
  }

  @ApiBearerAuth()
  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  @ApiOperation({ summary: '删除文章' })
  @ApiParam({ name: 'id', description: '文章ID', format: 'uuid' })
  @ApiResponse({ status: 204, description: '删除成功' })
  @ApiResponse({ status: 404, description: '文章不存在' })
  @ApiResponse({ status: 403, description: '无权删除此文章' })
  async remove(
    @Param('id', ParseUUIDPipe) id: string,
    @CurrentUser() currentUser: UserEntity,
  ): Promise<void> {
    return this.articlesService.remove(id, currentUser);
  }
}

📂 实现分类模块

1. 分类 DTO

typescript
// src/modules/categories/dto/create-category.dto.ts
import {
  IsString,
  IsNotEmpty,
  IsOptional,
  IsInt,
  MaxLength,
  Matches,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class CreateCategoryDto {
  @ApiProperty({
    description: '分类名称',
    example: '技术',
    maxLength: 100
  })
  @IsString({ message: '分类名称必须是字符串' })
  @IsNotEmpty({ message: '分类名称不能为空' })
  @MaxLength(100, { message: '分类名称最多100个字符' })
  name: string;

  @ApiPropertyOptional({
    description: '分类描述',
    maxLength: 500
  })
  @IsOptional()
  @IsString({ message: '分类描述必须是字符串' })
  @MaxLength(500, { message: '分类描述最多500个字符' })
  description?: string;

  @ApiPropertyOptional({
    description: '分类颜色(十六进制)',
    example: '#3B82F6',
    pattern: '^#[0-9A-Fa-f]{6}$'
  })
  @IsOptional()
  @IsString({ message: '分类颜色必须是字符串' })
  @Matches(/^#[0-9A-Fa-f]{6}$/, { message: '分类颜色必须是有效的十六进制颜色值' })
  color?: string;

  @ApiPropertyOptional({
    description: '分类图标',
    maxLength: 50
  })
  @IsOptional()
  @IsString({ message: '分类图标必须是字符串' })
  @MaxLength(50, { message: '分类图标最多50个字符' })
  icon?: string;

  @ApiPropertyOptional({
    description: '排序顺序',
    default: 0
  })
  @IsOptional()
  @IsInt({ message: '排序顺序必须是整数' })
  sortOrder?: number;

  @ApiPropertyOptional({
    description: 'SEO标题',
    maxLength: 255
  })
  @IsOptional()
  @IsString({ message: 'SEO标题必须是字符串' })
  @MaxLength(255, { message: 'SEO标题最多255个字符' })
  metaTitle?: string;

  @ApiPropertyOptional({
    description: 'SEO描述',
    maxLength: 500
  })
  @IsOptional()
  @IsString({ message: 'SEO描述必须是字符串' })
  @MaxLength(500, { message: 'SEO描述最多500个字符' })
  metaDescription?: string;
}
typescript
// src/modules/categories/dto/update-category.dto.ts
import { PartialType } from '@nestjs/swagger';
import { CreateCategoryDto } from './create-category.dto';

export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}

2. 分类服务

typescript
// src/modules/categories/categories.service.ts
import {
  Injectable,
  NotFoundException,
  ConflictException,
  BadRequestException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateCategoryDto } from './dto/create-category.dto';
import { UpdateCategoryDto } from './dto/update-category.dto';
import { Category, Prisma } from '@prisma/client';

@Injectable()
export class CategoriesService {
  constructor(private prisma: PrismaService) {}

  async create(createCategoryDto: CreateCategoryDto): Promise<Category> {
    const { name, ...rest } = createCategoryDto;

    // 生成 slug
    const slug = this.generateSlug(name);

    // 检查名称和 slug 是否已存在
    const existingCategory = await this.prisma.category.findFirst({
      where: {
        OR: [{ name }, { slug }],
      },
    });

    if (existingCategory) {
      throw new ConflictException('分类名称已存在');
    }

    try {
      return await this.prisma.category.create({
        data: {
          name,
          slug,
          ...rest,
        },
      });
    } catch (error) {
      if (error instanceof Prisma.PrismaClientKnownRequestError) {
        if (error.code === 'P2002') {
          throw new ConflictException('分类名称已存在');
        }
      }
      throw error;
    }
  }

  async findAll(): Promise<Category[]> {
    return this.prisma.category.findMany({
      orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
      include: {
        _count: {
          select: {
            articles: {
              where: { status: 'PUBLISHED' },
            },
          },
        },
      },
    });
  }

  async findById(id: string): Promise<Category | null> {
    return this.prisma.category.findUnique({
      where: { id },
      include: {
        _count: {
          select: {
            articles: {
              where: { status: 'PUBLISHED' },
            },
          },
        },
      },
    });
  }

  async findBySlug(slug: string): Promise<Category | null> {
    return this.prisma.category.findUnique({
      where: { slug },
      include: {
        _count: {
          select: {
            articles: {
              where: { status: 'PUBLISHED' },
            },
          },
        },
      },
    });
  }

  async update(id: string, updateCategoryDto: UpdateCategoryDto): Promise<Category> {
    const category = await this.prisma.category.findUnique({
      where: { id },
    });

    if (!category) {
      throw new NotFoundException('分类不存在');
    }

    // 如果更新名称,需要重新生成 slug
    let slug = category.slug;
    if (updateCategoryDto.name && updateCategoryDto.name !== category.name) {
      slug = this.generateSlug(updateCategoryDto.name);

      // 检查新的名称和 slug 是否已存在
      const existingCategory = await this.prisma.category.findFirst({
        where: {
          OR: [{ name: updateCategoryDto.name }, { slug }],
          NOT: { id },
        },
      });

      if (existingCategory) {
        throw new ConflictException('分类名称已存在');
      }
    }

    try {
      return await this.prisma.category.update({
        where: { id },
        data: {
          ...updateCategoryDto,
          slug,
        },
      });
    } catch (error) {
      if (error instanceof Prisma.PrismaClientKnownRequestError) {
        if (error.code === 'P2002') {
          throw new ConflictException('分类名称已存在');
        }
      }
      throw error;
    }
  }

  async remove(id: string): Promise<void> {
    const category = await this.prisma.category.findUnique({
      where: { id },
      include: {
        _count: {
          select: { articles: true },
        },
      },
    });

    if (!category) {
      throw new NotFoundException('分类不存在');
    }

    if (category._count.articles > 0) {
      throw new BadRequestException('该分类下还有文章,无法删除');
    }

    await this.prisma.category.delete({
      where: { id },
    });
  }

  private generateSlug(name: string): string {
    return name
      .toLowerCase()
      .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
      .replace(/^-+|-+$/g, '');
  }
}

🏷️ 实现标签模块

1. 标签服务(简化版)

typescript
// src/modules/tags/tags.service.ts
import {
  Injectable,
  NotFoundException,
  ConflictException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { Tag, Prisma } from '@prisma/client';

export interface CreateTagDto {
  name: string;
  description?: string;
  color?: string;
}

export interface UpdateTagDto {
  name?: string;
  description?: string;
  color?: string;
}

@Injectable()
export class TagsService {
  constructor(private prisma: PrismaService) {}

  async create(createTagDto: CreateTagDto): Promise<Tag> {
    const { name, ...rest } = createTagDto;
    const slug = this.generateSlug(name);

    try {
      return await this.prisma.tag.create({
        data: {
          name,
          slug,
          ...rest,
        },
      });
    } catch (error) {
      if (error instanceof Prisma.PrismaClientKnownRequestError) {
        if (error.code === 'P2002') {
          throw new ConflictException('标签名称已存在');
        }
      }
      throw error;
    }
  }

  async findAll(): Promise<Tag[]> {
    return this.prisma.tag.findMany({
      orderBy: [{ useCount: 'desc' }, { createdAt: 'desc' }],
      include: {
        _count: {
          select: { articleTags: true },
        },
      },
    });
  }

  async findById(id: string): Promise<Tag | null> {
    return this.prisma.tag.findUnique({
      where: { id },
    });
  }

  async update(id: string, updateTagDto: UpdateTagDto): Promise<Tag> {
    const tag = await this.findById(id);
    if (!tag) {
      throw new NotFoundException('标签不存在');
    }

    let slug = tag.slug;
    if (updateTagDto.name && updateTagDto.name !== tag.name) {
      slug = this.generateSlug(updateTagDto.name);
    }

    return this.prisma.tag.update({
      where: { id },
      data: {
        ...updateTagDto,
        slug,
      },
    });
  }

  async remove(id: string): Promise<void> {
    const tag = await this.findById(id);
    if (!tag) {
      throw new NotFoundException('标签不存在');
    }

    await this.prisma.tag.delete({
      where: { id },
    });
  }

  private generateSlug(name: string): string {
    return name
      .toLowerCase()
      .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
      .replace(/^-+|-+$/g, '');
  }
}

💬 实现评论系统

1. 评论 DTO

typescript
// src/modules/comments/dto/create-comment.dto.ts
import {
  IsString,
  IsNotEmpty,
  IsOptional,
  IsUUID,
  MaxLength,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class CreateCommentDto {
  @ApiProperty({
    description: '评论内容',
    example: '这篇文章写得很好!',
    maxLength: 1000
  })
  @IsString({ message: '评论内容必须是字符串' })
  @IsNotEmpty({ message: '评论内容不能为空' })
  @MaxLength(1000, { message: '评论内容最多1000个字符' })
  content: string;

  @ApiProperty({
    description: '文章ID',
    format: 'uuid'
  })
  @IsUUID(4, { message: '文章ID必须是有效的UUID' })
  @IsNotEmpty({ message: '文章ID不能为空' })
  articleId: string;

  @ApiPropertyOptional({
    description: '父评论ID(回复评论时使用)',
    format: 'uuid'
  })
  @IsOptional()
  @IsUUID(4, { message: '父评论ID必须是有效的UUID' })
  parentId?: string;
}

2. 评论服务

typescript
// src/modules/comments/comments.service.ts
import {
  Injectable,
  NotFoundException,
  ForbiddenException,
  BadRequestException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateCommentDto } from './dto/create-comment.dto';
import { Comment, CommentStatus, UserRole } from '@prisma/client';
import { UserEntity } from '../users/entities/user.entity';

@Injectable()
export class CommentsService {
  constructor(private prisma: PrismaService) {}

  async create(
    createCommentDto: CreateCommentDto,
    authorId: string,
    ipAddress?: string,
    userAgent?: string,
  ): Promise<Comment> {
    const { articleId, parentId, content } = createCommentDto;

    // 验证文章是否存在且允许评论
    const article = await this.prisma.article.findUnique({
      where: { id: articleId },
    });

    if (!article) {
      throw new NotFoundException('文章不存在');
    }

    if (!article.allowComments) {
      throw new BadRequestException('该文章不允许评论');
    }

    // 如果是回复评论,验证父评论是否存在
    if (parentId) {
      const parentComment = await this.prisma.comment.findUnique({
        where: { id: parentId },
      });

      if (!parentComment) {
        throw new NotFoundException('父评论不存在');
      }

      if (parentComment.articleId !== articleId) {
        throw new BadRequestException('父评论不属于该文章');
      }
    }

    return this.prisma.comment.create({
      data: {
        content,
        articleId,
        authorId,
        parentId,
        ipAddress,
        userAgent,
        status: CommentStatus.PENDING, // 默认待审核
      },
      include: {
        author: true,
        replies: {
          include: {
            author: true,
          },
        },
      },
    });
  }

  async findByArticle(articleId: string) {
    return this.prisma.comment.findMany({
      where: {
        articleId,
        status: CommentStatus.APPROVED,
        parentId: null, // 只获取顶级评论
      },
      include: {
        author: true,
        replies: {
          where: { status: CommentStatus.APPROVED },
          include: {
            author: true,
          },
          orderBy: { createdAt: 'asc' },
        },
      },
      orderBy: { createdAt: 'desc' },
    });
  }

  async updateStatus(
    id: string,
    status: CommentStatus,
    currentUser: UserEntity,
  ): Promise<Comment> {
    // 只有管理员和版主可以更新评论状态
    if (!['ADMIN', 'MODERATOR'].includes(currentUser.role)) {
      throw new ForbiddenException('无权更新评论状态');
    }

    const comment = await this.prisma.comment.findUnique({
      where: { id },
    });

    if (!comment) {
      throw new NotFoundException('评论不存在');
    }

    return this.prisma.comment.update({
      where: { id },
      data: { status },
    });
  }

  async remove(id: string, currentUser: UserEntity): Promise<void> {
    const comment = await this.prisma.comment.findUnique({
      where: { id },
    });

    if (!comment) {
      throw new NotFoundException('评论不存在');
    }

    // 只有评论作者或管理员可以删除评论
    if (
      comment.authorId !== currentUser.id &&
      currentUser.role !== UserRole.ADMIN
    ) {
      throw new ForbiddenException('无权删除此评论');
    }

    await this.prisma.comment.delete({
      where: { id },
    });
  }
}

🔧 配置模块

1. 文章模块

typescript
// src/modules/articles/articles.module.ts
import { Module } from '@nestjs/common';
import { ArticlesService } from './articles.service';
import { ArticlesController } from './articles.controller';

@Module({
  controllers: [ArticlesController],
  providers: [ArticlesService],
  exports: [ArticlesService],
})
export class ArticlesModule {}

2. 分类模块

typescript
// src/modules/categories/categories.module.ts
import { Module } from '@nestjs/common';
import { CategoriesService } from './categories.service';
import { CategoriesController } from './categories.controller';

@Module({
  controllers: [CategoriesController],
  providers: [CategoriesService],
  exports: [CategoriesService],
})
export class CategoriesModule {}

3. 更新主模块

typescript
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaModule } from './prisma/prisma.module';
import { UsersModule } from './modules/users/users.module';
import { AuthModule } from './modules/auth/auth.module';
import { ArticlesModule } from './modules/articles/articles.module';
import { CategoriesModule } from './modules/categories/categories.module';
import { TagsModule } from './modules/tags/tags.module';
import { CommentsModule } from './modules/comments/comments.module';
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
import configuration from './config/configuration';

@Module({
  imports: [
    ConfigModule.forRoot({
      load: [configuration],
      isGlobal: true,
      envFilePath: '.env',
    }),
    PrismaModule,
    UsersModule,
    AuthModule,
    ArticlesModule,
    CategoriesModule,
    TagsModule,
    CommentsModule,
  ],
  controllers: [AppController],
  providers: [
    AppService,
    {
      provide: APP_GUARD,
      useClass: JwtAuthGuard,
    },
  ],
})
export class AppModule {}

✅ 测试文章管理功能

1. 创建分类

bash
curl -X POST http://localhost:3000/categories \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
  -d '{
    "name": "技术",
    "description": "技术相关文章",
    "color": "#3B82F6"
  }'

2. 创建标签

bash
curl -X POST http://localhost:3000/tags \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "name": "NestJS",
    "description": "NestJS框架相关",
    "color": "#E11D48"
  }'

3. 创建文章

bash
curl -X POST http://localhost:3000/articles \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "title": "NestJS 实战指南",
    "content": "这是一篇关于 NestJS 的详细教程...",
    "excerpt": "学习如何使用 NestJS 构建企业级应用",
    "categoryId": "CATEGORY_ID",
    "tagIds": ["TAG_ID_1", "TAG_ID_2"],
    "status": "PUBLISHED"
  }'

4. 获取文章列表

bash
curl -X GET "http://localhost:3000/articles?page=1&limit=10&status=PUBLISHED"

5. 添加评论

bash
curl -X POST http://localhost:3000/comments \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "content": "这篇文章写得很好!",
    "articleId": "ARTICLE_ID"
  }'

🎉 小结

在本章中,我们完成了:

  • ✅ 创建了完整的文章管理系统
  • ✅ 实现了文章的 CRUD 操作
  • ✅ 创建了分类和标签管理
  • ✅ 实现了文章搜索和分页功能
  • ✅ 创建了评论系统
  • ✅ 实现了权限控制和状态管理

在下一章中,我们将创建公共组件与工具,包括拦截器、管道、过滤器等通用组件。

Released under the MIT License.