feat(moderation): add manual content review system with three-tier AI moderation
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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user