小小前端仔是如何基于 Nest 设计一个评论系统的?

1,456 阅读10分钟

为了方便自己和广大前端开发者深入学习 TypeScript,我独立开发了一个小站 类型小屋 TypeRoom 用于方便地刷类型体操题目(题目目前基于 antfu 大佬的 type-challenges 这个开源项目)。

我的愿景是为每一道题目进行翻译润色,并且都有非常清晰、易理解的官方题解,目前是完成了所有题目的手动搬运、翻译润色,但是官方题解只写了 19 题,而所有题目数量目前是 182 题 😭,而我一个人的力量非常有限,一边想多写点网站的功能,一边又要准备找工作,所以写题解这件事情就变的很棘手。

那我就想如果先开放题目的评论功能,是否会有好心的开发者能发表一些自己的见解,从而帮助到其他人呢?于是我就开始着手于设计和编写一个评论系统。

为什么要做评论功能?

除了上面说的好处,还考虑到以下几点:

  • 评论功能有一定的社交属性,这在一定程度上能增加用户粘度,就好像大多数人更喜欢网游,而不是单机游戏一样。
  • 后续的个人题解、官方题解甚至可能会新开的交流模块都要支持评论,先把这件事做起来对网站本身之后发展是有利的。
  • 用户有权被支持发表各种不同的意见和观点。

评论的展现形式

一般评论会有两种展示结构,分别为线形结构树形结构

一般来说对于评论较少的页面使用线形结构会好些,能充分利用页面空间,并且用户也能一次性看完所有评论,但是一旦评论多起来,对于评论的回复的阅读性是不可接受的,试想一下,目前有 48 条评论,你回复了第 1 条评论,结果 1 楼评论者要翻过 47 条评论才能看到你的最新回复,即使中间那些评论和自己毫无关系。

而树形结构能够有效应付这种评论很多的场景,但是我们也不能无限制地嵌套使用树形结构,不然页面将会非常难看,比如以下这种:

所以可以看到现在大多数 APP、网站都是采用的顶层评论为树形结构,第二层回复为线形结构来展示评论的,它会聚合展示所有相关的回复,这样可以保证布局美观前提下还能看到清晰的对话记录。

比如掘金就是这样:

评论的关联功能

除了每条评论的文字内容,其他用户还要能够对评论或回复进行点赞,这样可以将优质评论筛选出来。

当然了,也是要支持评论举报功能的,不过这个这在当下可能没有那么急迫,所以先不搞,只要一条路走通了,其他的也就是时间问题而已。

数据库设计

确定了要做的功能之后,我们首先就是要进行数据库的表设计,我这里的技术栈是 Nest.js + TypeORM ,如果有不同技术栈的,参考下设计思路就行。

设计思路

1. 功能需求

  • 用户可以在题目下发表评论。
  • 用户可以对评论或评论下的回复进行点赞。
  • 用户可以回复其他用户的评论或评论下的回复。
  • 返回的数据需要包括评论内容、用户信息(头像、昵称等)。

2. 数据表设计

  • problems :题目表,存储题目数据。
  • users :用户表,存储用户数据。
  • comments :评论表,存储用户在题目下的评论数据。
  • comment_likes :评论点赞表,存储用户对评论的点赞数据。
  • replies :回复表,存储评论下的回复的数据。
  • replie_likes :回复点赞表,存储用户对评论的回复的点赞数据。

这里可以看到,我们将第一层评论和在第二层的回复是作为不同数据进行存储的,而不是把评论和对评论的回复都当作同一种数据。这样数据就很清晰,SQL 的操作和代码处理起来也都很方便,而且他们本来就是不同的东西,没必要为了少建两个表搞得自己后面维护那么难受。

3. 实体关系

  • ProblemComment 是一对多关系(一个题目可以有多个评论)。
  • CommentReply 是一对多关系(一个评论可以有多个回复)。
  • CommentCommentLike 是一对多关系(一个评论可以有多个点赞)。
  • ReplyReplyLike 是一对多关系(一个回复可以有多个点赞)。
  • UserComment 是一对多关系(一个用户可以有多个评论)。
  • UserCommentLike 是一对多关系(一个用户可以有多个评论点赞)。
  • UserReplyLike 是一对多关系(一个用户可以有多个回复点赞)。

