跳到主要内容

Phase 2: 树形结构实现 🌲 ✅

学习目标: 掌握 Materialized Path、递归查询、深度控制、树形数据组装

✅ Phase 2 已于 2025-12-15 完成并验证!


📚 核心知识点

什么是 Materialized Path?

Materialized Path(物化路径)是一种存储树形结构的高效算法,通过记录从根节点到当前节点的完整路径来表示层级关系。

示例

评论ID=1 (根评论)       path: /1/
├─ 评论ID=2 (回复1) path: /1/2/
│ └─ 评论ID=5 (回复2) path: /1/2/5/
└─ 评论ID=3 (回复1) path: /1/3/
└─ 评论ID=4 (回复3) path: /1/3/4/

优势

  • ✅ 查询某条评论的所有子孙:WHERE path LIKE '/1/%'
  • ✅ 查询某条评论的祖先链:解析 path 字段
  • ✅ 无需递归查询,单次SQL即可获取整棵树
  • ✅ 支持任意深度限制

劣势

  • ❌ 移动节点成本较高(需更新所有子孙的 path)
  • ❌ path 字段占用空间(但评论系统不常移动节点)

🗄️ Step 1: 数据库设计升级

1.1 创建 Flyway 迁移脚本

文件路径: blog-application/src/main/resources/db/V1.2.1__add_comment_tree_fields.sql

-- ========================================================
-- 文件名: V1.2.1__add_comment_tree_fields.sql
-- 描述: 为评论表添加树形结构字段(Materialized Path)
-- 作者: liusxml
-- 日期: 2025-12-15
-- 版本: 1.2.1
-- ========================================================

USE blog_db;

-- 添加树形结构字段
ALTER TABLE `cmt_comment`
ADD COLUMN `path` VARCHAR(500) NULL COMMENT '物化路径 (例: /1/2/5/)',
ADD COLUMN `depth` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '评论深度 (0-根评论, 1-一级回复, ...)',
ADD COLUMN `root_id` BIGINT NULL COMMENT '根评论ID (顶级评论时为自身ID)';

-- 创建索引优化树形查询
CREATE INDEX `idx_path` ON `cmt_comment`(`path`);
CREATE INDEX `idx_root_depth` ON `cmt_comment`(`root_id`, `depth`);

-- 更新现有数据(如果有)
UPDATE `cmt_comment`
SET
`path` = CONCAT('/', `id`, '/'),
`depth` = IF(`parent_id` IS NULL, 0, 1),
`root_id` = IF(`parent_id` IS NULL, `id`, `parent_id`)
WHERE `path` IS NULL;

知识点

  • path VARCHAR(500): 假设最大深度5层,每个ID最长19位,/ 分隔符,总长度足够
  • depth TINYINT(1): 0-根评论,1-5 表示回复深度
  • root_id BIGINT: 冗余字段,方便查询某篇文章下的所有根评论

📦 Step 2: 扩展 VO - CommentTreeVO

2.1 创建树形 VO

文件路径: blog-comment-api/src/main/java/com/blog/comment/api/vo/CommentTreeVO.java

package com.blog.comment.api.vo;

import com.blog.comment.api.enums.CommentStatus;
import com.blog.comment.api.enums.CommentTargetType;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

/**
* 评论树形结构 VO
*
* <p>包含子评论列表,用于前端树形展示</p>
*
* @author liusxml
* @since 1.2.1
*/
@Data
@Schema(description = "评论树形结构对象")
public class CommentTreeVO implements Serializable {

@Schema(description = "评论ID")
private Long id;

@Schema(description = "目标类型")
private CommentTargetType targetType;

@Schema(description = "目标ID")
private Long targetId;

@Schema(description = "父评论ID")
private Long parentId;

@Schema(description = "评论内容")
private String content;

@Schema(description = "状态")
private CommentStatus status;

@Schema(description = "点赞数")
private Integer likeCount;

@Schema(description = "回复数")
private Integer replyCount;

@Schema(description = "评论者ID")
private Long createBy;

@Schema(description = "评论时间", example = "2025-12-15 12:00:00")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createTime;

@Schema(description = "更新时间", example = "2025-12-15 12:00:00")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime updateTime;

// ========== 树形结构字段 ==========

@Schema(description = "物化路径", example = "/1/2/5/")
private String path;

@Schema(description = "评论深度 (0-根评论)", example = "2")
private Integer depth;

@Schema(description = "根评论ID")
private Long rootId;

@Schema(description = "子评论列表")
private List<CommentTreeVO> children = new ArrayList<>();

/**
* 判断是否为根评论
*/
public boolean isRoot() {
return depth != null && depth == 0;
}

/**
* 添加子评论
*/
public void addChild(CommentTreeVO child) {
this.children.add(child);
}
}

