feat(moderation): add manual content review system with three-tier AI moderation
All checks were successful
Build Backend / build (push) Successful in 2m43s
Build Backend / build-docker (push) Successful in 1m22s

Add admin comment moderation endpoints (single and batch) and introduce a three-tier moderation system (pass/review/block) for content flagged by AI. Content with unclear violations is now held for manual review instead of being auto-rejected. Deleted the legacy audit_service.go in favor of the unified hook-based moderation system.
This commit is contained in:
lafay
2026-04-11 04:23:24 +08:00
parent 6af6aaa9d0
commit 1bb82e1e2b
17 changed files with 432 additions and 732 deletions

View File

@@ -11,6 +11,7 @@ import (
"carrot_bbs/internal/cache"
"carrot_bbs/internal/dto"
"carrot_bbs/internal/model"
"carrot_bbs/internal/pkg/hook"
"carrot_bbs/internal/repository"
)
@@ -21,15 +22,18 @@ type VoteService struct {
cache cache.Cache
postAIService *PostAIService
systemMessageService SystemMessageService
hookManager *hook.Manager
logService *LogService
}
// NewVoteService 创建投票服务
func NewVoteService(
voteRepo repository.VoteRepository,
postRepo repository.PostRepository,
cache cache.Cache,
postAIService *PostAIService,
systemMessageService SystemMessageService,
hookManager *hook.Manager,
logService *LogService,
) *VoteService {
return &VoteService{
voteRepo: voteRepo,
@@ -37,6 +41,8 @@ func NewVoteService(
cache: cache,
postAIService: postAIService,
systemMessageService: systemMessageService,
hookManager: hookManager,
logService: logService,
}
}
@@ -94,33 +100,60 @@ func (s *VoteService) reviewVotePostAsync(postID, userID, title, content string,
}
}()
if s.postAIService == nil || !s.postAIService.IsEnabled() {
if s.hookManager == nil {
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); err != nil {
log.Printf("[WARN] Failed to publish vote post without AI moderation: %v", err)
log.Printf("[WARN] Failed to publish vote post without hook manager: %v", err)
} else {
cache.InvalidatePostDetail(s.cache, postID)
cache.InvalidatePostList(s.cache)
}
return
}
err := s.postAIService.ModeratePost(context.Background(), title, content, images)
if err != nil {
var rejectedErr *PostModerationRejectedError
if errors.As(err, &rejectedErr) {
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusRejected, rejectedErr.UserMessage(), "ai"); updateErr != nil {
log.Printf("[WARN] Failed to reject vote post %s: %v", postID, updateErr)
}
s.notifyModerationRejected(userID, rejectedErr.Reason)
var authorID uint
fmt.Sscanf(userID, "%d", &authorID)
result := &hook.ModerationResult{}
s.hookManager.TriggerWithMetadata(context.Background(), hook.HookPostPreModerate, authorID, &hook.PostModerateHookData{
PostID: postID,
Title: title,
Content: content,
Images: images,
AuthorID: authorID,
}, map[string]any{
"result": result,
})
s.hookManager.Trigger(context.Background(), hook.HookPostModerated, authorID, &hook.PostModeratedHookData{
PostID: postID,
AuthorID: authorID,
Approved: result.Approved,
RejectReason: result.RejectReason,
ReviewedBy: result.ReviewedBy,
})
if !result.Approved {
if result.NeedsReview {
cache.InvalidatePostDetail(s.cache, postID)
cache.InvalidatePostList(s.cache)
return
}
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); updateErr != nil {
log.Printf("[WARN] Failed to publish vote post %s after moderation error: %v", postID, updateErr)
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusRejected, result.RejectReason, result.ReviewedBy); updateErr != nil {
log.Printf("[WARN] Failed to reject vote post %s: %v", postID, updateErr)
} else {
cache.InvalidatePostDetail(s.cache, postID)
cache.InvalidatePostList(s.cache)
}
s.notifyModerationRejected(userID, result.RejectReason)
return
}
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "ai"); err != nil {
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", result.ReviewedBy); err != nil {
log.Printf("[WARN] Failed to publish vote post %s: %v", postID, err)
return
}
cache.InvalidatePostDetail(s.cache, postID)
cache.InvalidatePostList(s.cache)
}
func (s *VoteService) updateModerationStatusWithRetry(postID string, status model.PostStatus, rejectReason string, reviewedBy string) error {
@@ -271,18 +304,18 @@ func (s *VoteService) convertToPostResponse(post *model.Post, currentUserID stri
}
response := &dto.PostResponse{
ID: post.ID,
UserID: post.UserID,
Title: post.Title,
Content: post.Content,
LikesCount: post.LikesCount,
CommentsCount: post.CommentsCount,
FavoritesCount: post.FavoritesCount,
SharesCount: post.SharesCount,
ViewsCount: post.ViewsCount,
IsPinned: post.IsPinned,
IsLocked: post.IsLocked,
IsVote: post.IsVote,
ID: post.ID,
UserID: post.UserID,
Title: post.Title,
Content: post.Content,
LikesCount: post.LikesCount,
CommentsCount: post.CommentsCount,
FavoritesCount: post.FavoritesCount,
SharesCount: post.SharesCount,
ViewsCount: post.ViewsCount,
IsPinned: post.IsPinned,
IsLocked: post.IsLocked,
IsVote: post.IsVote,
CreatedAt: dto.FormatTime(post.CreatedAt),
UpdatedAt: dto.FormatTime(post.UpdatedAt),
ContentEditedAt: dto.FormatTimePointer(post.ContentEditedAt),