数据库表设计

Problem

实体:

@Entity()
export class Problems {
  @PrimaryGeneratedColumn()
  id: number

  @Column({ type: 'varchar', comment: '题目标题', length: 255 })
  title: string

  @Column({ type: 'text', comment: '题目内容' })
  content: string

  @OneToMany(() => Comments, (comments) => comments.problem)
  comments: Comments[]

  @OneToMany(() => CommentReplys, (commentReplys) => commentReplys.problem)
  commentReplys: CommentReplys[]

  @OneToMany(() => CommentLikes, (commentLikes) => commentLikes.problem)
  commentLikes: CommentLikes[]

  @OneToMany(() => CommentReplyLikes, (commentReplyLikes) => commentReplyLikes.problem)
  commentReplyLikes: CommentReplyLikes[]
}

数据表:

字段名数据类型描述
idINT自增 id
titleVARCHAR(255)题目标题
contentTEXT题目内容

User

实体:

@Entity()
export class Users {
  @PrimaryGeneratedColumn()
  id: number

  @Column({ type: 'varchar', comment: '用户名,不唯一' })
  userName: string

  @Column({ type: 'varchar', nullable: true, comment: '头像图片地址' })
  avatar: string | null

  @OneToMany(() => Comments, (comments) => comments.user)
  comments: Comments[]

  @OneToMany(() => CommentReplys, (commentReplys) => commentReplys.user)
  commentReplys: CommentReplys[]

  @OneToMany(() => CommentLikes, (commentLikes) => commentLikes.user)
  commentLikes: CommentLikes[]

  @OneToMany(() => CommentReplyLikes, (commentReplyLikes) => commentReplyLikes.user)
  commentReplyLikes: CommentReplyLikes[]
}

数据表:

字段名数据类型描述
idINT自增 id
userNameVARCHAR(255)用户名
avatarVARCHAR(255)用户头像

Comment

实体:

@Entity()
export class Comments {
  @PrimaryGeneratedColumn()
  id: number

  @Column({ type: 'int', comment: '用户 ID' })
  userId: number

  @Column({ type: 'int', comment: '题目 ID' })
  problemId: number

  @Column({ type: 'text', comment: '评论内容' })
  content: string

  @Column({ type: 'int', default: 0, comment: '评论分数,根据(评论数 * 2 + 点赞数 * 1)计算所得' })
  sortGrade: number

  @ManyToOne(() => Users, (users) => users.comments)
  @JoinColumn([{ name: 'userId', referencedColumnName: 'id' }])
  user: Users

  @ManyToOne(() => Problems, (problems) => problems.comments)
  @JoinColumn([{ name: 'problemId', referencedColumnName: 'id' }])
  problem: Problems

  @OneToMany(() => CommentReplys, (commentReplys) => commentReplys.comment)
  commentReplys: CommentReplys[]

  @OneToMany(() => CommentLikes, (commentLikes) => commentLikes.comment)
  commentLikes: CommentLikes[]

  @OneToMany(() => CommentReplyLikes, (commentReplyLikes) => commentReplyLikes.comment)
  commentReplyLikes: CommentReplyLikes[]
}

数据表:

字段名数据类型描述
idINT自增 id
userIdINT关联用户 id
problemIdINT关联题目 id
contentTEXT评论内容
sortGradeTEXT评论所得分数,根据(评论数 * 2 + 点赞数 * 1)计算所得,用户“最热”搜索

CommentLike

实体:

@Entity()
export class CommentLikes {
  @PrimaryGeneratedColumn()
  id: number

  @Column({ type: 'int', comment: '用户 ID' })
  userId: number

  @Column({ type: 'int', comment: '题目 ID' })
  problemId: number

  @Column({ type: 'int', comment: '评论 ID' })
  commentId: number

  @Column({ type: 'tinyint', width: 1, comment: '状态, 1: 已点赞; 2: 未点赞' })
  status: number

  @ManyToOne(() => Users, (users) => users.commentLikes)
  @JoinColumn([{ name: 'userId', referencedColumnName: 'id' }])
  user: Users

  @ManyToOne(() => Problems, (problems) => problems.commentLikes)
  @JoinColumn([{ name: 'problemId', referencedColumnName: 'id' }])
  problem: Problems

