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), } }