feat(admin): 完善站点设置、内容回收站与后台菜单分组

新增站点设置与内容管理(含已删除恢复/清除),用户页改用独有运营指标,并按概览/内容/成员与经济/站点重组侧栏。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 05:56:27 +08:00
parent be47b38370
commit dd7bd010b8
25 changed files with 2193 additions and 323 deletions

View File

@@ -0,0 +1,395 @@
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
}