  @ManyToOne(() => Comments, (comments) => comments.commentLikes)
  @JoinColumn([{ name: 'commentId', referencedColumnName: 'id' }])
  comment: Comments
}

数据表:

字段名数据类型描述
idINT自增 id
userIdINT关联用户 id
problemIdINT关联题目 id
commentIdINT关联评论 id
statusTINYINT(1)状态, 1: 已点赞; 2: 未点赞

Reply

实体:

@Entity()
export class CommentReplys {
  @PrimaryGeneratedColumn()
  id: number

  @Column({ type: 'int', comment: '用户 ID' })
  userId: number

  @Column({ type: 'int', comment: '题目 ID' })
  problemId: number

  @Column({ type: 'int', comment: '题目评论 ID' })
  commentId: number

  @Column({ type: 'int', nullable: true, comment: '回复的目标用户 ID' })
  targetUserId: number

  @Column({ type: 'text', comment: '评论内容' })
  content: string

  @ManyToOne(() => Users, (users) => users.commentReplys)
  @JoinColumn([{ name: 'userId', referencedColumnName: 'id' }])
  user: Users

  @ManyToOne(() => Users, (users) => users.commentReplys)
  @JoinColumn([{ name: 'targetUserId', referencedColumnName: 'id' }])
  targetUser: Users

  @ManyToOne(() => Problems, (problems) => problems.commentReplys)
  @JoinColumn([{ name: 'problemId', referencedColumnName: 'id' }])
  problem: Problems

  @ManyToOne(() => Comments, (comments) => comments.commentReplys)
  @JoinColumn([{ name: 'commentId', referencedColumnName: 'id' }])
  comment: Comments

  @OneToMany(() => CommentReplyLikes, (commentReplyLikes) => commentReplyLikes.commentReply)
  commentReplyLikes: CommentReplyLikes[]
}

数据表:

字段名数据类型描述
idINT自增 id
userIdINT关联用户 id
problemIdINT关联题目 id
commentIdINT关联评论 id
targetUserIdINT关联目标用户 id
contentTEXT回复内容

ReplyLike

实体:

@Entity()
export class ProblemCommentReplyLikes {
  @PrimaryGeneratedColumn()
  id: number

  @Column({ type: 'int', comment: '用户 ID' })
  userId: number

  @Column({ type: 'int', comment: '题目 ID' })
  problemId: number

  @Column({ type: 'int', comment: '评论 ID' })
  commentId: number

  @Column({ type: 'int', comment: '回复 ID' })
  replyId: number

  @Column({ type: 'tinyint', width: 1, comment: '状态, 1: 已点赞; 2: 未点赞' })
  status: number

  @ManyToOne(() => Users, (users) => users.commentReplyLikes)
  @JoinColumn([{ name: 'userId', referencedColumnName: 'id' }])
  user: Users

  @ManyToOne(() => Problems, (problems) => problems.commentReplyLikes)
  @JoinColumn([{ name: 'problemId', referencedColumnName: 'id' }])
  problem: Problems

  @ManyToOne(() => Comments, (comments) => comments.commentReplyLikes)
  @JoinColumn([{ name: 'commentId', referencedColumnName: 'id' }])
  comment: Comments

  @ManyToOne(() => CommentReplys, (commentReplys) => commentReplys.commentReplyLikes)
  @JoinColumn([{ name: 'commentReplyId', referencedColumnName: 'id' }])
  commentReply: CommentReplys
}

数据表:

字段名数据类型描述
idINT自增 id
userIdINT关联用户 id
problemIdINT关联题目 id
commentIdINT关联评论 id
replyIdINT关联回复 id
statusTINYINT(1)状态, 1: 已点赞; 2: 未点赞

服务层

在我们把表建好之后,开始写 SQL 相关的编码逻辑,以下是我简化之后的 service 层的代码,大家可以参考下。

comment.service.ts

这个文件包含了 CommentsCommentLikes 两个实体的操作,没必要把他们分成两个文件。

@Injectable()
export class CommentService {
  constructor(
    @InjectRepository(Comments) private readonly commentsRepository: Repository<Comments>,
    @InjectRepository(CommentLikes) private readonly commentLikesRepository: Repository<CommentLikes>,
  ) {}

