package service import ( "errors" "strings" "time" "github.com/freefire/jiang13-bbs/model" "gorm.io/gorm" ) // 内容库管理错误 var ( ErrContentNotDeleted = errors.New("仅已删除内容可彻底清除") ErrContentNotFound = errors.New("内容不存在") ) func (s *ModerationService) authorRole(userID uint) (model.Role, error) { var u model.User if err := s.db.Select("role").First(&u, userID).Error; err != nil { return model.RoleUser, err } return u.Role, nil } func (s *ModerationService) assertCanModerateAuthor(actor *Actor, authorID uint) error { role, err := s.authorRole(authorID) if err != nil { return err } if !actor.CanModerateAuthor(role) { return ErrAuthorProtected } return nil } const adminContentPageSize = 15 // AdminContentStatus 列表筛选:live=未删除全部状态;其余为具体状态或已删除 type AdminContentStatus string const ( AdminContentLive AdminContentStatus = "live" AdminContentPending AdminContentStatus = "pending" AdminContentRejected AdminContentStatus = "rejected" AdminContentPublished AdminContentStatus = "published" AdminContentDeleted AdminContentStatus = "deleted" ) // AdminContentCounts 当前操作者可见范围内的数量(用于页内 Tab) type AdminContentCounts struct { PostsLive int64 `json:"posts_live"` PostsPending int64 `json:"posts_pending"` PostsRejected int64 `json:"posts_rejected"` PostsDeleted int64 `json:"posts_deleted"` CommentsLive int64 `json:"comments_live"` CommentsPending int64 `json:"comments_pending"` CommentsRejected int64 `json:"comments_rejected"` CommentsDeleted int64 `json:"comments_deleted"` } // AdminContentPost 后台帖子列表项 type AdminContentPost struct { ID uint `json:"id"` Title string `json:"title"` Status string `json:"status"` Deleted bool `json:"deleted"` DeletedAt *time.Time `json:"deleted_at,omitempty"` CreatedAt time.Time `json:"created_at"` CommentCount int `json:"comment_count"` BoardID uint `json:"board_id"` Board model.Board `json:"board"` User model.User `json:"user"` } // AdminContentComment 后台评论列表项 type AdminContentComment struct { ID uint `json:"id"` PostID uint `json:"post_id"` PostTitle string `json:"post_title"` BoardID uint `json:"board_id"` Content string `json:"content"` Status string `json:"status"` Edited bool `json:"edited"` // 相对创建已编辑(同评论侧规则),用于隐藏无修订的历史入口 Deleted bool `json:"deleted"` DeletedAt *time.Time `json:"deleted_at,omitempty"` CreatedAt time.Time `json:"created_at"` Board model.Board `json:"board"` User model.User `json:"user"` } func normalizeAdminStatus(raw string) AdminContentStatus { switch AdminContentStatus(raw) { case AdminContentPending, AdminContentRejected, AdminContentPublished, AdminContentDeleted: return AdminContentStatus(raw) default: return AdminContentLive } } func applyPostStatusFilter(q *gorm.DB, st AdminContentStatus) *gorm.DB { switch st { case AdminContentDeleted: return q.Where("deleted_at IS NOT NULL") case AdminContentPending, AdminContentRejected, AdminContentPublished: return q.Where("deleted_at IS NULL AND status = ?", string(st)) default: return q.Where("deleted_at IS NULL") } } func applyCommentStatusFilter(q *gorm.DB, st AdminContentStatus) *gorm.DB { switch st { case AdminContentDeleted: return q.Where("comments.deleted_at IS NOT NULL") case AdminContentPending, AdminContentRejected, AdminContentPublished: return q.Where("comments.deleted_at IS NULL AND comments.status = ?", string(st)) default: return q.Where("comments.deleted_at IS NULL") } } func (s *ModerationService) scopedPosts(actor *Actor) *gorm.DB { q := s.db.Unscoped().Model(&model.Post{}) if actor != nil && actor.Role == model.RoleBoardAdmin { if len(actor.BoardIDs) == 0 { return q.Where("1 = 0") } q = q.Where("board_id IN ?", actor.BoardIDs) } return q } func (s *ModerationService) scopedComments(actor *Actor) *gorm.DB { q := s.db.Unscoped().Table("comments"). Joins("JOIN posts ON posts.id = comments.post_id") if actor != nil && actor.Role == model.RoleBoardAdmin { if len(actor.BoardIDs) == 0 { return q.Where("1 = 0") } q = q.Where("posts.board_id IN ?", actor.BoardIDs) } return q } // AdminContentCounts 各筛选桶数量(每次独立起查询,避免 Where 叠加) func (s *ModerationService) AdminContentCounts(actor *Actor) AdminContentCounts { var c AdminContentCounts s.scopedPosts(actor).Where("deleted_at IS NULL").Count(&c.PostsLive) s.scopedPosts(actor).Where("deleted_at IS NULL AND status = ?", model.ContentStatusPending).Count(&c.PostsPending) s.scopedPosts(actor).Where("deleted_at IS NULL AND status = ?", model.ContentStatusRejected).Count(&c.PostsRejected) s.scopedPosts(actor).Where("deleted_at IS NOT NULL").Count(&c.PostsDeleted) s.scopedComments(actor).Where("comments.deleted_at IS NULL").Count(&c.CommentsLive) s.scopedComments(actor).Where("comments.deleted_at IS NULL AND comments.status = ?", model.ContentStatusPending).Count(&c.CommentsPending) s.scopedComments(actor).Where("comments.deleted_at IS NULL AND comments.status = ?", model.ContentStatusRejected).Count(&c.CommentsRejected) s.scopedComments(actor).Where("comments.deleted_at IS NOT NULL").Count(&c.CommentsDeleted) return c } // ListAdminPosts 后台帖子库(含已删除) func (s *ModerationService) ListAdminPosts(actor *Actor, status, keyword string, page int) ([]AdminContentPost, int64, error) { if page < 1 { page = 1 } st := normalizeAdminStatus(status) q := applyPostStatusFilter(s.scopedPosts(actor), st) if kw := strings.TrimSpace(keyword); kw != "" { like := "%" + kw + "%" q = q.Where("title ILIKE ?", like) } var total int64 if err := q.Count(&total).Error; err != nil { return nil, 0, err } var posts []model.Post if err := q.Order("id DESC"). Offset((page - 1) * adminContentPageSize).Limit(adminContentPageSize). Preload("Board").Preload("User"). Find(&posts).Error; err != nil { return nil, 0, err } items := make([]AdminContentPost, 0, len(posts)) for _, p := range posts { it := AdminContentPost{ ID: p.ID, Title: p.Title, Status: p.Status, CreatedAt: p.CreatedAt, CommentCount: p.CommentCount, BoardID: p.BoardID, Board: p.Board, User: p.User, } if p.DeletedAt.Valid { it.Deleted = true t := p.DeletedAt.Time it.DeletedAt = &t } items = append(items, it) } return items, total, nil } // ListAdminComments 后台评论库(含已删除;所属帖即使已删仍可见标题) func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword string, page int) ([]AdminContentComment, int64, error) { if page < 1 { page = 1 } st := normalizeAdminStatus(status) q := applyCommentStatusFilter(s.scopedComments(actor), st) if kw := strings.TrimSpace(keyword); kw != "" { like := "%" + kw + "%" q = q.Where("comments.content ILIKE ?", like) } var total int64 if err := q.Count(&total).Error; err != nil { return nil, 0, err } type row struct { ID uint PostID uint Content string Status string CreatedAt time.Time UpdatedAt time.Time DeletedAt gorm.DeletedAt BoardID uint PostTitle string UserID uint } var rows []row if err := q.Select("comments.id, comments.post_id, comments.content, comments.status, comments.created_at, comments.updated_at, comments.deleted_at, posts.board_id, posts.title AS post_title, comments.user_id"). Order("comments.id DESC"). Offset((page - 1) * adminContentPageSize).Limit(adminContentPageSize). Scan(&rows).Error; err != nil { return nil, 0, err } boardIDs := make([]uint, 0) userIDs := make([]uint, 0) seenB := map[uint]struct{}{} seenU := map[uint]struct{}{} for _, r := range rows { if _, ok := seenB[r.BoardID]; !ok { seenB[r.BoardID] = struct{}{} boardIDs = append(boardIDs, r.BoardID) } if _, ok := seenU[r.UserID]; !ok { seenU[r.UserID] = struct{}{} userIDs = append(userIDs, r.UserID) } } boards := map[uint]model.Board{} if len(boardIDs) > 0 { var list []model.Board s.db.Where("id IN ?", boardIDs).Find(&list) for _, b := range list { boards[b.ID] = b } } users := map[uint]model.User{} if len(userIDs) > 0 { var list []model.User s.db.Where("id IN ?", userIDs).Find(&list) for _, u := range list { users[u.ID] = u } } items := make([]AdminContentComment, 0, len(rows)) for _, r := range rows { it := AdminContentComment{ ID: r.ID, PostID: r.PostID, PostTitle: r.PostTitle, BoardID: r.BoardID, Content: r.Content, Status: r.Status, Edited: r.UpdatedAt.Sub(r.CreatedAt) > time.Minute, CreatedAt: r.CreatedAt, Board: boards[r.BoardID], User: users[r.UserID], } if r.DeletedAt.Valid { it.Deleted = true t := r.DeletedAt.Time it.DeletedAt = &t } items = append(items, it) } return items, total, nil } func (s *ModerationService) loadPostUnscoped(actor *Actor, id uint) (*model.Post, error) { var post model.Post if err := s.db.Unscoped().First(&post, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrContentNotFound } return nil, err } if !actor.CanModerateBoard(post.BoardID) { return nil, ErrModerationForbidden } return &post, nil } func (s *ModerationService) loadCommentUnscoped(actor *Actor, id uint) (*model.Comment, uint, error) { var cm model.Comment if err := s.db.Unscoped().First(&cm, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, 0, ErrContentNotFound } return nil, 0, err } var post model.Post if err := s.db.Unscoped().Select("id", "board_id").First(&post, cm.PostID).Error; err != nil { return nil, 0, ErrContentNotFound } if !actor.CanModerateBoard(post.BoardID) { return nil, 0, ErrModerationForbidden } return &cm, post.BoardID, nil } // SoftDeletePost 软删帖子(未删除时);须选类型,「其他」才须填理由,并通知作者 func (s *ModerationService) SoftDeletePost(actor *Actor, id uint, deleteType, deleteReason string) (authorID uint, err error) { post, err := s.loadPostUnscoped(actor, id) if err != nil { return 0, err } if err := s.assertCanModerateAuthor(actor, post.UserID); err != nil { return 0, err } if post.DeletedAt.Valid { return 0, errors.New("帖子已在回收站") } deleteType = strings.TrimSpace(deleteType) deleteReason = strings.TrimSpace(deleteReason) if !model.ValidPostDeleteType(deleteType) { return 0, errors.New("无效的删除类型") } if deleteType == model.PostDeleteTypeOther { if deleteReason == "" { return 0, errors.New("请填写删除理由") } if len([]rune(deleteReason)) > 200 { return 0, errors.New("删除理由不能超过 200 字") } } else { deleteReason = "" } updates := map[string]interface{}{ "delete_type": deleteType, "delete_reason": deleteReason, "deleted_by": actor.ID, } // UpdateColumns:删除元数据非内容编辑,不刷新 updated_at(详情页「更新于」由该字段推导) if err := s.db.Model(post).UpdateColumns(updates).Error; err != nil { return 0, err } if err := s.db.Delete(post).Error; err != nil { return 0, err } return post.UserID, nil } // RestorePost 从回收站恢复帖子 func (s *ModerationService) RestorePost(actor *Actor, id uint) error { post, err := s.loadPostUnscoped(actor, id) if err != nil { return err } if err := s.assertCanModerateAuthor(actor, post.UserID); err != nil { return err } if !post.DeletedAt.Valid { return errors.New("帖子未被删除") } // UpdateColumn:恢复非内容编辑,不刷新 updated_at return s.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).UpdateColumn("deleted_at", nil).Error } // PurgePost 彻底删除已软删帖子及其评论(仅站长) func (s *ModerationService) PurgePost(actor *Actor, id uint) error { if !actor.CanPurgeContent() { return ErrPurgeOwnerOnly } post, err := s.loadPostUnscoped(actor, id) if err != nil { return err } if !post.DeletedAt.Valid { return ErrContentNotDeleted } return s.db.Transaction(func(tx *gorm.DB) error { if err := tx.Unscoped().Where("post_id = ?", id).Delete(&model.PostEditHistory{}).Error; err != nil { return err } if err := tx.Unscoped().Where("post_id = ?", id).Delete(&model.Comment{}).Error; err != nil { return err } return tx.Unscoped().Delete(&model.Post{}, id).Error }) } // SoftDeleteComment 软删单条评论(不级联子树);须选类型,「其他」才须填理由 func (s *ModerationService) SoftDeleteComment(actor *Actor, id uint, deleteType, deleteReason string) error { cm, _, err := s.loadCommentUnscoped(actor, id) if err != nil { return err } if err := s.assertCanModerateAuthor(actor, cm.UserID); err != nil { return err } if cm.DeletedAt.Valid { return errors.New("评论已在回收站") } deleteType = strings.TrimSpace(deleteType) deleteReason = strings.TrimSpace(deleteReason) if !model.ValidPostDeleteType(deleteType) { return errors.New("无效的删除类型") } if deleteType == model.PostDeleteTypeOther { if deleteReason == "" { return errors.New("请填写删除理由") } if len([]rune(deleteReason)) > 200 { return errors.New("删除理由不能超过 200 字") } } else { deleteReason = "" } updates := map[string]interface{}{ "delete_type": deleteType, "delete_reason": deleteReason, "deleted_by": actor.ID, } // UpdateColumns:删除元数据非内容编辑,不刷新 updated_at(该字段用于 edited 判定) if err := s.db.Model(cm).UpdateColumns(updates).Error; err != nil { return err } if err := s.db.Delete(cm).Error; err != nil { return err } if cm.Status == model.ContentStatusPublished { s.db.Model(&model.Post{}).Where("id = ?", cm.PostID). UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - 1, 0)")) } return nil } // RestoreComment 恢复单条评论,并清空删除元数据 func (s *ModerationService) RestoreComment(actor *Actor, id uint) error { cm, _, err := s.loadCommentUnscoped(actor, id) if err != nil { return err } if err := s.assertCanModerateAuthor(actor, cm.UserID); err != nil { return err } if !cm.DeletedAt.Valid { return errors.New("评论未被删除") } updates := map[string]interface{}{ "deleted_at": nil, "delete_type": "", "delete_reason": "", "deleted_by": 0, } // UpdateColumns:恢复非内容编辑,不刷新 updated_at(该字段用于 edited 判定) if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", id).UpdateColumns(updates).Error; err != nil { return err } if cm.Status == model.ContentStatusPublished { s.db.Model(&model.Post{}).Where("id = ?", cm.PostID). UpdateColumn("comment_count", gorm.Expr("comment_count + 1")) } return nil } // PurgeComment 彻底删除已软删评论(仅站长) func (s *ModerationService) PurgeComment(actor *Actor, id uint) error { if !actor.CanPurgeContent() { return ErrPurgeOwnerOnly } cm, _, err := s.loadCommentUnscoped(actor, id) if err != nil { return err } if !cm.DeletedAt.Valid { return ErrContentNotDeleted } return s.db.Transaction(func(tx *gorm.DB) error { if err := tx.Where("comment_id = ?", id).Delete(&model.CommentEditHistory{}).Error; err != nil { return err } return tx.Unscoped().Delete(&model.Comment{}, id).Error }) }