Files
jiang13-bbs/backend/service/moderation.go
freefire 5270fee2b7 feat: 评论/帖子编辑历史与编辑标记,内容锁定
- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal
- 新增 PostEditedMark 编辑标记展示
- 新增内容锁定机制(content_lock)防止并发编辑冲突
- 审核与通知服务适配
2026-09-25 23:41:45 +08:00

316 lines
10 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("该内容不在待审核状态")
ErrAuthorProtected = errors.New("无权处置该作者的内容")
ErrPurgeOwnerOnly = 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 帖子审核通过;成功时返回板块 ID 供 feed 广播
func (s *ModerationService) ApprovePost(actor *Actor, id uint) (boardID uint, err error) {
err = 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
}
updates := map[string]interface{}{
"status": model.ContentStatusPublished,
}
// 悬赏/抽奖:首次公开发布时补写 ends_at
if raw, ok := EnsureDeadlineOnPublish(post.TypeMeta, post.PostType, time.Now().UTC()); ok {
updates["type_meta"] = raw
}
// UpdateColumns:审核非内容编辑,不刷新 updated_at(详情页「更新于」由该字段推导)
if err := tx.Model(&post).UpdateColumns(updates).Error; err != nil {
return err
}
boardID = post.BoardID
s.notif.Create(post.UserID, actor.ID, model.NotificationTypeApproved, post.ID, 0,
"你的帖子《"+post.Title+"》已通过审核")
return nil
})
return boardID, err
}
// 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
}
// UpdateColumn:审核非内容编辑,不刷新 updated_at
if err := tx.Model(&post).UpdateColumn("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 评论审核通过(通过时才计入帖子评论数);成功时返回帖子 ID 与板块 ID
func (s *ModerationService) ApproveComment(actor *Actor, id uint) (postID, boardID uint, err error) {
err = 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", "user_id").First(&post, cm.PostID).Error; err != nil {
return err
}
if !actor.CanModerateBoard(post.BoardID) {
return ErrModerationForbidden
}
if cm.Status != model.ContentStatusPending {
return ErrNotPending
}
// UpdateColumn:审核非内容编辑,不刷新评论 updated_at(该字段用于 edited 判定)
if err := tx.Model(&cm).UpdateColumn("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
}
postID = cm.PostID
boardID = post.BoardID
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
})
return postID, boardID, err
}
// 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
}
// UpdateColumn:审核非内容编辑,不刷新评论 updated_at(该字段用于 edited 判定)
if err := tx.Model(&cm).UpdateColumn("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
})
}
// UserHasApprovedContent 用户是否已有至少一篇已发布帖子或一条已发布评论(即曾通过审核或免审直发)
func (s *ModerationService) UserHasApprovedContent(userID uint) (bool, error) {
var n int64
if err := s.db.Model(&model.Post{}).
Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished).
Limit(1).Count(&n).Error; err != nil {
return false, err
}
if n > 0 {
return true, nil
}
n = 0
if err := s.db.Model(&model.Comment{}).
Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished).
Limit(1).Count(&n).Error; err != nil {
return false, err
}
return n > 0, nil
}
func keys(m map[uint]bool) []uint {
out := make([]uint, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}