Files
jiang13-forum/services/comment.go
freefire 145c7a3e1f feat: SSR 评论编辑与作者删除
帖详情提供时限内编辑与软删入口,作者删除与管理员共用回收站子树语义。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 21:36:12 +08:00

720 lines
21 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 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 {
var comment models.Comment
if err := models.DB.First(&comment, commentID).Error; err != nil {
return ErrCommentNotFound
}
if !isAdmin && (comment.UserID == 0 || comment.UserID != userID) {
return ErrPermissionDenied
}
return s.AdminDelete(commentID)
}
// CanUserEditComment 作者在编辑时限内,或管理员
func (s *CommentService) CanUserEditComment(comment *models.Comment, userID uint, isAdmin bool) bool {
window := 3
if s != nil && s.settings != nil {
window = s.settings.CommentEditWindowMinutes()
}
return canEditComment(comment, userID, isAdmin, window)
}
// CanUserDeleteComment 作者或管理员可软删
func (s *CommentService) CanUserDeleteComment(comment *models.Comment, userID uint, isAdmin bool) bool {
if comment == nil || userID == 0 {
return false
}
if isAdmin {
return true
}
return comment.UserID > 0 && comment.UserID == userID
}
func canEditComment(comment *models.Comment, userID uint, isAdmin bool, windowMin int) bool {
if comment == nil || userID == 0 {
return false
}
if isAdmin {
return true
}
if comment.UserID == 0 || comment.UserID != userID {
return false
}
if windowMin > 0 && time.Since(comment.CreatedAt) > time.Duration(windowMin)*time.Minute {
return false
}
return true
}
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
}