493 lines
15 KiB
Go
493 lines
15 KiB
Go
package service
|
||
|
||
import (
|
||
"errors"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/freefire/jiang13-bbs/model"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// 内容库管理错误
|
||
var (
|
||
ErrContentNotDeleted = errors.New("仅已删除内容可彻底清除")
|
||
ErrContentNotFound = 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"`
|
||
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 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,
|
||
}
|
||
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 err := s.assertCanModerateAuthor(actor, post.UserID); 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 {
|
||
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.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,
|
||
}
|
||
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 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,
|
||
}
|
||
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 {
|
||
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
|
||
})
|
||
}
|