Files
jiang13-bbs/backend/service/admin_content.go
freefire dd7bd010b8 feat(admin): 完善站点设置、内容回收站与后台菜单分组
新增站点设置与内容管理(含已删除恢复/清除),用户页改用独有运营指标,并按概览/内容/成员与经济/站点重组侧栏。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 05:56:27 +08:00

396 lines
12 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) error {
post, err := s.loadPostUnscoped(actor, id)
if err != nil {
return err
}
if post.DeletedAt.Valid {
return errors.New("帖子已在回收站")
}
return s.db.Delete(post).Error
}
// 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) error {
cm, _, err := s.loadCommentUnscoped(actor, id)
if err != nil {
return err
}
if cm.DeletedAt.Valid {
return errors.New("评论已在回收站")
}
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("评论未被删除")
}
if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", id).Update("deleted_at", nil).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
}