Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

如何基于 Nest.js 设计一个评论系统 #25

Open
vortesnail opened this issue Nov 25, 2024 · 0 comments
Open

如何基于 Nest.js 设计一个评论系统 #25

vortesnail opened this issue Nov 25, 2024 · 0 comments
Labels

Comments

@vortesnail
Copy link
Owner

为了方便自己和广大前端开发者深入学习 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[]
}

数据表:

字段名 数据类型 描述
id INT 自增 id
title VARCHAR(255) 题目标题
content TEXT 题目内容

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[]
}

数据表:

字段名 数据类型 描述
id INT 自增 id
userName VARCHAR(255) 用户名
avatar VARCHAR(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[]
}

数据表:

字段名 数据类型 描述
id INT 自增 id
userId INT 关联用户 id
problemId INT 关联题目 id
content TEXT 评论内容
sortGrade TEXT 评论所得分数,根据(评论数 * 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
}

数据表:

字段名 数据类型 描述
id INT 自增 id
userId INT 关联用户 id
problemId INT 关联题目 id
commentId INT 关联评论 id
status TINYINT(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[]
}

数据表:

字段名 数据类型 描述
id INT 自增 id
userId INT 关联用户 id
problemId INT 关联题目 id
commentId INT 关联评论 id
targetUserId INT 关联目标用户 id
content TEXT 回复内容

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
}

数据表:

字段名 数据类型 描述
id INT 自增 id
userId INT 关联用户 id
problemId INT 关联题目 id
commentId INT 关联评论 id
replyId INT 关联回复 id
status TINYINT(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~

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant