We read every piece of feedback, and take your input very seriously.
To see all available qualifiers, see our documentation.
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
为了方便自己和广大前端开发者深入学习 TypeScript,我独立开发了一个小站 类型小屋 TypeRoom 用于方便地刷类型体操题目(题目目前基于 antfu 大佬的 type-challenges 这个开源项目)。
我的愿景是为每一道题目进行翻译润色,并且都有非常清晰、易理解的官方题解,目前是完成了所有题目的手动搬运、翻译润色,但是官方题解只写了 19 题,而所有题目数量目前是 182 题 😭,而我一个人的力量非常有限,一边想多写点网站的功能,一边又要准备找工作,所以写题解这件事情就变的很棘手。
那我就想如果先开放题目的评论功能,是否会有好心的开发者能发表一些自己的见解,从而帮助到其他人呢?于是我就开始着手于设计和编写一个评论系统。
除了上面说的好处,还考虑到以下几点:
一般评论会有两种展示结构,分别为线形结构和树形结构。
一般来说对于评论较少的页面使用线形结构会好些,能充分利用页面空间,并且用户也能一次性看完所有评论,但是一旦评论多起来,对于评论的回复的阅读性是不可接受的,试想一下,目前有 48 条评论,你回复了第 1 条评论,结果 1 楼评论者要翻过 47 条评论才能看到你的最新回复,即使中间那些评论和自己毫无关系。
而树形结构能够有效应付这种评论很多的场景,但是我们也不能无限制地嵌套使用树形结构,不然页面将会非常难看,比如以下这种:
所以可以看到现在大多数 APP、网站都是采用的顶层评论为树形结构,第二层回复为线形结构来展示评论的,它会聚合展示所有相关的回复,这样可以保证布局美观前提下还能看到清晰的对话记录。
比如掘金就是这样:
除了每条评论的文字内容,其他用户还要能够对评论或回复进行点赞,这样可以将优质评论筛选出来。
当然了,也是要支持评论举报功能的,不过这个这在当下可能没有那么急迫,所以先不搞,只要一条路走通了,其他的也就是时间问题而已。
确定了要做的功能之后,我们首先就是要进行数据库的表设计,我这里的技术栈是 Nest.js + TypeORM ,如果有不同技术栈的,参考下设计思路就行。
problems
users
comments
comment_likes
replies
replie_likes
这里可以看到,我们将第一层评论和在第二层的回复是作为不同数据进行存储的,而不是把评论和对评论的回复都当作同一种数据。这样数据就很清晰,SQL 的操作和代码处理起来也都很方便,而且他们本来就是不同的东西,没必要为了少建两个表搞得自己后面维护那么难受。
Problem
Comment
Reply
CommentLike
ReplyLike
User
实体:
@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[] }
数据表:
@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[] }
@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[] }
@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 }
@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[] }
@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 }
在我们把表建好之后,开始写 SQL 相关的编码逻辑,以下是我简化之后的 service 层的代码,大家可以参考下。
service
comment.service.ts
这个文件包含了 Comments 和 CommentLikes 两个实体的操作,没必要把他们分成两个文件。
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 两个实体的的操作。
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~
The text was updated successfully, but these errors were encountered:
No branches or pull requests
为了方便自己和广大前端开发者深入学习 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
表实体:
数据表:
User
表实体:
数据表:
Comment
表实体:
数据表:
CommentLike
表实体:
数据表:
Reply
表实体:
数据表:
ReplyLike
表实体:
数据表:
服务层
在我们把表建好之后,开始写 SQL 相关的编码逻辑,以下是我简化之后的
service
层的代码,大家可以参考下。comment.service.ts
这个文件包含了
Comments
和CommentLikes
两个实体的操作,没必要把他们分成两个文件。comment-reply.service.ts
同样,这里也是包含了
CommentReplys
和CommentReplyLikes
两个实体的的操作。前端布局
服务接口写完之后,前端又是怎么写布局的呢?以下是 typeroom 的实际成品图。
可以看到,现有的服务接口设计支持对评论点赞和回复,对评论的回复点赞和回复,当回复过多,我们可以规定没跳评论下面第一次请求最多显示 10 条评论,还要查看更多可在回复的最下面,添加一个 “展示更多回复” 的按钮,接着请求另外 10 条,一次类推。
这个结构我觉得还是比较清晰的,后续也可以继续扩展其他功能,比如评论和回复的举报功能,同样需要我们新增服务接口。
最后
事实上,在用户量很大的 APP 或网站中,评论功能是需要接入内容审核系统和验证码服务的,一是你无法控制用户评论的内容是否符合国家的法律允许框架,人工审核成本是很大的,需要接入第三方服务来做这部分审核工作;二是有些想搞你的人,会利用评论接口批量回复大量垃圾内容,这里服务端可以做限制,但其实对于诚心想搞你的人来说,没啥用,所以还是得接入行为式验证码这种服务,防止脚本自动刷评论。
总之,实际使用场景中还是有很多需要考虑的东西的,希望这篇文章能给有相关需求的前端(全栈)小伙伴一点设计思路,如果对你有帮助,不要吝啬你的 star🌟 哦,这是我的 github/blog ,希望交个朋友的也可以加我 v~
The text was updated successfully, but these errors were encountered: