package services import ( "errors" "strings" "time" "gorm.io/gorm" "git.iioio.com/freefire/jiang13-forum/models" ) type CommentService struct { filter *SensitiveFilter settings *ForumSettingsService } func NewCommentService(filter *SensitiveFilter, settings *ForumSettingsService) *CommentService { return &CommentService{filter: filter, settings: settings} } // HasUserReplied 用户是否已在该帖发表过有效评论(已发布或审核中,不含被拒) func (s *CommentService) HasUserReplied(postID, userID uint) bool { if postID == 0 || userID == 0 { return false } var count int64 err := models.DB.Model(&models.Comment{}). Where("post_id = ? AND user_id = ? AND status IN ?", postID, userID, []string{models.ContentStatusPublished, models.ContentStatusPending}). Limit(1). Count(&count).Error return err == nil && count > 0 } type CommentCreateInput struct { UserID uint PostID uint Content string ReplyTo *uint GuestNick string GuestEmail string GuestURL string IsPrivate bool } func (s *CommentService) canViewPrivate(c models.Comment, viewerID uint, isAdmin bool, postAuthorID uint, guestSet map[uint]struct{}) bool { if !c.IsPrivate { return true } if isAdmin { return true } if viewerID > 0 && viewerID == postAuthorID { return true } if c.UserID > 0 && viewerID == c.UserID { return true } if _, ok := guestSet[c.ID]; ok { return true } return false } func (s *CommentService) fillReplyTargets(comments []models.Comment, loadMissing bool) { idMap := make(map[uint]models.Comment, len(comments)) for _, c := range comments { idMap[c.ID] = c } for i := range comments { if comments[i].ReplyTo == nil { continue } if target, ok := idMap[*comments[i].ReplyTo]; ok { t := target comments[i].ReplyTarget = &t continue } if loadMissing { var target models.Comment if models.DB.Preload("User").First(&target, *comments[i].ReplyTo).Error == nil { comments[i].ReplyTarget = &target } } } } func canViewComment(c models.Comment, viewerID uint, isAdmin bool) bool { if isAdmin || c.Status == models.ContentStatusPublished || c.Status == "" { return true } if c.Status == models.ContentStatusPending || c.Status == models.ContentStatusRejected { return viewerID > 0 && c.UserID == viewerID } return false } func (s *CommentService) ListByPost(postID, viewerID uint, isAdmin bool, postAuthorID uint, visibleGuestIDs []uint) ([]models.Comment, error) { var comments []models.Comment err := models.DB.Preload("User").Where("post_id = ?", postID).Order("floor asc").Find(&comments).Error if err != nil { return nil, err } guestSet := make(map[uint]struct{}, len(visibleGuestIDs)) for _, id := range visibleGuestIDs { guestSet[id] = struct{}{} } allByID := make(map[uint]models.Comment, len(comments)) for _, c := range comments { allByID[c.ID] = c } visible := make([]models.Comment, 0, len(comments)) visibleIDs := make(map[uint]struct{}, len(comments)) for i := range comments { if !canViewComment(comments[i], viewerID, isAdmin) { continue } if comments[i].IsPrivate && !s.canViewPrivate(comments[i], viewerID, isAdmin, postAuthorID, guestSet) { comments[i].ContentHidden = true comments[i].Content = "" } visibleIDs[comments[i].ID] = struct{}{} visible = append(visible, comments[i]) } // 父评论不可见时,回挂到最近可见祖先,避免回复在游客侧变成独立顶层评论 for i := range visible { visible[i].ThreadParentID = resolveThreadParent(visible[i].ReplyTo, visibleIDs, allByID) } s.fillReplyTargets(visible, true) for i := range visible { if rt := visible[i].ReplyTarget; rt != nil && !canViewComment(*rt, viewerID, isAdmin) { // 不可见父评论仅保留昵称供 @,不泄露正文 rt.Content = "" rt.ContentHidden = true } } s.fillLiked(visible, viewerID) return visible, nil } // resolveThreadParent 计算嵌套展示父节点:优先直接父评论,否则沿 reply_to 向上找到最近可见祖先 func resolveThreadParent(replyTo *uint, visibleIDs map[uint]struct{}, allByID map[uint]models.Comment) *uint { if replyTo == nil { return nil } if _, ok := visibleIDs[*replyTo]; ok { id := *replyTo return &id } cur := *replyTo for hops := 0; hops < 32; hops++ { parent, ok := allByID[cur] if !ok || parent.ReplyTo == nil { return nil } next := *parent.ReplyTo if _, ok := visibleIDs[next]; ok { id := next return &id } cur = next } return nil } func (s *CommentService) Create(in CommentCreateInput) (*models.Comment, error) { content := SanitizePostHTML(strings.TrimSpace(in.Content)) content = s.filter.Filter(content) if content == "" { return nil, errors.New("评论内容不能为空") } if err := s.settings.ValidateTextLength(content, s.settings.CommentMax(), ErrCommentTooLong); err != nil { return nil, err } var post models.Post if err := models.DB.First(&post, in.PostID).Error; err != nil { return nil, ErrPostNotFound } if in.UserID == 0 { return nil, errors.New("请登录后评论") } var user models.User if err := models.DB.First(&user, in.UserID).Error; err != nil { return nil, errors.New("用户不存在") } if user.Banned { return nil, errors.New("账号已被禁言") } // 讨论锁定:管理员亦不可强评(避免结贴后仍被顶楼) if post.CommentsLocked { return nil, ErrPostCommentsLocked } // 未公开帖仅作者/管理员可评论 if post.Status != models.ContentStatusPublished && post.Status != "" { if user.Role != models.RoleAdmin && post.UserID != in.UserID { return nil, errors.New("帖子审核中,暂不可评论") } } var maxFloor int models.DB.Model(&models.Comment{}).Where("post_id = ?", in.PostID).Select("COALESCE(MAX(floor), 0)").Scan(&maxFloor) if in.ReplyTo != nil { var target models.Comment if err := models.DB.Where("id = ? AND post_id = ?", *in.ReplyTo, in.PostID).First(&target).Error; err != nil { return nil, ErrCommentNotFound } if !canViewComment(target, in.UserID, user.Role == models.RoleAdmin) { return nil, ErrCommentNotFound } } status := models.ContentStatusPending if user.SkipsModeration() { status = models.ContentStatusPublished } comment := &models.Comment{ PostID: in.PostID, UserID: in.UserID, Floor: maxFloor + 1, Content: content, ReplyTo: in.ReplyTo, GuestNick: strings.TrimSpace(in.GuestNick), GuestEmail: strings.TrimSpace(in.GuestEmail), GuestURL: strings.TrimSpace(in.GuestURL), IsPrivate: in.IsPrivate, Status: status, } if err := models.DB.Create(comment).Error; err != nil { return nil, err } if status == models.ContentStatusPublished { AddExp(in.UserID, 2) } return comment, nil } // SetStatus 设置评论审核状态 func (s *CommentService) SetStatus(commentID uint, status string) error { switch status { case models.ContentStatusPending, models.ContentStatusPublished, models.ContentStatusRejected: default: return errors.New("无效的审核状态") } var comment models.Comment if err := models.DB.Select("id", "user_id", "status").First(&comment, commentID).Error; err != nil { return ErrCommentNotFound } prev := comment.Status res := models.DB.Model(&models.Comment{}).Where("id = ?", commentID).Update("status", status) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return ErrCommentNotFound } if status == models.ContentStatusPublished && prev != models.ContentStatusPublished && comment.UserID > 0 { AddExp(comment.UserID, 2) } return nil } // GetByID 获取评论 func (s *CommentService) GetByID(id uint) (*models.Comment, error) { var c models.Comment if err := models.DB.Preload("User").Preload("Post").First(&c, id).Error; err != nil { return nil, ErrCommentNotFound } return &c, nil } // fillLiked 批量标记当前用户是否已点赞 func (s *CommentService) fillLiked(comments []models.Comment, viewerID uint) { if viewerID == 0 || len(comments) == 0 { return } ids := make([]uint, 0, len(comments)) for _, c := range comments { ids = append(ids, c.ID) } var likes []models.CommentLike models.DB.Where("user_id = ? AND comment_id IN ?", viewerID, ids).Find(&likes) likedSet := make(map[uint]struct{}, len(likes)) for _, l := range likes { likedSet[l.CommentID] = struct{}{} } for i := range comments { _, comments[i].Liked = likedSet[comments[i].ID] } } // ToggleLike 切换评论点赞 func (s *CommentService) ToggleLike(userID, commentID uint) (liked bool, likeCount int, err error) { var comment models.Comment if err := models.DB.Select("id", "like_count").First(&comment, commentID).Error; err != nil { return false, 0, ErrCommentNotFound } var like models.CommentLike result := models.DB.Where("comment_id = ? AND user_id = ?", commentID, userID).Limit(1).Find(&like) if result.Error != nil { return false, 0, result.Error } if result.RowsAffected > 0 { if err := models.DB.Delete(&like).Error; err != nil { return false, 0, err } models.DB.Model(&models.Comment{}).Where("id = ?", commentID).UpdateColumn("like_count", gorm.Expr("CASE WHEN like_count > 0 THEN like_count - 1 ELSE 0 END")) _ = models.DB.Select("like_count").First(&comment, commentID) return false, comment.LikeCount, nil } like = models.CommentLike{CommentID: commentID, UserID: userID} if err := models.DB.Create(&like).Error; err != nil { return false, 0, err } models.DB.Model(&models.Comment{}).Where("id = ?", commentID).UpdateColumn("like_count", gorm.Expr("like_count + 1")) _ = models.DB.Select("like_count").First(&comment, commentID) return true, comment.LikeCount, nil } // IsLiked 用户是否已点赞该评论 func (s *CommentService) IsLiked(userID, commentID uint) bool { if userID == 0 || commentID == 0 { return false } var count int64 models.DB.Model(&models.CommentLike{}).Where("comment_id = ? AND user_id = ?", commentID, userID).Count(&count) return count > 0 } // PendingCommentCount 待审评论数 func (s *CommentService) PendingCommentCount() (int64, error) { var n int64 err := models.DB.Model(&models.Comment{}).Where("status = ?", models.ContentStatusPending).Count(&n).Error return n, err } // ListPending 待审评论列表(管理端) func (s *CommentService) ListPending(page, size int) ([]models.Comment, int64, error) { if page < 1 { page = 1 } if size < 1 { size = 30 } if size > 100 { size = 100 } db := models.DB.Model(&models.Comment{}). Where("status = ?", models.ContentStatusPending). Preload("User").Preload("Post") var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, err } var list []models.Comment err := db.Order("id asc").Offset((page - 1) * size).Limit(size).Find(&list).Error return list, total, err } func (s *CommentService) Delete(userID, commentID uint, isAdmin bool) error { if !isAdmin { return ErrPermissionDenied } return s.AdminDelete(commentID) } func (s *CommentService) Update(userID, commentID uint, isAdmin, skipModeration bool, content string) (string, bool, error) { var comment models.Comment if err := models.DB.First(&comment, commentID).Error; err != nil { return "", false, ErrCommentNotFound } if !isAdmin && (comment.UserID == 0 || comment.UserID != userID) { return "", false, ErrPermissionDenied } if !isAdmin { window := s.settings.CommentEditWindowMinutes() if window > 0 && time.Since(comment.CreatedAt) > time.Duration(window)*time.Minute { return "", false, errors.New("已超过可编辑时限") } } content = SanitizePostHTML(strings.TrimSpace(content)) content = s.filter.Filter(content) if content == "" { return "", false, errors.New("评论内容不能为空") } if err := s.settings.ValidateTextLength(content, s.settings.CommentMax(), ErrCommentTooLong); err != nil { return "", false, err } if content == comment.Content { return content, false, nil } enteredPending := false err := models.DB.Transaction(func(tx *gorm.DB) error { rev := models.CommentRevision{ CommentID: commentID, EditorID: userID, Content: comment.Content, } if err := tx.Create(&rev).Error; err != nil { return err } updates := map[string]interface{}{"content": content} if !skipModeration { updates["status"] = models.ContentStatusPending enteredPending = true } return tx.Model(&comment).Updates(updates).Error }) if err != nil { return "", false, err } return content, enteredPending, nil } // collectReplySubtreeIDs 沿 reply_to BFS 收集子树 ID(含 rootID) // softDeletedOnly 为 true 时仅收集已软删节点(用于回收站恢复/永久删除) func collectReplySubtreeIDs(db *gorm.DB, rootID uint, softDeletedOnly bool) ([]uint, error) { q := db if softDeletedOnly { q = db.Unscoped() } ids := []uint{rootID} seen := map[uint]struct{}{rootID: {}} frontier := []uint{rootID} for len(frontier) > 0 { childQ := q.Model(&models.Comment{}).Select("id").Where("reply_to IN ?", frontier) if softDeletedOnly { childQ = childQ.Where("deleted_at IS NOT NULL") } var children []models.Comment if err := childQ.Find(&children).Error; err != nil { return nil, err } frontier = frontier[:0] for _, c := range children { if _, ok := seen[c.ID]; ok { continue } seen[c.ID] = struct{}{} ids = append(ids, c.ID) frontier = append(frontier, c.ID) } } return ids, nil } // AdminDelete 软删除评论及其回复树(进入回收站);修订与点赞保留以便恢复 func (s *CommentService) AdminDelete(commentID uint) error { var root models.Comment if err := models.DB.First(&root, commentID).Error; err != nil { return ErrCommentNotFound } ids, err := collectReplySubtreeIDs(models.DB, commentID, false) if err != nil { return err } return models.DB.Where("id IN ?", ids).Delete(&models.Comment{}).Error } // TrashCommentItem 评论回收站列表项 type TrashCommentItem struct { models.Comment DeletedAt time.Time `json:"deleted_at"` } // ListTrash 列出已软删评论(不含随帖子一并删除的评论,那些在帖子回收站处理) func (s *CommentService) ListTrash(page, size int, keyword string) ([]TrashCommentItem, int64, error) { if page < 1 { page = 1 } size = s.settings.NormalizePageSize(size) db := models.DB.Unscoped().Model(&models.Comment{}). Where("comments.deleted_at IS NOT NULL"). Joins("JOIN posts ON posts.id = comments.post_id AND posts.deleted_at IS NULL"). Preload("User").Preload("Post") if keyword != "" { kw, err := s.settings.NormalizeSearchKeyword(keyword) if err != nil { return nil, 0, err } like := "%" + kw + "%" db = db.Where("comments.content LIKE ? OR posts.title LIKE ?", like, like) } var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, err } var comments []models.Comment if err := db.Order("comments.deleted_at DESC").Offset((page - 1) * size).Limit(size).Find(&comments).Error; err != nil { return nil, 0, err } out := make([]TrashCommentItem, len(comments)) for i, c := range comments { out[i] = TrashCommentItem{Comment: c} if c.DeletedAt.Valid { out[i].DeletedAt = c.DeletedAt.Time } } return out, total, nil } // Restore 从回收站恢复评论及其已软删的回复树 func (s *CommentService) Restore(commentID uint) error { var comment models.Comment if err := models.DB.Unscoped().First(&comment, commentID).Error; err != nil { return ErrCommentNotFound } if !comment.DeletedAt.Valid { return errors.New("评论未被删除") } // 所属帖子必须仍存在且未删除 var post models.Post if err := models.DB.First(&post, comment.PostID).Error; err != nil { return errors.New("所属帖子不存在或已在回收站,请先恢复帖子") } ids, err := collectReplySubtreeIDs(models.DB, commentID, true) if err != nil { return err } return models.DB.Unscoped().Model(&models.Comment{}). Where("id IN ?", ids). Update("deleted_at", nil).Error } // Purge 永久删除回收站中的评论及其已软删回复(含修订、点赞) func (s *CommentService) Purge(commentID uint) error { var comment models.Comment if err := models.DB.Unscoped().First(&comment, commentID).Error; err != nil { return ErrCommentNotFound } if !comment.DeletedAt.Valid { return errors.New("仅可彻底删除回收站中的评论,请先删除评论") } ids, err := collectReplySubtreeIDs(models.DB, commentID, true) if err != nil { return err } return models.DB.Transaction(func(tx *gorm.DB) error { if err := tx.Where("comment_id IN ?", ids).Delete(&models.CommentRevision{}).Error; err != nil { return err } if err := tx.Where("comment_id IN ?", ids).Delete(&models.CommentLike{}).Error; err != nil { return err } return tx.Unscoped().Where("id IN ?", ids).Delete(&models.Comment{}).Error }) } // ListRevisions 评论编辑历史(管理员查看) func (s *CommentService) ListRevisions(commentID uint) ([]models.CommentRevision, error) { if _, err := s.GetByID(commentID); err != nil { return nil, err } var revs []models.CommentRevision err := models.DB.Preload("Editor"). Where("comment_id = ?", commentID). Order("id desc").Find(&revs).Error if err != nil { return nil, err } if revs == nil { revs = []models.CommentRevision{} } return revs, nil } // RecentCommentItem 右栏「最新评论」条目 type RecentCommentItem struct { ID uint `json:"id"` PostID uint `json:"post_id"` Floor int `json:"floor"` UserID uint `json:"user_id,omitempty"` Author string `json:"author"` Avatar string `json:"avatar"` Excerpt string `json:"excerpt"` PostTitle string `json:"post_title"` CreatedAt string `json:"created_at"` } // ListRecentPublic 前台最新公开评论(排除私密、已删帖) func (s *CommentService) ListRecentPublic(limit int) ([]RecentCommentItem, error) { if limit < 1 { limit = 8 } var comments []models.Comment err := models.DB.Preload("User").Preload("Post"). Where("is_private = ? AND status = ?", false, models.ContentStatusPublished). Order("id desc").Limit(limit * 2). // 多取一些以跳过已删帖 Find(&comments).Error if err != nil { return nil, err } out := make([]RecentCommentItem, 0, limit) for _, c := range comments { if c.Post.ID == 0 { continue } author := "游客" avatar := "" if c.UserID > 0 && c.User.Nickname != "" { author = c.User.Nickname avatar = c.User.Avatar } else if c.GuestNick != "" { author = c.GuestNick } excerpt := StripHTMLForSearch(c.Content) excerpt = truncateRunes(excerpt, 64) if excerpt == "" { excerpt = "发表了评论" } out = append(out, RecentCommentItem{ ID: c.ID, PostID: c.PostID, Floor: c.Floor, UserID: c.UserID, Author: author, Avatar: avatar, Excerpt: excerpt, PostTitle: c.Post.Title, // 返回 UTC ISO,由前端按本地时区展示(避免与后台差 8 小时) CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339), }) if len(out) >= limit { break } } return out, nil } func truncateRunes(s string, n int) string { if n <= 0 || s == "" { return s } runes := []rune(s) if len(runes) <= n { return s } return string(runes[:n]) + "…" } // ListRecent 管理员查看最近评论 func (s *CommentService) ListRecent(page, size int, status string) ([]models.Comment, int64, error) { if page < 1 { page = 1 } if size < 1 { size = 20 } db := models.DB.Model(&models.Comment{}) switch status { case models.ContentStatusPending, models.ContentStatusPublished, models.ContentStatusRejected: db = db.Where("status = ?", status) } var total int64 db.Count(&total) var comments []models.Comment err := db.Preload("User").Preload("Post"). Order("CASE WHEN status = 'pending' THEN 0 ELSE 1 END, id DESC"). Offset((page - 1) * size).Limit(size).Find(&comments).Error if err != nil { return nil, 0, err } s.fillReplyTargets(comments, true) return comments, total, err }