Files
jiang13-bbs/backend/service/admin_content.go
freefire c6fdbb7193 feat(comments): 软删占位、理由与恢复,管理删仅对访客打码
自删/管理删差异文案与理由可见性,评论者与管理员免马赛克,并支持恢复与处理人链接。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 07:43:48 +08:00

447 lines
13 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 service
import (
"errors"
"strings"
"time"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// 内容库管理错误
var (
ErrContentNotDeleted = errors.New("仅已删除内容可彻底清除")
ErrContentNotFound = errors.New("内容不存在")
)
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"`
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
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.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,
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 post.DeletedAt.Valid {
return 0, errors.New("帖子已在回收站")
}
deleteType = strings.TrimSpace(deleteType)
deleteReason = strings.TrimSpace(deleteReason)
if !model.ValidPostDeleteType(deleteType) {
return 0, errors.New("无效的删除类型")
}
if deleteReason == "" {
return 0, errors.New("请填写删除理由")
}
if len([]rune(deleteReason)) > 200 {
return 0, errors.New("删除理由不能超过 200 字")
}
updates := map[string]interface{}{
"delete_type": deleteType,
"delete_reason": deleteReason,
"deleted_by": actor.ID,
}
if err := s.db.Model(post).Updates(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 !post.DeletedAt.Valid {
return errors.New("帖子未被删除")
}
return s.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).Update("deleted_at", nil).Error
}
// PurgePost 彻底删除已软删帖子及其评论
func (s *ModerationService) PurgePost(actor *Actor, id uint) error {
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.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 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,
}
if err := s.db.Model(cm).Updates(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 !cm.DeletedAt.Valid {
return errors.New("评论未被删除")
}
updates := map[string]interface{}{
"deleted_at": nil,
"delete_type": "",
"delete_reason": "",
"deleted_by": 0,
}
if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", id).Updates(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 {
cm, _, err := s.loadCommentUnscoped(actor, id)
if err != nil {
return err
}
if !cm.DeletedAt.Valid {
return ErrContentNotDeleted
}
return s.db.Unscoped().Delete(&model.Comment{}, id).Error
}