package service import ( "errors" "strings" "time" "github.com/freefire/jiang13-bbs/model" "gorm.io/gorm" ) // 评论操作错误 var ( ErrCommentNotFound = errors.New("评论不存在") ErrCommentForbidden = errors.New("无权限删除此评论") ) // CommentService 评论服务 type CommentService struct { db *gorm.DB } func NewCommentService(db *gorm.DB) *CommentService { return &CommentService{db: db} } // CommentNode 评论树节点:嵌入 model.Comment 使 JSON 字段拍平,replies 为空时省略 type CommentNode struct { model.Comment Replies []CommentNode `json:"replies,omitempty"` } // maxReplyDepth 回复层级上限(主评论为 0 层),防止恶意无限嵌套 const maxReplyDepth = 10 // ListFloorPaged 楼层分页获取帖子评论树: // 分页与楼层号只针对主评论(parent_id IS NULL),每楼附带全部子评论(时间正序)。 // 返回:树根列表、楼层数、全部评论数(含回复)。 func (s *CommentService) ListFloorPaged(postID uint, page, size int) ([]CommentNode, int64, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 100 { size = 20 } base := s.db.Model(&model.Comment{}). Where("post_id = ? AND status = ?", postID, model.ContentStatusPublished) var floors, totalComments int64 if err := base.Session(&gorm.Session{}). Where("parent_id IS NULL").Count(&floors).Error; err != nil { return nil, 0, 0, err } if err := base.Session(&gorm.Session{}).Count(&totalComments).Error; err != nil { return nil, 0, 0, err } // 1. 本页楼层(主评论) var roots []model.Comment if err := base.Session(&gorm.Session{}). Where("parent_id IS NULL"). Order("created_at ASC"). Preload("User"). Offset((page - 1) * size). Limit(size). Find(&roots).Error; err != nil { return nil, 0, 0, err } if len(roots) == 0 { return []CommentNode{}, floors, totalComments, nil } // 2. 一次拉齐本页全部楼层的所有回复(root_id 冗余,免递归查询) rootIDs := make([]uint, len(roots)) for i, r := range roots { rootIDs[i] = r.ID } var replies []model.Comment if err := s.db.Where("root_id IN (?) AND status = ?", rootIDs, model.ContentStatusPublished). Order("created_at ASC"). Preload("User"). Find(&replies).Error; err != nil { return nil, 0, 0, err } // 3. 组装树:按 parent_id 建索引递归构造;父缺失(已删/脏数据)时降级挂到所属楼层根部 children := make(map[uint][]model.Comment, len(replies)) // parentID -> 直接子评论(时间正序) for _, r := range replies { if r.ParentID != nil { children[*r.ParentID] = append(children[*r.ParentID], r) } } placed := make(map[uint]bool, len(roots)+len(replies)) var build func(pid uint) []CommentNode build = func(pid uint) []CommentNode { out := []CommentNode{} for _, c := range children[pid] { out = append(out, CommentNode{Comment: c, Replies: build(c.ID)}) placed[c.ID] = true } return out } result := make([]CommentNode, len(roots)) for i := range roots { result[i] = CommentNode{Comment: roots[i], Replies: build(roots[i].ID)} placed[roots[i].ID] = true } // 防御性兜底:父评论不在树中的回复 → 挂到所属楼层根部,保证不丢内容 for _, r := range replies { if placed[r.ID] || r.RootID == nil { continue } for i := range result { if result[i].ID == *r.RootID { result[i].Replies = append(result[i].Replies, CommentNode{Comment: r, Replies: build(r.ID)}) break } } } return result, floors, totalComments, nil } // Create 创建评论(parentID 为 nil 时发主评论/楼层,否则发为对应评论的子回复)。 // status 由 handler 按角色计算:管理团队直发 published,普通用户进入 pending; // pending 评论不计入 comment_count,审核通过时才 +1。 // 返回:新评论、父评论(子回复时非 nil,供通知定位被回复人) func (s *CommentService) Create(userID, postID uint, content string, parentID *uint, status string) (*model.Comment, *model.Comment, error) { content = strings.TrimSpace(content) if content == "" { return nil, nil, errors.New("评论内容不能为空") } if status != model.ContentStatusPending && status != model.ContentStatusPublished { status = model.ContentStatusPending } // 检查帖子存在且已发布(待审/被拒帖子不接受评论) var post model.Post if err := s.db.First(&post, postID).Error; err != nil { return nil, nil, errors.New("帖子不存在") } if post.Status != model.ContentStatusPublished { return nil, nil, errors.New("帖子不存在") } comment := &model.Comment{ PostID: postID, UserID: userID, Content: content, Status: status, } // 子回复:校验父评论(同帖、已发布),继承楼层根与层级 var parent *model.Comment if parentID != nil { parent = &model.Comment{} if err := s.db. Where("id = ? AND post_id = ? AND status = ?", *parentID, postID, model.ContentStatusPublished). First(parent).Error; err != nil { return nil, nil, errors.New("回复的评论不存在") } if parent.Depth >= maxReplyDepth { return nil, nil, errors.New("回复层级过深") } rootID := parent.ID if parent.RootID != nil { rootID = *parent.RootID } comment.ParentID = parentID comment.RootID = &rootID comment.Depth = parent.Depth + 1 } if err := s.db.Create(comment).Error; err != nil { return nil, nil, err } // 仅已发布评论立即计入评论数;待审评论通过审核时才 +1 if comment.Status == model.ContentStatusPublished { s.db.Model(&post).UpdateColumn("comment_count", gorm.Expr("comment_count + 1")) } // 预加载用户 s.db.Preload("User").First(comment, comment.ID) return comment, parent, nil } // UserCommentItem 用户评论列表项(含帖子标题便于跳转) type UserCommentItem struct { ID uint `json:"id"` PostID uint `json:"post_id"` PostTitle string `json:"post_title"` Content string `json:"content"` CreatedAt time.Time `json:"created_at"` } // ListByUser 获取指定用户发表的评论(含所属帖子标题) func (s *CommentService) ListByUser(userID uint, page, size int) ([]UserCommentItem, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 50 { size = 20 } query := s.db.Table("comments"). Select("comments.id, comments.post_id, posts.title AS post_title, comments.content, comments.created_at"). Joins("JOIN posts ON posts.id = comments.post_id"). Where("comments.user_id = ? AND comments.status = ? AND posts.deleted_at IS NULL", userID, model.ContentStatusPublished) var total int64 if err := query.Count(&total).Error; err != nil { return nil, 0, err } var items []UserCommentItem offset := (page - 1) * size if err := query.Order("comments.created_at DESC").Offset(offset).Limit(size).Scan(&items).Error; err != nil { return nil, 0, err } return items, total, nil } // CountByUser 统计用户评论数(仅统计未删除帖子上的评论,与列表保持一致) func (s *CommentService) CountByUser(userID uint) (int64, error) { var total int64 err := s.db.Table("comments"). Joins("JOIN posts ON posts.id = comments.post_id"). Where("comments.user_id = ? AND comments.status = ? AND posts.deleted_at IS NULL", userID, model.ContentStatusPublished). Count(&total).Error return total, err } // Delete 删除评论(作者本人,或对帖子所属板块有审核权的管理成员), // 级联软删整棵子树 func (s *CommentService) Delete(actor *Actor, commentID, userID uint) error { var comment model.Comment if err := s.db.First(&comment, commentID).Error; err != nil { return ErrCommentNotFound } // 权限校验:作者本人或板块审核权 if comment.UserID != userID { var post model.Post if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil { return ErrCommentNotFound } if !actor.CanModerateBoard(post.BoardID) { return ErrCommentForbidden } } // 收集子树:取同楼层全部评论,多轮标记出以目标为祖先的集合(含自身,深度有限必然收敛) var all []model.Comment if comment.RootID != nil { s.db.Where("root_id = ?", *comment.RootID).Find(&all) } else { s.db.Where("root_id = ?", comment.ID).Find(&all) } descendants := map[uint]bool{comment.ID: true} for i := 0; i <= maxReplyDepth; i++ { added := false for _, c := range all { if c.ParentID == nil || descendants[c.ID] { continue } if descendants[*c.ParentID] { descendants[c.ID] = true added = true } } if !added { break } } ids := make([]uint, 0, len(descendants)) for id := range descendants { ids = append(ids, id) } // 只有已发布评论曾计入 comment_count,递减时排除待审/被拒评论。 // all 含同楼层全部子评论(删除主评论时目标自身不在 all 内,需单独计入) publishedCnt := 0 if comment.Status == model.ContentStatusPublished { publishedCnt++ } for _, c := range all { if c.ID == comment.ID { continue } if descendants[c.ID] && c.Status == model.ContentStatusPublished { publishedCnt++ } } // 软删子树并按已发布条数递减帖子评论数 if err := s.db.Delete(&model.Comment{}, ids).Error; err != nil { return err } if publishedCnt > 0 { s.db.Model(&model.Post{}).Where("id = ?", comment.PostID). UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - ?, 0)", publishedCnt)) } return nil }