本文记录我在博客项目中实现评论功能时,涉及的一些细节。


一、后端实现

1. 评论实体设计(Comment)

评论表的核心在于自引用关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Entity
public class Comment {
@Id
private Long id;

private String content; // VARCHAR(1000)

@ManyToOne(fetch = FetchType.EAGER)
private Article article; // 所属文章

@ManyToOne(fetch = FetchType.EAGER)
private User author; // 评论作者

@ManyToOne(fetch = FetchType.LAZY)
private Comment parentComment; // 父评论(自引用关系)

@CreationTimestamp
private LocalDateTime createdAt;
}

设计要点

  • parentComment 指向自身,实现评论嵌套
  • 父评论使用 LAZY,避免循环加载
  • 作者、文章使用 EAGER,避免访问时频繁触发延迟加载
  • 不在实体中直接维护 children,避免 ORM 复杂度失控

高并发场景应使用join fetch 或DTO投影来精确控制查询行为。


2. 数据访问层(Repository)

1
2
3
4
5
6
7
8
9
10
public interface CommentRepository extends JpaRepository<Comment, Long> {
// 按文章ID查询,升序排列(老评论在前)
Page<Comment> findByArticleIdOrderByCreatedAtAsc(Long articleId, Pageable pageable);

// 按作者ID查询(用于个人主页)
Page<Comment> findByAuthorId(Long authorId, Pageable pageable);

// 查找子评论(用于级联删除)
List<Comment> findByParentCommentId(Long parentId);
}

👉 递归删除时依赖 findByParentCommentId 查找子节点

评论属于高频读写表,后续应在数据库层添加必要索引。


3. 评论创建逻辑(Service)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public CommentDTO createComment(Long articleId, Long userId, String content, Long parentId) {
// 验证文章和用户存在
Article article = articleRepository.findById(articleId)...
User author = userRepository.findById(userId)...

// 验证内容非空
if (content == null || content.trim().isEmpty()) {
throw new RuntimeException("评论内容不能为空");
}

// 如果是回复,验证父评论
if (parentId != null) {
Comment parent = commentRepository.findById(parentId)...
// 确保父评论属于同一篇文章
if (!parent.getArticle().getId().equals(articleId)) {
throw new RuntimeException("父评论与文章不匹配");
}
comment.setParentComment(parent);// 设置parentcomment
}

// 保存并转换为DTO
comment = commentRepository.save(comment);
return convertToDTO(comment, userId);
}

核心约束点

  • 回复的父评论必须属于同一篇文章
  • 所有评论统一通过 DTO 返回,避免实体泄露
  • 创建逻辑不做层级限制,展示逻辑交给前端

4. 递归级联删除

删除一条评论时,必须同时删除:

  1. 所有子评论(递归)
  2. 所有关联的点赞记录
  3. 保证外键约束不报错
  4. 保证操作原子性

实现方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Transactional
public void deleteComment(Long commentId, Long userId) {
Comment comment = commentRepository.findById(commentId)...

// 权限检查:只能删除自己的评论
if (!comment.getAuthor().getId().equals(userId)) {
throw new RuntimeException("只能删除自己的评论");
}

deleteCommentWithChildren(comment);
}

// 递归删除子评论
private void deleteCommentWithChildren(Comment comment) {
// 递归删除所有子评论
List<Comment> children = commentRepository.findByParentCommentId(comment.getId());
for (Comment child : children) {
deleteCommentWithChildren(child); // 递归调用
}

// 先删除该评论的所有点赞(解决FK约束)
commentLikeRepository.deleteByCommentId(comment.getId());

// 最后删除评论本身
commentRepository.deleteById(comment.getId());
}

设计要点

  • 深度优先遍历(DFS)
  • 时间复杂度 O(n)
  • 使用事务保证一致性
  • 显式控制删除顺序,避免外键(FK)约束问题

为避免复杂递归以及保留上下文,可考虑采用逻辑删除private boolean deleted;


二、前端实现

1. 数据结构设计

前端接收的是平铺数组,自行构建树结构:

1
2
3
4
5
6
7
8
9
data() {
return {
comments: [], // 平铺的评论列表(从API获取)
commentTree: [], // 树型结构(用于渲染)
newCommentContent: '', // 新评论内容
replyingTo: null, // 当前回复的评论对象
replyContent: '' // 回复内容
}
}

选择前端构建树结构而不是后端直接递归组装,是为了保持接口简单、避免后端递归查询带来的复杂度,同时让前端掌控展示策略。


2. 评论树构建与展平

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
buildCommentTree(comments) {
const map = new Map();
const roots = [];

// 1. 创建映射表,每个评论作为一个节点
comments.forEach(comment => {
map.set(comment.id, { ...comment, children: [] });
});

// 2. 构建父子关系
comments.forEach(comment => {
const node = map.get(comment.id);

// 顶级评论(没有父评论)
if (!comment.parentId) {
roots.push(node);
return;
}

// 找到最顶层的父评论(展平多层嵌套)
let parent = map.get(comment.parentId);
while (parent && parent.parentId) {
parent = map.get(parent.parentId);
}

// 将当前评论添加到顶层父评论的children中
if (parent) {
parent.children.push(node);
} else {
roots.push(node); // 孤儿节点作为顶级评论
}
});

return roots;
}

设计要点

  • 支持任意层级回复
  • UI 只展示两层
  • 不需要后端额外接口
  • 构建复杂度 O(n)

前端只展示两层结构,避免前端无限递归渲染