Files
jiang13-bbs/backend/service/moderation.go
freefire a7b6421840 feat: 实现完整的板块级RBAC内容审核系统
- 新增站长/超级管理员/管理员/板块管理员四级角色体系
- 实现实时权限快照加载与细粒度权限校验
- 新增内容审核队列与前后端页面
- 重构用户权限管理与站点管理逻辑
- 新增评论/帖子审核状态与通知推送
- 优化前端权限控制与角色徽章展示
- 修正数据库迁移与默认值问题
2026-09-15 04:47:34 +08:00

278 lines
8.3 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"
"time"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// 内容审核相关错误
var (
ErrModerationForbidden = errors.New("无权审核该板块的内容")
ErrNotPending = errors.New("该内容不在待审核状态")
)
// ModerationService 帖子/评论审核队列
type ModerationService struct {
db *gorm.DB
notif *NotificationService
}
func NewModerationService(db *gorm.DB, notif *NotificationService) *ModerationService {
return &ModerationService{db: db, notif: notif}
}
// PendingCommentItem 待审评论列表项(带上所属帖子与板块,便于按板块隔离与展示)
type PendingCommentItem struct {
ID uint `json:"id"`
PostID uint `json:"post_id"`
PostTitle string `json:"post_title"`
BoardID uint `json:"board_id"`
Board model.Board `json:"board"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
User model.User `json:"user"`
}
// boardScope 板块管理员只能看到/操作被授权板块;管理员及以上不限
func boardScope(q *gorm.DB, actor *Actor) *gorm.DB {
if actor.Role == model.RoleBoardAdmin {
return q.Where("board_id IN ?", actor.BoardIDs)
}
return q
}
// PendingPosts 待审核帖子分页
func (s *ModerationService) PendingPosts(actor *Actor, page, size int) ([]model.Post, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 15
}
q := s.db.Model(&model.Post{}).Where("status = ?", model.ContentStatusPending)
q = boardScope(q, actor)
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var posts []model.Post
if err := q.Order("created_at ASC").
Offset((page - 1) * size).Limit(size).
Preload("Board").Preload("User").
Find(&posts).Error; err != nil {
return nil, 0, err
}
return posts, total, nil
}
// PendingComments 待审核评论分页(JOIN 帖子取板块与标题)
func (s *ModerationService) PendingComments(actor *Actor, page, size int) ([]PendingCommentItem, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 15
}
base := s.db.Table("comments").
Joins("JOIN posts ON posts.id = comments.post_id").
Where("comments.status = ? AND comments.deleted_at IS NULL AND posts.deleted_at IS NULL",
model.ContentStatusPending)
if actor.Role == model.RoleBoardAdmin {
base = base.Where("posts.board_id IN ?", actor.BoardIDs)
}
var total int64
if err := base.Count(&total).Error; err != nil {
return nil, 0, err
}
type row struct {
ID uint
PostID uint
PostTitle string
BoardID uint
Content string
CreatedAt time.Time
UserID uint
}
var rows []row
if err := base.Select("comments.id, comments.post_id, posts.title AS post_title, posts.board_id, " +
"comments.content, comments.created_at, comments.user_id").
Order("comments.created_at ASC").
Offset((page - 1) * size).Limit(size).
Scan(&rows).Error; err != nil {
return nil, 0, err
}
items := make([]PendingCommentItem, 0, len(rows))
if len(rows) > 0 {
userIDs := map[uint]bool{}
boardIDs := map[uint]bool{}
for _, r := range rows {
userIDs[r.UserID] = true
if r.BoardID > 0 {
boardIDs[r.BoardID] = true
}
}
var users []model.User
s.db.Where("id IN ?", keys(userIDs)).Find(&users)
userMap := map[uint]model.User{}
for _, u := range users {
userMap[u.ID] = u
}
var boards []model.Board
s.db.Where("id IN ?", keys(boardIDs)).Find(&boards)
boardMap := map[uint]model.Board{}
for _, b := range boards {
boardMap[b.ID] = b
}
for _, r := range rows {
items = append(items, PendingCommentItem{
ID: r.ID,
PostID: r.PostID,
PostTitle: r.PostTitle,
BoardID: r.BoardID,
Board: boardMap[r.BoardID],
Content: r.Content,
CreatedAt: r.CreatedAt,
User: userMap[r.UserID],
})
}
}
return items, total, nil
}
// PendingCounts 当前操作者可见的待审数量(导航角标)
func (s *ModerationService) PendingCounts(actor *Actor) (posts int64, comments int64) {
q := s.db.Model(&model.Post{}).Where("status = ?", model.ContentStatusPending)
boardScope(q, actor).Count(&posts)
cq := s.db.Table("comments").
Joins("JOIN posts ON posts.id = comments.post_id").
Where("comments.status = ? AND comments.deleted_at IS NULL AND posts.deleted_at IS NULL",
model.ContentStatusPending)
if actor.Role == model.RoleBoardAdmin {
cq = cq.Where("posts.board_id IN ?", actor.BoardIDs)
}
cq.Count(&comments)
return
}
// ApprovePost 帖子审核通过
func (s *ModerationService) ApprovePost(actor *Actor, id uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var post model.Post
if err := tx.First(&post, id).Error; err != nil {
return err
}
if !actor.CanModerateBoard(post.BoardID) {
return ErrModerationForbidden
}
if post.Status != model.ContentStatusPending {
return ErrNotPending
}
if err := tx.Model(&post).Update("status", model.ContentStatusPublished).Error; err != nil {
return err
}
s.notif.Create(post.UserID, actor.ID, model.NotificationTypeApproved, post.ID, 0,
"你的帖子《"+post.Title+"》已通过审核")
return nil
})
}
// RejectPost 帖子审核拒绝(标记 rejected,不物理删除;作者仍可在详情页看到结果)
func (s *ModerationService) RejectPost(actor *Actor, id uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var post model.Post
if err := tx.First(&post, id).Error; err != nil {
return err
}
if !actor.CanModerateBoard(post.BoardID) {
return ErrModerationForbidden
}
if post.Status != model.ContentStatusPending {
return ErrNotPending
}
if err := tx.Model(&post).Update("status", model.ContentStatusRejected).Error; err != nil {
return err
}
s.notif.Create(post.UserID, actor.ID, model.NotificationTypeRejected, post.ID, 0,
"你的帖子《"+post.Title+"》未通过审核")
return nil
})
}
// ApproveComment 评论审核通过(通过时才计入帖子评论数)
func (s *ModerationService) ApproveComment(actor *Actor, id uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var cm model.Comment
if err := tx.First(&cm, id).Error; err != nil {
return err
}
var post model.Post
if err := tx.Select("id", "board_id", "title").First(&post, cm.PostID).Error; err != nil {
return err
}
if !actor.CanModerateBoard(post.BoardID) {
return ErrModerationForbidden
}
if cm.Status != model.ContentStatusPending {
return ErrNotPending
}
if err := tx.Model(&cm).Update("status", model.ContentStatusPublished).Error; err != nil {
return err
}
if err := tx.Model(&model.Post{}).Where("id = ?", cm.PostID).
UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error; err != nil {
return err
}
s.notif.Create(cm.UserID, actor.ID, model.NotificationTypeApproved, cm.PostID, cm.ID,
"你在帖子《"+post.Title+"》下的评论已通过审核")
// 评论此时才公开,补发送业务通知(以评论者为 actor):
// 主评论通知帖子作者;子回复通知父评论作者
if cm.ParentID == nil {
s.notif.Create(post.UserID, cm.UserID, model.NotificationTypeComment, cm.PostID, cm.ID, cm.Content)
} else {
var parent model.Comment
if err := tx.Select("user_id").First(&parent, *cm.ParentID).Error; err == nil {
s.notif.Create(parent.UserID, cm.UserID, model.NotificationTypeReply, cm.PostID, cm.ID, cm.Content)
}
}
return nil
})
}
// RejectComment 评论审核拒绝
func (s *ModerationService) RejectComment(actor *Actor, id uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var cm model.Comment
if err := tx.First(&cm, id).Error; err != nil {
return err
}
var post model.Post
if err := tx.Select("id", "board_id", "title").First(&post, cm.PostID).Error; err != nil {
return err
}
if !actor.CanModerateBoard(post.BoardID) {
return ErrModerationForbidden
}
if cm.Status != model.ContentStatusPending {
return ErrNotPending
}
if err := tx.Model(&cm).Update("status", model.ContentStatusRejected).Error; err != nil {
return err
}
s.notif.Create(cm.UserID, actor.ID, model.NotificationTypeRejected, cm.PostID, cm.ID,
"你在帖子《"+post.Title+"》下的评论未通过审核")
return nil
})
}
func keys(m map[uint]bool) []uint {
out := make([]uint, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}