Files
jiang13-bbs/backend/service/admin_content.go

617 lines
19 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("内容不存在")
ErrPinOrderForbidden = 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"`
Floor uint `json:"floor"` // 所属楼层相对序号
IsRoot bool `json:"is_root"` // 主楼/楼中楼,供前台拼 #comment-{floor}[-r{id}]
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))
commentIDs := make([]uint, 0, len(rows))
for _, r := range rows {
commentIDs = append(commentIDs, r.ID)
}
anchors := CommentAnchors(s.db, commentIDs)
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 a, ok := anchors[r.ID]; ok {
it.Floor = a.Floor
it.IsRoot = a.IsRoot
}
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
})
}
// ===== 置顶顺序配置(后台可拖拽排序,同层级 pin_order 越小越靠前) =====
// PinnedPostItem 置顶帖排序列表项
type PinnedPostItem struct {
ID uint `json:"id"`
Title string `json:"title"`
Pinned int `json:"pinned"`
PinOrder int `json:"pin_order"`
BoardID uint `json:"board_id"`
BoardName string `json:"board_name"`
CreatedAt time.Time `json:"created_at"`
}
// assertPinScopePermission 校验 actor 对指定置顶层级/板块的排序权限:
// 全局置顶仅管理员及以上;板块置顶允许管理员或管辖该板块的板管(board_id 必填)。
func (s *ModerationService) assertPinScopePermission(actor *Actor, level int, boardID uint) error {
if actor == nil {
return ErrPinOrderForbidden
}
if model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin) {
return nil
}
if level == model.PinGlobal {
return ErrPinOrderForbidden
}
if level == model.PinBoard {
if boardID == 0 {
return ErrPinOrderForbidden // 板管必须指定具体板块
}
if actor.CanModerateBoard(boardID) {
return nil
}
}
return ErrPinOrderForbidden
}
// ListPinnedPosts 列出指定层级的置顶帖(当前顺序:pin_order 升序,未配置按发帖时间倒序兜底)。
// level=2 全站置顶;level=1 需带 board_id(管理员可省=全部板块的板块置顶)。
func (s *ModerationService) ListPinnedPosts(actor *Actor, level int, boardID uint) ([]PinnedPostItem, error) {
if err := s.assertPinScopePermission(actor, level, boardID); err != nil {
return nil, err
}
q := s.db.Model(&model.Post{}).
Where("pinned = ? AND deleted_at IS NULL AND status = ?", level, model.ContentStatusPublished)
if boardID > 0 {
q = q.Where("board_id = ?", boardID)
}
var posts []model.Post
if err := q.Order("(pin_order = 0), pin_order ASC, created_at DESC").
Preload("Board").Find(&posts).Error; err != nil {
return nil, err
}
items := make([]PinnedPostItem, 0, len(posts))
for _, p := range posts {
items = append(items, PinnedPostItem{
ID: p.ID,
Title: p.Title,
Pinned: p.Pinned,
PinOrder: p.PinOrder,
BoardID: p.BoardID,
BoardName: p.Board.Name,
CreatedAt: p.CreatedAt,
})
}
return items, nil
}
// SavePinOrder 按传入 ID 顺序重写 pin_order(从 1 递增)。
// 安全约束:ids 必须全部属于该层级且在权限范围内,否则整体拒绝,避免半截写入。
func (s *ModerationService) SavePinOrder(actor *Actor, level int, boardID uint, ids []uint) error {
if err := s.assertPinScopePermission(actor, level, boardID); err != nil {
return err
}
if len(ids) == 0 {
return nil
}
if len(ids) > 200 {
return errors.New("单次最多排序 200 条")
}
var count int64
q := s.db.Model(&model.Post{}).
Where("id IN ? AND pinned = ? AND deleted_at IS NULL", ids, level)
if boardID > 0 {
q = q.Where("board_id = ?", boardID)
}
if err := q.Count(&count).Error; err != nil {
return err
}
if count != int64(len(ids)) {
return ErrPinOrderForbidden // 含越权/已取消置顶/已删除的 ID
}
return s.db.Transaction(func(tx *gorm.DB) error {
for i, id := range ids {
if err := tx.Model(&model.Post{}).Where("id = ?", id).
UpdateColumn("pin_order", i+1).Error; err != nil {
return err
}
}
return nil
})
}