  // 创建评论数据
  async createComment(createCommentByUserIdDto: CreateCommentByUserIdDto) {
    const problemComment = this.commentsRepository.create(createCommentByUserIdDto)
    const res = await this.commentsRepository.save(problemComment)

    return res ? res : null
  }

  // 更新评论的 sortGrade
  async updateSortGrade() {
    const comments = await this.commentsRepository.find({
      where: {},
      relations: ['commentReplys', 'commentLikes'],
    })

    // 遍历每个评论,计算 sortGrade
    const updatePromises = comments.map(async (comment) => {
      const { commentReplys, commentLikes } = comment
      const replyCount = commentReplys.length
      const likeCount = commentLikes.length
      const sortGrade = replyCount * 2 + likeCount * 1
      // 更新评论的 sortGrade 字段
      await this.commentsRepository.update(comment.id, {
        sortGrade,
      })
    })

    await Promise.all(updatePromises)
  }

  // 查询评论列表
  async queryCommentList(queryCommentListDto: QueryCommentListDto) {
    const { userId, problemId, sortValue, pageNum = 1, pageSize = 10 } = queryCommentListDto
    let orderCondition: FindOptionsOrder<Comments> = {}
    if (sortValue === COMMENT_SORT.HOTEST[0]) {
      orderCondition = { sortGrade: 'DESC' }
    } else if (sortValue === COMMENT_SORT.NEWEST[0]) {
      orderCondition = { createdAt: 'DESC' }
    } else if (sortValue === COMMENT_SORT.OLDEST[0]) {
      orderCondition = { createdAt: 'ASC' }
    }
    const res = await this.commentsRepository.findAndCount({
      select: {
        id: true,
        userId: true,
        problemId: true,
        content: true,
        sortGrade: true,
        user: {
          id: true,
          avatar: true,
          userName: true,
        },
        commentReplys: {
          id: true,
        },
        commentLikes: true,
      },
      skip: (pageNum - 1) * pageSize,
      take: pageSize,
      where: {
        problemId,
      },
      order: orderCondition,
      relations: ['user', 'commentReplys', 'commentLikes'],
    })

    const [datas, total] = res || []

    const list = (datas || []).map((item) => {
      const { content, commentReplys, commentLikes, createdAt, updatedAt, ...rest } = item
      const contentHtmlStr = `<div class="md-extra-class md-extra-thin-class">${md.render(content)}</div>`

      const status = !!commentLikes?.find(
        (like) => like.userId === userId && like.problemId === problemId && like.status === YES_OR_NO.YES[0],
      )
        ? YES_OR_NO.YES[0]
        : YES_OR_NO.NO[0]

      return {
        ...rest,
        contentHtmlStr,
        replysTotal: (commentReplys || []).length,
        likesTotal: (commentLikes || []).filter((l) => l.status === YES_OR_NO.YES[0]).length,
        meInfo: {
          liked: status,
        },
      }
    })

    return {
      count: total,
      rows: list,
    }
  }

  // 更新点赞状态
  async updateCommentLike(updateCommentLikeByUserIdDto: UpdateCommentLikeByUserIdDto) {
    const { userId, problemId, commentId, status } = updateCommentLikeByUserIdDto
    const like = await this.commentLikesRepository.findOne({
      where: {
        userId,
        problemId,
        commentId,
      },
    })

    // 若已存在点赞记录,更新状态即可
    if (like) {
      const res = await this.commentLikesRepository.save({
        ...like,
        status,
      })
      return res || null
    }

    const commentLike = this.commentLikesRepository.create(updateCommentLikeByUserIdDto)
    const res = await this.commentLikesRepository.save(commentLike)

    return res ? res : null
  }
}

comment-reply.service.ts

同样,这里也是包含了 CommentReplysCommentReplyLikes 两个实体的的操作。

@Injectable()
export class CommentReplyService {
  constructor(
    @InjectRepository(CommentReplys) private readonly commentReplysRepository: Repository<CommentReplys>,
    @InjectRepository(CommentReplyLikes) private readonly commentReplyLikesRepository: Repository<CommentReplyLikes>,
  ) {}