知识点

  • children 列表:存储直接子评论,前端可递归渲染
  • isRoot() 方法:业务逻辑判断
  • addChild() 方法:方便树形组装

🔧 Step 3: 通用树形工具类 - TreeBuilder

3.1 创建工具类

文件路径: blog-common/src/main/java/com/blog/common/util/TreeBuilder.java

package com.blog.common.util;

import java.util.*;
import java.util.function.BiConsumer;
import java.util.function.Function;

/**
* 通用树形结构构建器
*
* <p>支持任意类型的列表转换为树形结构</p>
*
* @param <T> 节点类型
* @param <ID> ID类型
* @author liusxml
* @since 1.2.1
*/
public class TreeBuilder<T, ID> {

private final Function<T, ID> idGetter;
private final Function<T, ID> parentIdGetter;
private final BiConsumer<T, List<T>> childrenSetter;

/**
* 构造函数
*
* @param idGetter 获取节点ID的函数
* @param parentIdGetter 获取父节点ID的函数
* @param childrenSetter 设置子节点列表的函数
*/
public TreeBuilder(
Function<T, ID> idGetter,
Function<T, ID> parentIdGetter,
BiConsumer<T, List<T>> childrenSetter) {
this.idGetter = idGetter;
this.parentIdGetter = parentIdGetter;
this.childrenSetter = childrenSetter;
}

/**
* 构建树形结构(单根)
*
* @param nodes 扁平列表
* @param rootId 根节点ID
* @return 树形结构(单个根节点)
*/
public T buildTree(List<T> nodes, ID rootId) {
if (nodes == null || nodes.isEmpty()) {
return null;
}

List<T> roots = buildForest(nodes);

return roots.stream()
.filter(node -> Objects.equals(idGetter.apply(node), rootId))
.findFirst()
.orElse(null);
}

/**
* 构建树形结构(森林,多个根节点)
*
* @param nodes 扁平列表
* @return 树形列表(根节点列表)
*/
public List<T> buildForest(List<T> nodes) {
if (nodes == null || nodes.isEmpty()) {
return Collections.emptyList();
}

// 1. 按ID建立索引
Map<ID, T> nodeMap = new HashMap<>(nodes.size());
for (T node : nodes) {
nodeMap.put(idGetter.apply(node), node);
}

// 2. 构建父子关系
List<T> roots = new ArrayList<>();
for (T node : nodes) {
ID parentId = parentIdGetter.apply(node);

if (parentId == null) {
// 根节点
roots.add(node);
} else {
// 子节点,添加到父节点的children
T parent = nodeMap.get(parentId);
if (parent != null) {
List<T> children = getOrCreateChildren(parent);
children.add(node);
} else {
// 父节点不存在,当作根节点处理(数据不一致)
roots.add(node);
}
}
}

return roots;
}

/**
* 获取或创建子节点列表
*/
@SuppressWarnings("unchecked")
private List<T> getOrCreateChildren(T parent) {
// 通过反射获取children字段(简化处理)
try {
var field = parent.getClass().getDeclaredField("children");
field.setAccessible(true);
List<T> children = (List<T>) field.get(parent);
if (children == null) {
children = new ArrayList<>();
childrenSetter.accept(parent, children);
}
return children;
} catch (Exception e) {
List<T> children = new ArrayList<>();
childrenSetter.accept(parent, children);
return children;
}
}
}

使用示例

TreeBuilder<CommentTreeVO, Long> builder = new TreeBuilder<>(
CommentTreeVO::getId, // ID getter
CommentTreeVO::getParentId, // Parent ID getter
CommentTreeVO::setChildren // Children setter
);

List<CommentTreeVO> forest = builder.buildForest(flatList);

🏛️ Step 4: Service 层实现

4.1 更新 Entity

文件路径: blog-comment-service/src/main/java/com/blog/comment/domain/entity/CommentEntity.java

// 在 CommentEntity 中添加以下字段:

private String path;

private Integer depth;

private Long rootId;

4.2 更新 Converter

MapStruct 会自动处理新字段的映射,无需修改。

4.3 扩展 Service 接口

文件路径: blog-comment-service/src/main/java/com/blog/comment/service/ICommentService.java

