package service import ( "errors" "strings" "time" "github.com/freefire/jiang13-bbs/model" "gorm.io/gorm" ) // 评论操作错误 var ( ErrCommentNotFound = errors.New("评论不存在") ErrCommentForbidden = errors.New("无权限删除此评论") ErrCommentDeleteMeta = errors.New("请填写删除理由") ErrCommentInvalidType = errors.New("无效的删除类型") ErrCommentNotDeleted = errors.New("评论未被删除") ErrCommentNotChanged = errors.New("评论内容未变更") ErrCommentDeleted = 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 Deleted bool `json:"deleted,omitempty"` // 软删占位 StaffDeleted bool `json:"staff_deleted,omitempty"` // DeletedBy≠评论者 Edited bool `json:"edited,omitempty"` // 相对创建已编辑 DeletedByUser *CommentActorBrief `json:"deleted_by_user,omitempty"` // 管理删执行者(公开) Replies []CommentNode `json:"replies,omitempty"` } // CommentActorBrief 评论相关公开用户摘要(删评管理员链接等) type CommentActorBrief struct { ID uint `json:"id"` Username string `json:"username"` Nickname string `json:"nickname"` Avatar string `json:"avatar"` } // maxReplyDepth 回复层级上限(主评论为 0 层),防止恶意无限嵌套 const maxReplyDepth = 10 // ListFloorPaged 楼层分页获取帖子评论树: // 分页与楼层号只针对主评论(parent_id IS NULL),每楼附带全部子评论(时间正序)。 // 已软删评论以 tombstone 占位(计入楼层);已硬删不存在。 // totalComments 仅计未删条数(头部徽章);floors 含 tombstone 主评。 func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *Actor, page, size int) ([]CommentNode, int64, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 100 { size = 20 } // Unscoped:把软删行一并纳入,供占位展示 base := applyCommentListVisibility( s.db.Unscoped().Model(&model.Comment{}).Where("post_id = ?", postID), boardID, viewerID, actor, ) 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{}). Where("deleted_at IS NULL").Count(&totalComments).Error; err != nil { return nil, 0, 0, err } 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 } rootIDs := make([]uint, len(roots)) for i, r := range roots { rootIDs[i] = r.ID } replyQ := applyCommentListVisibility(s.db.Unscoped().Where("root_id IN (?)", rootIDs), boardID, viewerID, actor) var replies []model.Comment if err := replyQ.Order("created_at ASC").Preload("User").Find(&replies).Error; err != nil { return nil, 0, 0, err } children := make(map[uint][]model.Comment, len(replies)) for _, r := range replies { if r.ParentID != nil { children[*r.ParentID] = append(children[*r.ParentID], r) } } allRows := make([]model.Comment, 0, len(roots)+len(replies)) allRows = append(allRows, roots...) allRows = append(allRows, replies...) deleters := s.loadCommentActorsByIDs(collectStaffDeleterIDs(allRows)) placed := make(map[uint]bool, len(roots)+len(replies)) revealAudit := actor != nil && actor.CanModerateBoard(boardID) var build func(pid uint) []CommentNode build = func(pid uint) []CommentNode { out := []CommentNode{} for _, c := range children[pid] { out = append(out, toCommentNode(c, build(c.ID), revealAudit, deleters)) placed[c.ID] = true } return out } result := make([]CommentNode, len(roots)) for i := range roots { result[i] = toCommentNode(roots[i], build(roots[i].ID), revealAudit, deleters) 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, toCommentNode(r, build(r.ID), revealAudit, deleters)) break } } } return result, floors, totalComments, nil } func collectStaffDeleterIDs(rows []model.Comment) []uint { seen := map[uint]bool{} var ids []uint for _, c := range rows { if !c.DeletedAt.Valid || c.DeletedBy == 0 || c.DeletedBy == c.UserID { continue } if !seen[c.DeletedBy] { seen[c.DeletedBy] = true ids = append(ids, c.DeletedBy) } } return ids } func (s *CommentService) loadCommentActorsByIDs(ids []uint) map[uint]CommentActorBrief { out := map[uint]CommentActorBrief{} if len(ids) == 0 { return out } var users []model.User if err := s.db.Select("id", "username", "nickname", "avatar").Where("id IN ?", ids).Find(&users).Error; err != nil { return out } for _, u := range users { out[u.ID] = CommentActorBrief{ ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar, } } return out } // toCommentNode 软删占位:清空公开正文;版主保留原文;自删理由仅版主可见。 func toCommentNode(c model.Comment, replies []CommentNode, revealAudit bool, deleters map[uint]CommentActorBrief) CommentNode { edited := c.UpdatedAt.Sub(c.CreatedAt) > time.Minute if !c.DeletedAt.Valid { return CommentNode{Comment: c, Edited: edited, Replies: replies} } staff := c.DeletedBy != 0 && c.DeletedBy != c.UserID if !revealAudit { c.Content = "" } if !staff { c.DeleteType = "" if !revealAudit { c.DeleteReason = "" } } var deleter *CommentActorBrief if staff { if u, ok := deleters[c.DeletedBy]; ok { cp := u deleter = &cp } } return CommentNode{ Comment: c, Deleted: true, StaffDeleted: staff, Edited: edited, DeletedByUser: deleter, Replies: replies, } } // FloorNumber 本帖主评楼层号(含软删占位,按 created_at/id 升序);子评取其所属主楼。无则 0。 func FloorNumber(db *gorm.DB, postID, commentID uint) int { if db == nil || postID == 0 || commentID == 0 { return 0 } var c model.Comment if err := db.Unscoped().Select("id", "post_id", "parent_id", "root_id", "created_at"). Where("id = ? AND post_id = ?", commentID, postID).First(&c).Error; err != nil { return 0 } floorID := c.ID floorCreated := c.CreatedAt if c.ParentID != nil { rootID := c.ID if c.RootID != nil { rootID = *c.RootID } var root model.Comment if err := db.Unscoped().Select("id", "created_at"). Where("id = ? AND post_id = ? AND parent_id IS NULL", rootID, postID).First(&root).Error; err != nil { return 0 } floorID = root.ID floorCreated = root.CreatedAt } var ahead int64 db.Unscoped().Model(&model.Comment{}). Where("post_id = ? AND parent_id IS NULL AND (created_at < ? OR (created_at = ? AND id < ?))", postID, floorCreated, floorCreated, floorID). Count(&ahead) return int(ahead) + 1 } // (s *CommentService) FloorNumber 见包级 FloorNumber func (s *CommentService) FloorNumber(postID, commentID uint) int { return FloorNumber(s.db, postID, commentID) } // applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审); // 软删行:已发布的对所有人占位;待审软删仅作者/可审者可见占位。 func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Actor) *gorm.DB { pub := model.ContentStatusPublished pend := model.ContentStatusPending if viewerID == 0 || actor == nil { return db.Where( "(deleted_at IS NULL AND status = ?) OR (deleted_at IS NOT NULL AND status = ?)", pub, pub, ) } if actor.CanModerateBoard(boardID) { return db.Where( "(deleted_at IS NULL AND status IN ?) OR (deleted_at IS NOT NULL AND status IN ?)", []string{pub, pend}, []string{pub, pend}, ) } return db.Where( `(deleted_at IS NULL AND (status = ? OR (status = ? AND user_id = ?))) OR (deleted_at IS NOT NULL AND (status = ? OR (status = ? AND user_id = ?)))`, pub, pend, viewerID, pub, pend, viewerID, ) } // 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 } if items == nil { items = []UserCommentItem{} } 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 } // DeleteCommentOpts 软删选项:自删须填理由;staff 须选类型,「其他」才须填理由 type DeleteCommentOpts struct { DeleteType string DeleteReason string } // Delete 软删单条评论(评论者本人或板块审核权),不级联子树;占位保留楼层。 func (s *CommentService) Delete(actor *Actor, commentID, userID uint, opts DeleteCommentOpts) error { var comment model.Comment if err := s.db.First(&comment, commentID).Error; err != nil { return ErrCommentNotFound } deleteType := "" deleteReason := strings.TrimSpace(opts.DeleteReason) if comment.UserID == userID { if deleteReason == "" { return ErrCommentDeleteMeta } if len([]rune(deleteReason)) > 200 { return errors.New("删除理由不能超过 200 字") } } else { var post model.Post if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil { return ErrCommentNotFound } if actor == nil || !actor.CanModerateBoard(post.BoardID) { return ErrCommentForbidden } deleteType = strings.TrimSpace(opts.DeleteType) if !model.ValidPostDeleteType(deleteType) { return ErrCommentInvalidType } if deleteType == model.PostDeleteTypeOther { if deleteReason == "" { return ErrCommentDeleteMeta } if len([]rune(deleteReason)) > 200 { return errors.New("删除理由不能超过 200 字") } } else { // 非「其他」仅展示类型标签,不落库自由文本 deleteReason = "" } } updates := map[string]interface{}{ "delete_type": deleteType, "delete_reason": deleteReason, "deleted_by": userID, } if err := s.db.Model(&comment).Updates(updates).Error; err != nil { return err } if err := s.db.Delete(&comment).Error; err != nil { return err } if comment.Status == model.ContentStatusPublished { s.db.Model(&model.Post{}).Where("id = ?", comment.PostID). UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - 1, 0)")) } return nil } // Restore 恢复软删评论:作者可恢复自己的自删;版主可恢复本板任意软删(含管理删除)。 func (s *CommentService) Restore(actor *Actor, commentID, userID uint) error { var comment model.Comment if err := s.db.Unscoped().First(&comment, commentID).Error; err != nil { return ErrCommentNotFound } if !comment.DeletedAt.Valid { return ErrCommentNotDeleted } staffDeleted := comment.DeletedBy != 0 && comment.DeletedBy != comment.UserID allowed := false if comment.UserID == userID && !staffDeleted { allowed = true } else { var post model.Post if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil { return ErrCommentNotFound } if actor != nil && actor.CanModerateBoard(post.BoardID) { allowed = true } } if !allowed { return ErrCommentForbidden } updates := map[string]interface{}{ "deleted_at": nil, "delete_type": "", "delete_reason": "", "deleted_by": 0, } if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", commentID).Updates(updates).Error; err != nil { return err } if comment.Status == model.ContentStatusPublished { s.db.Model(&model.Post{}).Where("id = ?", comment.PostID). UpdateColumn("comment_count", gorm.Expr("comment_count + 1")) } return nil } // collectDescendantIDs 收集以 root 为根的子树 ID(含自身) func (s *CommentService) collectDescendantIDs(comment model.Comment) []uint { var all []model.Comment if comment.RootID != nil { s.db.Unscoped().Where("root_id = ?", *comment.RootID).Find(&all) } else { s.db.Unscoped().Where("root_id = ? OR id = ?", comment.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) } return ids } // Purge 硬删评论及子树(仅板块审核权);不占位;对尚未软删的已发布条递减 comment_count。 func (s *CommentService) Purge(actor *Actor, commentID, userID uint) error { var comment model.Comment if err := s.db.Unscoped().First(&comment, commentID).Error; err != nil { return ErrCommentNotFound } var post model.Post if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil { return ErrCommentNotFound } if actor == nil || !actor.CanModerateBoard(post.BoardID) { return ErrCommentForbidden } _ = userID ids := s.collectDescendantIDs(comment) // 仅对「尚未软删」的已发布评论减计数(软删时已减过) var livePublished int64 s.db.Model(&model.Comment{}). Where("id IN ? AND status = ? AND deleted_at IS NULL", ids, model.ContentStatusPublished). Count(&livePublished) return s.db.Transaction(func(tx *gorm.DB) error { if err := tx.Where("comment_id IN ?", ids).Delete(&model.CommentEditHistory{}).Error; err != nil { return err } if err := tx.Unscoped().Delete(&model.Comment{}, ids).Error; err != nil { return err } if livePublished > 0 { return tx.Model(&model.Post{}).Where("id = ?", comment.PostID). UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - ?, 0)", livePublished)).Error } return nil }) } // Update 编辑评论(作者本人或版主;已软删不可改);先写入旧正文快照再更新。 func (s *CommentService) Update(actor *Actor, commentID, userID uint, content string) (*CommentNode, error) { content = strings.TrimSpace(content) if content == "" { return nil, errors.New("评论内容不能为空") } var comment model.Comment if err := s.db.Unscoped().First(&comment, commentID).Error; err != nil { return nil, ErrCommentNotFound } if comment.DeletedAt.Valid { return nil, ErrCommentDeleted } if comment.UserID != userID { var post model.Post if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil { return nil, ErrCommentNotFound } if actor == nil || !actor.CanModerateBoard(post.BoardID) { return nil, ErrCommentForbidden } } if comment.Content == content { return nil, ErrCommentNotChanged } old := comment.Content if err := s.db.Transaction(func(tx *gorm.DB) error { hist := model.CommentEditHistory{ CommentID: comment.ID, EditorID: userID, OldContent: old, } if err := tx.Create(&hist).Error; err != nil { return err } return tx.Model(&comment).Update("content", content).Error }); err != nil { return nil, err } if err := s.db.Preload("User").First(&comment, comment.ID).Error; err != nil { return nil, err } node := toCommentNode(comment, nil, false, nil) return &node, nil } // CommentEditHistoryItem 管理可见的评论修订记录 type CommentEditHistoryItem struct { ID uint `json:"id"` Editor CommentActorBrief `json:"editor"` OldContent string `json:"old_content"` CreatedAt time.Time `json:"created_at"` } // ListEditHistory 评论编辑历史(仅版主;含软删评论;按创建时间倒序分页) func (s *CommentService) ListEditHistory(actor *Actor, commentID uint, page, size int) ([]CommentEditHistoryItem, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 50 { size = 10 } var comment model.Comment if err := s.db.Unscoped().Select("id", "post_id").First(&comment, commentID).Error; err != nil { return nil, 0, ErrCommentNotFound } var post model.Post if err := s.db.Unscoped().Select("id", "board_id").First(&post, comment.PostID).Error; err != nil { return nil, 0, ErrCommentNotFound } if actor == nil || !actor.CanModerateBoard(post.BoardID) { return nil, 0, ErrCommentForbidden } q := s.db.Model(&model.CommentEditHistory{}).Where("comment_id = ?", commentID) var total int64 if err := q.Count(&total).Error; err != nil { return nil, 0, err } var rows []model.CommentEditHistory if err := s.db.Where("comment_id = ?", commentID). Order("created_at DESC"). Offset((page - 1) * size).Limit(size). Find(&rows).Error; err != nil { return nil, 0, err } editorIDs := make([]uint, 0, len(rows)) seen := map[uint]struct{}{} for _, r := range rows { if _, ok := seen[r.EditorID]; ok { continue } seen[r.EditorID] = struct{}{} editorIDs = append(editorIDs, r.EditorID) } editors := map[uint]CommentActorBrief{} if len(editorIDs) > 0 { var users []model.User s.db.Select("id", "username", "nickname", "avatar").Where("id IN ?", editorIDs).Find(&users) for _, u := range users { editors[u.ID] = CommentActorBrief{ ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar, } } } items := make([]CommentEditHistoryItem, 0, len(rows)) for _, r := range rows { ed := editors[r.EditorID] if ed.ID == 0 { ed = CommentActorBrief{ID: r.EditorID, Username: "已注销", Nickname: "已注销"} } items = append(items, CommentEditHistoryItem{ ID: r.ID, Editor: ed, OldContent: r.OldContent, CreatedAt: r.CreatedAt, }) } return items, total, nil }