  // 创建评论回复数据
  async createCommentReply(createCommentReplyByUserIdDto: CreateProblemCommentReplyByUserIdDto) {
    const commentReply = this.commentReplysRepository.create(createCommentReplyByUserIdDto)
    const res = await this.commentReplysRepository.save(commentReply)

    return res ? res : null
  }

  // 查询评论回复列表
  async queryCommentList(queryCommentReplyListDto: QueryCommentReplyListDto) {
    const { userId, problemId, commentId, pageNum = 1, pageSize = 5 } = queryCommentReplyListDto
    const res = await this.commentReplysRepository.findAndCount({
      select: {
        id: true,
        userId: true,
        problemId: true,
        commentId: true,
        targetUserId: true,
        content: true,
        user: {
          id: true,
          avatar: true,
          userName: true,
        },
        targetUser: {
          id: true,
          avatar: true,
          userName: true,
        },
        commentReplyLikes: true,
      },
      skip: (pageNum - 1) * pageSize,
      take: pageSize,
      where: {
        problemId,
        commentId,
      },
      relations: ['user', 'targetUser', 'commentReplyLikes'],
    })

    const [datas, total] = res || []

    const list = (datas || []).map((item) => {
      const { content, targetUser, commentReplyLikes, ...rest } = item
      const contentWithTargetUser = targetUser ? `[@${targetUser.userName}](/user/${targetUser.id}) ${content}` : content
      const contentHtmlStr = `<div class="md-extra-class md-extra-thin-class">${md.render(contentWithTargetUser)}</div>`

      const status = !!commentReplyLikes?.find(
        (like) =>
          like.userId === userId && like.problemId === problemId && like.commentId === commentId && like.status === YES_OR_NO.YES[0],
      )
        ? YES_OR_NO.YES[0]
        : YES_OR_NO.NO[0]

      return {
        ...rest,
        contentHtmlStr,
        targetUser,
        likesTotal: (commentReplyLikes || []).filter((l) => l.status === YES_OR_NO.YES[0]).length,
        meInfo: {
          liked: status,
        },
      }
    })

    return {
      count: total,
      rows: list,
    }
  }

  async updateCommentReplyLike(updateCommentReplyLikeByUserIdDto: UpdateCommentReplyLikeByUserIdDto) {
    const { userId, problemId, commentId, commentReplyId, status } = updateCommentReplyLikeByUserIdDto
    const like = await this.commentReplyLikesRepository.findOne({
      where: {
        userId,
        problemId,
        commentId,
        commentReplyId,
      },
    })

    // 若已存在点赞记录,更新状态即可
    if (like) {
      const res = await this.commentReplyLikesRepository.save({
        ...like,
        status,
      })
      return res || null
    }

    const commentLike = this.commentReplyLikesRepository.create(updateProblemCommentReplyLikeByUserIdDto)
    const res = await this.commentReplyLikesRepository.save(commentLike)

    return res ? res : null
  }
}

前端布局

服务接口写完之后,前端又是怎么写布局的呢?以下是 typeroom 的实际成品图。

可以看到,现有的服务接口设计支持对评论点赞和回复,对评论的回复点赞和回复,当回复过多,我们可以规定每条评论下面第一次请求最多显示 10 条评论,还要查看更多可在回复的最下面,添加一个 “展示更多回复” 的按钮,接着请求另外 10 条,以此类推。

这个结构我觉得还是比较清晰的,后续也可以继续扩展其他功能,比如评论和回复的举报功能,同样需要我们新增服务接口。

最后

事实上,在用户量很大的 APP 或网站中,评论功能是需要接入内容审核系统和验证码服务的,一是你无法控制用户评论的内容是否符合国家的法律允许框架,人工审核成本是很大的,需要接入第三方服务来做这部分审核工作;二是有些想搞你的人,会利用评论接口批量回复大量垃圾内容,这里服务端可以做限制,但其实对于诚心想搞你的人来说,没啥用,所以还是得接入行为式验证码这种服务,防止脚本自动刷评论。

总之,实际使用场景中还是有很多需要考虑的东西的,希望这篇文章能给有相关需求的前端(全栈)小伙伴一点设计思路,如果对你有帮助,不要吝啬你的 star🌟 哦,这是我的 github/blog ,希望交个朋友的也可以加我 v~