/**
* 获取评论树(某篇文章下的所有评论)
*
* @param targetType 目标类型
* @param targetId 目标ID
* @return 评论树列表
*/
List<CommentTreeVO> getCommentTree(CommentTargetType targetType, Long targetId);

/**
* 回复评论
*
* @param dto 评论DTO
* @return 评论ID
*/
Long replyComment(CommentDTO dto);

4.4 实现 Service

文件路径: blog-comment-service/src/main/java/com/blog/comment/service/impl/CommentServiceImpl.java

package com.blog.comment.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.blog.comment.api.dto.CommentDTO;
import com.blog.comment.api.enums.CommentStatus;
import com.blog.comment.api.enums.CommentTargetType;
import com.blog.comment.api.vo.CommentTreeVO;
import com.blog.comment.api.vo.CommentVO;
import com.blog.comment.domain.entity.CommentEntity;
import com.blog.comment.infrastructure.converter.CommentConverter;
import com.blog.comment.infrastructure.mapper.CommentMapper;
import com.blog.comment.service.ICommentService;
import com.blog.common.base.BaseServiceImpl;
import com.blog.common.exception.BusinessException;
import com.blog.common.exception.SystemErrorCode;
import com.blog.common.utils.TreeBuilder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

/**
* 评论服务实现(Phase 2 扩展)
*/
@Slf4j
@Service
public class CommentServiceImpl
extends BaseServiceImpl<CommentMapper, CommentEntity, CommentVO, CommentDTO, CommentConverter>
implements ICommentService {

/**
* 最大嵌套深度
*/
private static final int MAX_DEPTH = 5;

private final TreeBuilder<CommentTreeVO, Long> treeBuilder;

public CommentServiceImpl(CommentConverter converter) {
super(converter);
this.treeBuilder = new TreeBuilder<>(
CommentTreeVO::getId,
CommentTreeVO::getParentId,
CommentTreeVO::setChildren
);
}

@Override
protected void preSave(CommentEntity entity) {
// 设置默认状态
if (Objects.isNull(entity.getStatus())) {
entity.setStatus(CommentStatus.APPROVED);
}

// 初始化统计字段
if (Objects.isNull(entity.getLikeCount())) {
entity.setLikeCount(0);
}
if (Objects.isNull(entity.getReplyCount())) {
entity.setReplyCount(0);
}

// 计算树形字段
if (Objects.isNull(entity.getParentId())) {
// 根评论
entity.setDepth(0);
entity.setRootId(null); // 待保存后更新为自身ID
} else {
// 回复评论,从父评论继承路径信息
CommentEntity parent = getById(entity.getParentId());
if (parent == null) {
throw new BusinessException(SystemErrorCode.NOT_FOUND, "父评论不存在");
}

// 深度限制
if (parent.getDepth() >= MAX_DEPTH) {
throw new BusinessException(SystemErrorCode.PARAM_ERROR,
"评论嵌套深度超过限制(最大" + MAX_DEPTH + "层)");
}

entity.setDepth(parent.getDepth() + 1);
entity.setRootId(parent.getRootId() != null ? parent.getRootId() : parent.getId());
}

log.debug("评论保存前处理: depth={}, rootId={}", entity.getDepth(), entity.getRootId());
}

@Override
@Transactional(rollback = true)
public Long replyComment(CommentDTO dto) {
// 校验父评论存在
if (dto.getParentId() == null) {
throw new BusinessException(SystemErrorCode.PARAM_ERROR, "回复评论时必须指定父评论ID");
}

// 保存评论(preSave会处理树形字段)
Long commentId = (Long) saveByDto(dto);

// 更新path字段(需要用到ID)
CommentEntity entity = getById(commentId);
CommentEntity parent = getById(dto.getParentId());

String newPath = (parent.getPath() != null ? parent.getPath() : "/" + parent.getId() + "/")
+ commentId + "/";
entity.setPath(newPath);

// 如果是根评论,rootId设为自身
if (entity.getRootId() == null) {
entity.setRootId(commentId);
}

updateById(entity);

// 更新父评论的reply_count
parent.setReplyCount(parent.getReplyCount() + 1);
updateById(parent);

return commentId;
}

@Override
public List<CommentTreeVO> getCommentTree(CommentTargetType targetType, Long targetId) {
// 查询所有评论(扁平列表)
List<CommentEntity> entities = list(new LambdaQueryWrapper<CommentEntity>()
.eq(CommentEntity::getTargetType, targetType)
.eq(CommentEntity::getTargetId, targetId)
.eq(CommentEntity::getStatus, CommentStatus.APPROVED)
.orderByAsc(CommentEntity::getCreateTime));

// 转换为 TreeVO
List<CommentTreeVO> flatList = entities.stream()
.map(this::entityToTreeVO)
.collect(Collectors.toList());

// 组装树形结构
return treeBuilder.buildForest(flatList);
}

/**
* Entity 转 TreeVO
*/
private CommentTreeVO entityToTreeVO(CommentEntity entity) {
CommentTreeVO vo = new CommentTreeVO();
vo.setId(entity.getId());
vo.setTargetType(entity.getTargetType());
vo.setTargetId(entity.getTargetId());
vo.setParentId(entity.getParentId());
vo.setContent(entity.getContent());
vo.setStatus(entity.getStatus());
vo.setLikeCount(entity.getLikeCount());
vo.setReplyCount(entity.getReplyCount());
vo.setCreateBy(entity.getCreateBy());
vo.setCreateTime(entity.getCreateTime());
vo.setUpdateTime(entity.getUpdateTime());
vo.setPath(entity.getPath());
vo.setDepth(entity.getDepth());
vo.setRootId(entity.getRootId());
return vo;
}
}

