为了方便自己和广大前端开发者深入学习 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. 实体关系
Problem和Comment是一对多关系(一个题目可以有多个评论)。Comment和Reply是一对多关系(一个评论可以有多个回复)。Comment和CommentLike是一对多关系(一个评论可以有多个点赞)。Reply和ReplyLike是一对多关系(一个回复可以有多个点赞)。User和Comment是一对多关系(一个用户可以有多个评论)。User和CommentLike是一对多关系(一个用户可以有多个评论点赞)。User和ReplyLike是一对多关系(一个用户可以有多个回复点赞)。
数据库表设计
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
这个文件包含了 Comments 和 CommentLikes 两个实体的操作,没必要把他们分成两个文件。
@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
同样,这里也是包含了 CommentReplys 和 CommentReplyLikes 两个实体的的操作。
@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~