Files
smartmate/backend/services/taskclassforum/commenttree/tree.go
2026-05-04 20:38:49 +08:00

205 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package commenttree
import (
"fmt"
"sort"
"time"
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
)
const deletedCommentPlaceholder = "该评论已删除"
type commentTreeNode struct {
comment forummodel.ForumComment
parent *commentTreeNode
children []*commentTreeNode
}
// BuildForumCommentTree 将扁平评论组装为多层评论树。
//
// 职责边界:
// 1. 负责根据 parent_comment_id 组装无限层树结构,并按 CreatedAt 升序稳定排序;
// 2. 负责把软删除评论转换成前端展示文案,同时保留 deleted 状态与 deleted_at
// 3. 不负责查询数据库、补充真实昵称头像,也不负责帖子级权限校验。
func BuildForumCommentTree(comments []forummodel.ForumComment, actorUserID uint64) []forumcontracts.ForumCommentNode {
if len(comments) == 0 {
return nil
}
nodesByID := make(map[uint64]*commentTreeNode, len(comments))
orderedNodes := make([]*commentTreeNode, 0, len(comments))
for i := range comments {
node := &commentTreeNode{comment: comments[i]}
nodesByID[comments[i].ID] = node
orderedNodes = append(orderedNodes, node)
}
roots := attachCommentTreeNodes(orderedNodes, nodesByID)
sortCommentTreeChildren(roots)
result := make([]forumcontracts.ForumCommentNode, 0, len(roots))
for i := range roots {
result = append(result, buildForumCommentNode(roots[i], actorUserID))
}
return result
}
// attachCommentTreeNodes 按原始 parent_comment_id 把评论挂成树。
//
// 职责边界:
// 1. 只处理节点挂载关系,不做字段格式化;
// 2. 缺失父节点、自指向、环引用都回退到根层,避免整棵树丢失;
// 3. 根层顺序先保留输入顺序,后续统一由排序函数做稳定排序。
func attachCommentTreeNodes(
orderedNodes []*commentTreeNode,
nodesByID map[uint64]*commentTreeNode,
) []*commentTreeNode {
roots := make([]*commentTreeNode, 0, len(orderedNodes))
for i := range orderedNodes {
node := orderedNodes[i]
parentID := node.comment.ParentCommentID
if parentID == nil {
roots = append(roots, node)
continue
}
parentNode, ok := nodesByID[*parentID]
if !ok || parentNode == nil || parentNode.comment.ID == node.comment.ID {
roots = append(roots, node)
continue
}
if wouldCreateCommentCycle(nodesByID, node.comment.ID, parentNode) {
roots = append(roots, node)
continue
}
node.parent = parentNode
parentNode.children = append(parentNode.children, node)
}
return roots
}
// wouldCreateCommentCycle 判断把 child 挂到 parent 下时是否会形成环。
//
// 职责边界:
// 1. 只依赖原始 parent_comment_id 链路判断,不依赖当前挂载顺序;
// 2. 一旦发现 child 会回到自己,或父链本身已成环,就返回 true
// 3. 父链中途断开时按“无环”处理,让节点继续挂到可用分支上。
func wouldCreateCommentCycle(
nodesByID map[uint64]*commentTreeNode,
childCommentID uint64,
parentNode *commentTreeNode,
) bool {
visited := make(map[uint64]struct{})
current := parentNode
for current != nil {
if current.comment.ID == childCommentID {
return true
}
if _, seen := visited[current.comment.ID]; seen {
return true
}
visited[current.comment.ID] = struct{}{}
if current.comment.ParentCommentID == nil {
return false
}
nextNode, ok := nodesByID[*current.comment.ParentCommentID]
if !ok {
return false
}
current = nextNode
}
return false
}
// sortCommentTreeChildren 对根层以下的兄弟节点做 CreatedAt 升序稳定排序。
//
// 职责边界:
// 1. 根层顺序来自服务层根评论分页,必须保留 latest/oldest 的查询语义;
// 2. 子回复统一按 CreatedAt 升序展示,符合常见对话阅读顺序;
// 3. 相同 CreatedAt 依赖稳定排序保留原始输入顺序,避免同秒回复来回跳动。
func sortCommentTreeChildren(nodes []*commentTreeNode) {
if len(nodes) == 0 {
return
}
for i := range nodes {
sort.SliceStable(nodes[i].children, func(left, right int) bool {
return nodes[i].children[left].comment.CreatedAt.Before(nodes[i].children[right].comment.CreatedAt)
})
sortCommentTreeChildren(nodes[i].children)
}
}
// buildForumCommentNode 把内部树节点转换成对外契约节点。
//
// 职责边界:
// 1. 负责软删除展示文案、CanDelete、时间格式等输出字段整理
// 2. 根节点统一输出 nil parent_comment_id孤儿兜底到根层后也遵循该规则
// 3. 这里沿用当前服务里的“用户{ID}”占位昵称语义。
func buildForumCommentNode(node *commentTreeNode, actorUserID uint64) forumcontracts.ForumCommentNode {
children := make([]forumcontracts.ForumCommentNode, 0, len(node.children))
for i := range node.children {
children = append(children, buildForumCommentNode(node.children[i], actorUserID))
}
// 1. 先基于最终挂载结果回填 parent_comment_id保证孤儿回退到根层后对外语义一致。
// 2. 再处理软删除评论文案:内容固定替换,但 status 仍保留 deleted便于前端区分。
// 3. 最后按“当前用户且评论可见”计算 CanDelete避免已删除评论被重复展示可删除按钮。
parentCommentID := actualParentCommentID(node.parent)
content := node.comment.Content
if node.comment.Status == forummodel.ForumCommentStatusDeleted {
content = deletedCommentPlaceholder
}
return forumcontracts.ForumCommentNode{
CommentID: node.comment.ID,
PostID: node.comment.PostID,
ParentCommentID: parentCommentID,
Content: content,
Status: node.comment.Status,
Author: buildCommentAuthor(node.comment.UserID),
CanDelete: node.comment.Status == forummodel.ForumCommentStatusVisible && node.comment.UserID == actorUserID,
CreatedAt: formatCommentTime(node.comment.CreatedAt),
DeletedAt: formatCommentTimePtr(node.comment.DeletedAt),
Children: children,
}
}
func actualParentCommentID(parent *commentTreeNode) *uint64 {
if parent == nil {
return nil
}
parentID := parent.comment.ID
return &parentID
}
func formatCommentTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.Format(time.RFC3339)
}
func formatCommentTimePtr(value *time.Time) *string {
if value == nil || value.IsZero() {
return nil
}
formatted := value.Format(time.RFC3339)
return &formatted
}
func buildCommentAuthor(userID uint64) forumcontracts.UserBrief {
// 由于本轮写入范围被限制在 commenttree/tree.go暂时无法把 UserBrief 生成逻辑下沉成公共能力;
// 这里先与现有服务层保持同一占位昵称语义,避免为了抽公共层去改动 sv/contract 等非授权文件。
return forumcontracts.UserBrief{
UserID: userID,
Nickname: fmt.Sprintf("用户%d", userID),
}
}