🎮 Step 5: Controller 层扩展

/**
* 获取评论树
*
* @param targetType 目标类型
* @param targetId 目标ID
* @return 评论树
*/
@GetMapping("/tree")
@Operation(summary = "获取评论树")
public Result<List<CommentTreeVO>> getTree(
@RequestParam CommentTargetType targetType,
@RequestParam Long targetId) {
return Result.success(commentService.getCommentTree(targetType, targetId));
}

/**
* 回复评论
*
* @param dto 评论DTO
* @return 评论ID
*/
@PostMapping("/reply")
@Operation(summary = "回复评论")
public Result<Long> reply(@Valid @RequestBody CommentDTO dto) {
return Result.success(commentService.replyComment(dto));
}

✅ Phase 2 完成检查清单

  • Flyway 脚本创建成功(已整合为 V1.2.0)
  • 数据库字段和索引创建成功
  • CommentTreeVO 创建完成
  • TreeBuilder 工具类可用
  • Entity 添加新字段
  • Service 实现 replyComment 和 getCommentTree
  • Controller 添加新接口
  • 测试回复功能(嵌套6层)✅
  • 测试深度限制(第6层已生效)✅
  • 测试树形查询(正确组装)✅
  • 根评论 path 和 rootId 自动填充 ✅
  • 错误提示优化(清晰的业务错误消息)✅
  • 错误码修正(参数错误返回 400)✅

🎉 Phase 2 已于 2025-12-15 完成!


💡 关键知识点总结

知识点核心概念实际作用
Materialized Path物化路径算法单次SQL查询整棵树
depth 字段深度标识限制嵌套层级
root_id 字段根节点冗余快速查询同一根的所有评论
TreeBuilder通用树构建器扁平列表→树形结构
preSave 钩子自动计算树字段简化业务代码
saveByDto 重写后置处理处理需要ID的字段更新
@Transactional事务控制确保数据一致性

🎯 Phase 2 完成总结

✅ 已实现的功能

  1. 树形结构设计

    • ✅ Materialized Path 算法实现
    • ✅ path、depth、root_id 字段
    • ✅ 高效的树形查询索引
  2. 深度控制

    • ✅ 最大深度限制(5层)
    • ✅ 清晰的错误提示
    • ✅ 深度自动计算
  3. 树形数据组装

    • ✅ TreeBuilder 通用工具类
    • ✅ CommentTreeVO 嵌套结构
    • ✅ 高效的树形组装算法
  4. 错误处理优化

    • ✅ GlobalExceptionHandler 支持自定义消息
    • ✅ 语义化错误码(400 vs 404)
    • ✅ 友好的错误提示
  5. Bug 修复

    • ✅ 根评论 path 和 rootId 正确填充
    • ✅ BusinessException 消息正确传递

📊 测试验证

  • ✅ 6层嵌套评论测试通过
  • ✅ 树形结构查询正确
  • ✅ 深度限制生效
  • ✅ 错误处理符合预期

🚀 下一步:Phase 3

已完成! 查看 phase3_guide.md 了解状态模式实现详情。


Phase 2 完成时间: 2025-12-15
Phase 3 完成时间: 2025-12-15