feat(admin): 完善站点设置、内容回收站与后台菜单分组
新增站点设置与内容管理(含已删除恢复/清除),用户页改用独有运营指标,并按概览/内容/成员与经济/站点重组侧栏。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
395
backend/service/admin_content.go
Normal file
395
backend/service/admin_content.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user