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,15 +11,12 @@ import (
|
||||
|
||||
// AdminCommentService 管理端评论服务接口
|
||||
type AdminCommentService interface {
|
||||
// GetCommentList 获取评论列表(分页、搜索、状态筛选)
|
||||
GetCommentList(ctx context.Context, page, pageSize int, keyword, postID, authorID, status, startDate, endDate string) ([]dto.AdminCommentListResponse, int64, error)
|
||||
// GetCommentDetail 获取评论详情
|
||||
GetCommentDetail(ctx context.Context, commentID string) (*dto.AdminCommentDetailResponse, error)
|
||||
// DeleteComment 删除评论
|
||||
DeleteComment(ctx context.Context, commentID string) error
|
||||
// BatchDeleteComments 批量删除评论
|
||||
BatchDeleteComments(ctx context.Context, ids []string) (*dto.AdminBatchOperationResponse, error)
|
||||
// GetCommentsByPostID 获取帖子的评论列表
|
||||
ModerateComment(ctx context.Context, commentID string, status model.CommentStatus, reason, reviewedBy string) (*dto.AdminCommentDetailResponse, error)
|
||||
BatchModerateComments(ctx context.Context, ids []string, status model.CommentStatus, reviewedBy string) (*dto.AdminBatchOperationResponse, error)
|
||||
GetCommentsByPostID(ctx context.Context, postID string, page, pageSize int) ([]dto.AdminCommentListResponse, int64, error)
|
||||
}
|
||||
|
||||
@@ -103,6 +100,54 @@ func (s *adminCommentServiceImpl) BatchDeleteComments(ctx context.Context, ids [
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *adminCommentServiceImpl) ModerateComment(ctx context.Context, commentID string, status model.CommentStatus, reason, reviewedBy string) (*dto.AdminCommentDetailResponse, error) {
|
||||
_, err := s.commentRepo.GetAdminCommentByID(commentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.commentRepo.UpdateModerationStatus(commentID, status, reason, reviewedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
comment, err := s.commentRepo.GetAdminCommentByID(commentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.convertCommentToAdminDetailResponse(comment), nil
|
||||
}
|
||||
|
||||
func (s *adminCommentServiceImpl) BatchModerateComments(ctx context.Context, ids []string, status model.CommentStatus, reviewedBy string) (*dto.AdminBatchOperationResponse, error) {
|
||||
var failedIDs []string
|
||||
for _, id := range ids {
|
||||
if err := s.commentRepo.UpdateModerationStatus(id, status, "", reviewedBy); err != nil {
|
||||
failedIDs = append(failedIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
if len(failedIDs) == 0 {
|
||||
return &dto.AdminBatchOperationResponse{
|
||||
Success: true,
|
||||
Message: "批量审核成功",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if len(failedIDs) == len(ids) {
|
||||
return &dto.AdminBatchOperationResponse{
|
||||
Success: false,
|
||||
Message: "批量审核失败",
|
||||
Failed: failedIDs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &dto.AdminBatchOperationResponse{
|
||||
Success: true,
|
||||
Message: "部分评论审核成功",
|
||||
Failed: failedIDs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCommentsByPostID 获取帖子的评论列表
|
||||
func (s *adminCommentServiceImpl) GetCommentsByPostID(ctx context.Context, postID string, page, pageSize int) ([]dto.AdminCommentListResponse, int64, error) {
|
||||
comments, total, err := s.commentRepo.GetCommentsByPostIDForAdmin(postID, page, pageSize)
|
||||
@@ -160,6 +205,9 @@ func (s *adminCommentServiceImpl) convertCommentToAdminListResponse(comment *mod
|
||||
Content: comment.Content,
|
||||
Images: images,
|
||||
Status: string(comment.Status),
|
||||
RejectReason: comment.RejectReason,
|
||||
ReviewedAt: dto.FormatTimePointer(comment.ReviewedAt),
|
||||
ReviewedBy: comment.ReviewedBy,
|
||||
LikesCount: comment.LikesCount,
|
||||
RepliesCount: comment.RepliesCount,
|
||||
CreatedAt: comment.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
@@ -211,6 +259,9 @@ func (s *adminCommentServiceImpl) convertCommentToAdminDetailResponse(comment *m
|
||||
Content: comment.Content,
|
||||
Images: images,
|
||||
Status: string(comment.Status),
|
||||
RejectReason: comment.RejectReason,
|
||||
ReviewedAt: dto.FormatTimePointer(comment.ReviewedAt),
|
||||
ReviewedBy: comment.ReviewedBy,
|
||||
LikesCount: comment.LikesCount,
|
||||
RepliesCount: comment.RepliesCount,
|
||||
CreatedAt: comment.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
|
||||
@@ -1,597 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/openai"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ==================== 内容审核服务接口和实现 ====================
|
||||
|
||||
// AuditResult 审核结果
|
||||
type AuditResult struct {
|
||||
Pass bool `json:"pass"` // 是否通过
|
||||
Risk string `json:"risk"` // 风险等级: low, medium, high
|
||||
Labels []string `json:"labels"` // 标签列表
|
||||
Suggest string `json:"suggest"` // 建议: pass, review, block
|
||||
Detail string `json:"detail"` // 详细说明
|
||||
Provider string `json:"provider"` // 服务提供商
|
||||
}
|
||||
|
||||
// AuditService 内容审核服务接口
|
||||
type AuditService interface {
|
||||
// AuditText 审核文本
|
||||
AuditText(ctx context.Context, text string, auditType string) (*AuditResult, error)
|
||||
// AuditImage 审核图片
|
||||
AuditImage(ctx context.Context, imageURL string) (*AuditResult, error)
|
||||
// AuditPost 审核帖子
|
||||
AuditPost(ctx context.Context, title, content string, images []string) (*AuditResult, error)
|
||||
// AuditComment 审核评论
|
||||
AuditComment(ctx context.Context, content string, images []string) (*AuditResult, error)
|
||||
// GetAuditResult 获取审核结果
|
||||
GetAuditResult(ctx context.Context, auditID string) (*AuditResult, error)
|
||||
// IsEnabled 检查审核服务是否启用
|
||||
IsEnabled() bool
|
||||
}
|
||||
|
||||
// auditServiceImpl 内容审核服务实现
|
||||
type auditServiceImpl struct {
|
||||
db *gorm.DB
|
||||
openaiClient openai.Client
|
||||
config *AuditConfig
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// AuditConfig 内容审核服务配置
|
||||
type AuditConfig struct {
|
||||
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
||||
StrictModeration bool `mapstructure:"strict_moderation" yaml:"strict_moderation"` // 严格模式:审核失败时拒绝;非严格模式:审核失败时放行
|
||||
Timeout int `mapstructure:"timeout" yaml:"timeout"`
|
||||
}
|
||||
|
||||
// NewAuditService 创建内容审核服务
|
||||
func NewAuditService(db *gorm.DB, openaiClient openai.Client, config *AuditConfig) AuditService {
|
||||
return &auditServiceImpl{
|
||||
db: db,
|
||||
openaiClient: openaiClient,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled 检查审核服务是否启用
|
||||
func (s *auditServiceImpl) IsEnabled() bool {
|
||||
return s != nil && s.config != nil && s.config.Enabled && s.openaiClient != nil && s.openaiClient.IsEnabled()
|
||||
}
|
||||
|
||||
// AuditText 审核文本
|
||||
func (s *auditServiceImpl) AuditText(ctx context.Context, text string, auditType string) (*AuditResult, error) {
|
||||
if !s.IsEnabled() {
|
||||
return &AuditResult{
|
||||
Pass: true,
|
||||
Risk: "low",
|
||||
Suggest: "pass",
|
||||
Detail: "Audit service disabled",
|
||||
Provider: "ai",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
return &AuditResult{
|
||||
Pass: true,
|
||||
Risk: "low",
|
||||
Suggest: "pass",
|
||||
Detail: "Empty text",
|
||||
Provider: "ai",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 使用 AI 审核文本
|
||||
approved, reason, err := s.openaiClient.ModerateComment(ctx, text, nil)
|
||||
if err != nil {
|
||||
zap.L().Error("AI audit text error",
|
||||
zap.Error(err),
|
||||
)
|
||||
if s.config.StrictModeration {
|
||||
return &AuditResult{
|
||||
Pass: false,
|
||||
Risk: "high",
|
||||
Suggest: "review",
|
||||
Detail: fmt.Sprintf("Audit error: %v", err),
|
||||
Provider: "ai",
|
||||
}, err
|
||||
}
|
||||
// 非严格模式下,审核失败时放行
|
||||
return &AuditResult{
|
||||
Pass: true,
|
||||
Risk: "low",
|
||||
Suggest: "pass",
|
||||
Detail: fmt.Sprintf("Audit fallback pass due to error: %v", err),
|
||||
Provider: "ai",
|
||||
}, nil
|
||||
}
|
||||
|
||||
result := &AuditResult{
|
||||
Pass: approved,
|
||||
Provider: "ai",
|
||||
Detail: reason,
|
||||
}
|
||||
|
||||
if approved {
|
||||
result.Risk = "low"
|
||||
result.Suggest = "pass"
|
||||
} else {
|
||||
result.Risk = "high"
|
||||
result.Suggest = "block"
|
||||
result.Labels = []string{"ai_rejected"}
|
||||
}
|
||||
|
||||
// 记录审核日志
|
||||
go s.saveAuditLog(ctx, "text", text, "", auditType, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AuditImage 审核图片
|
||||
func (s *auditServiceImpl) AuditImage(ctx context.Context, imageURL string) (*AuditResult, error) {
|
||||
if !s.IsEnabled() {
|
||||
return &AuditResult{
|
||||
Pass: true,
|
||||
Risk: "low",
|
||||
Suggest: "pass",
|
||||
Detail: "Audit service disabled",
|
||||
Provider: "ai",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if imageURL == "" {
|
||||
return &AuditResult{
|
||||
Pass: true,
|
||||
Risk: "low",
|
||||
Suggest: "pass",
|
||||
Detail: "Empty image URL",
|
||||
Provider: "ai",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 使用 AI 审核图片
|
||||
approved, reason, err := s.openaiClient.ModerateComment(ctx, "", []string{imageURL})
|
||||
if err != nil {
|
||||
zap.L().Error("AI audit image error",
|
||||
zap.Error(err),
|
||||
)
|
||||
if s.config.StrictModeration {
|
||||
return &AuditResult{
|
||||
Pass: false,
|
||||
Risk: "high",
|
||||
Suggest: "review",
|
||||
Detail: fmt.Sprintf("Audit error: %v", err),
|
||||
Provider: "ai",
|
||||
}, err
|
||||
}
|
||||
// 非严格模式下,审核失败时放行
|
||||
return &AuditResult{
|
||||
Pass: true,
|
||||
Risk: "low",
|
||||
Suggest: "pass",
|
||||
Detail: fmt.Sprintf("Audit fallback pass due to error: %v", err),
|
||||
Provider: "ai",
|
||||
}, nil
|
||||
}
|
||||
|
||||
result := &AuditResult{
|
||||
Pass: approved,
|
||||
Provider: "ai",
|
||||
Detail: reason,
|
||||
}
|
||||
|
||||
if approved {
|
||||
result.Risk = "low"
|
||||
result.Suggest = "pass"
|
||||
} else {
|
||||
result.Risk = "high"
|
||||
result.Suggest = "block"
|
||||
result.Labels = []string{"ai_rejected"}
|
||||
}
|
||||
|
||||
// 记录审核日志
|
||||
go s.saveAuditLog(ctx, "image", "", imageURL, "image", result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AuditPost 审核帖子
|
||||
func (s *auditServiceImpl) AuditPost(ctx context.Context, title, content string, images []string) (*AuditResult, error) {
|
||||
if !s.IsEnabled() {
|
||||
return &AuditResult{
|
||||
Pass: true,
|
||||
Risk: "low",
|
||||
Suggest: "pass",
|
||||
Detail: "Audit service disabled",
|
||||
Provider: "ai",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 使用 AI 审核帖子
|
||||
approved, reason, err := s.openaiClient.ModeratePost(ctx, title, content, images)
|
||||
if err != nil {
|
||||
zap.L().Error("AI audit post error",
|
||||
zap.Error(err),
|
||||
)
|
||||
if s.config.StrictModeration {
|
||||
return &AuditResult{
|
||||
Pass: false,
|
||||
Risk: "high",
|
||||
Suggest: "review",
|
||||
Detail: fmt.Sprintf("Audit error: %v", err),
|
||||
Provider: "ai",
|
||||
}, err
|
||||
}
|
||||
// 非严格模式下,审核失败时放行
|
||||
return &AuditResult{
|
||||
Pass: true,
|
||||
Risk: "low",
|
||||
Suggest: "pass",
|
||||
Detail: fmt.Sprintf("Audit fallback pass due to error: %v", err),
|
||||
Provider: "ai",
|
||||
}, nil
|
||||
}
|
||||
|
||||
result := &AuditResult{
|
||||
Pass: approved,
|
||||
Provider: "ai",
|
||||
Detail: reason,
|
||||
}
|
||||
|
||||
if approved {
|
||||
result.Risk = "low"
|
||||
result.Suggest = "pass"
|
||||
} else {
|
||||
result.Risk = "high"
|
||||
result.Suggest = "block"
|
||||
result.Labels = []string{"ai_rejected"}
|
||||
}
|
||||
|
||||
// 记录审核日志
|
||||
contentSummary := fmt.Sprintf("标题: %s, 内容: %s", title, content)
|
||||
if len(content) > 200 {
|
||||
contentSummary = fmt.Sprintf("标题: %s, 内容: %s...", title, content[:200])
|
||||
}
|
||||
go s.saveAuditLog(ctx, "post", contentSummary, strings.Join(images, ","), "post", result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AuditComment 审核评论
|
||||
func (s *auditServiceImpl) AuditComment(ctx context.Context, content string, images []string) (*AuditResult, error) {
|
||||
if !s.IsEnabled() {
|
||||
return &AuditResult{
|
||||
Pass: true,
|
||||
Risk: "low",
|
||||
Suggest: "pass",
|
||||
Detail: "Audit service disabled",
|
||||
Provider: "ai",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 使用 AI 审核评论
|
||||
approved, reason, err := s.openaiClient.ModerateComment(ctx, content, images)
|
||||
if err != nil {
|
||||
zap.L().Error("AI audit comment error",
|
||||
zap.Error(err),
|
||||
)
|
||||
if s.config.StrictModeration {
|
||||
return &AuditResult{
|
||||
Pass: false,
|
||||
Risk: "high",
|
||||
Suggest: "review",
|
||||
Detail: fmt.Sprintf("Audit error: %v", err),
|
||||
Provider: "ai",
|
||||
}, err
|
||||
}
|
||||
// 非严格模式下,审核失败时放行
|
||||
return &AuditResult{
|
||||
Pass: true,
|
||||
Risk: "low",
|
||||
Suggest: "pass",
|
||||
Detail: fmt.Sprintf("Audit fallback pass due to error: %v", err),
|
||||
Provider: "ai",
|
||||
}, nil
|
||||
}
|
||||
|
||||
result := &AuditResult{
|
||||
Pass: approved,
|
||||
Provider: "ai",
|
||||
Detail: reason,
|
||||
}
|
||||
|
||||
if approved {
|
||||
result.Risk = "low"
|
||||
result.Suggest = "pass"
|
||||
} else {
|
||||
result.Risk = "high"
|
||||
result.Suggest = "block"
|
||||
result.Labels = []string{"ai_rejected"}
|
||||
}
|
||||
|
||||
// 记录审核日志
|
||||
contentSummary := content
|
||||
if len(content) > 200 {
|
||||
contentSummary = content[:200] + "..."
|
||||
}
|
||||
go s.saveAuditLog(ctx, "comment", contentSummary, strings.Join(images, ","), "comment", result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetAuditResult 获取审核结果
|
||||
func (s *auditServiceImpl) GetAuditResult(ctx context.Context, auditID string) (*AuditResult, error) {
|
||||
if s.db == nil || auditID == "" {
|
||||
return nil, fmt.Errorf("invalid audit ID")
|
||||
}
|
||||
|
||||
var auditLog model.AuditLog
|
||||
if err := s.db.Where("id = ?", auditID).First(&auditLog).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &AuditResult{
|
||||
Pass: auditLog.Result == model.AuditResultPass,
|
||||
Risk: string(auditLog.RiskLevel),
|
||||
Suggest: auditLog.Suggestion,
|
||||
Detail: auditLog.Detail,
|
||||
}
|
||||
|
||||
// 解析标签
|
||||
if auditLog.Labels != "" {
|
||||
json.Unmarshal([]byte(auditLog.Labels), &result.Labels)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// saveAuditLog 保存审核日志
|
||||
func (s *auditServiceImpl) saveAuditLog(ctx context.Context, contentType, content, imageURL, auditType string, result *AuditResult) {
|
||||
if s.db == nil {
|
||||
return
|
||||
}
|
||||
|
||||
labelsJSON := ""
|
||||
if len(result.Labels) > 0 {
|
||||
if data, err := json.Marshal(result.Labels); err == nil {
|
||||
labelsJSON = string(data)
|
||||
}
|
||||
}
|
||||
|
||||
auditLog := model.AuditLog{
|
||||
ContentType: contentType,
|
||||
Content: content,
|
||||
ContentURL: imageURL,
|
||||
AuditType: auditType,
|
||||
Labels: labelsJSON,
|
||||
Suggestion: result.Suggest,
|
||||
Detail: result.Detail,
|
||||
Source: model.AuditSourceAuto,
|
||||
Status: "completed",
|
||||
}
|
||||
|
||||
if result.Pass {
|
||||
auditLog.Result = model.AuditResultPass
|
||||
} else if result.Suggest == "review" {
|
||||
auditLog.Result = model.AuditResultReview
|
||||
} else {
|
||||
auditLog.Result = model.AuditResultBlock
|
||||
}
|
||||
|
||||
switch result.Risk {
|
||||
case "low":
|
||||
auditLog.RiskLevel = model.AuditRiskLevelLow
|
||||
case "medium":
|
||||
auditLog.RiskLevel = model.AuditRiskLevelMedium
|
||||
case "high":
|
||||
auditLog.RiskLevel = model.AuditRiskLevelHigh
|
||||
default:
|
||||
auditLog.RiskLevel = model.AuditRiskLevelLow
|
||||
}
|
||||
|
||||
if err := s.db.Create(&auditLog).Error; err != nil {
|
||||
zap.L().Error("Failed to save audit log",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 审核结果回调处理 ====================
|
||||
|
||||
// AuditCallback 审核回调处理
|
||||
type AuditCallback struct {
|
||||
service AuditService
|
||||
}
|
||||
|
||||
// NewAuditCallback 创建审核回调处理
|
||||
func NewAuditCallback(service AuditService) *AuditCallback {
|
||||
return &AuditCallback{
|
||||
service: service,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTextCallback 处理文本审核回调
|
||||
func (c *AuditCallback) HandleTextCallback(ctx context.Context, auditID string, result *AuditResult) error {
|
||||
if c.service == nil || auditID == "" || result == nil {
|
||||
return fmt.Errorf("invalid parameters")
|
||||
}
|
||||
|
||||
zap.L().Debug("Processing text audit callback",
|
||||
zap.String("auditID", auditID),
|
||||
zap.Bool("pass", result.Pass),
|
||||
zap.String("risk", result.Risk),
|
||||
)
|
||||
|
||||
// 根据审核结果执行相应操作
|
||||
// 例如: 更新帖子状态、发送通知等
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleImageCallback 处理图片审核回调
|
||||
func (c *AuditCallback) HandleImageCallback(ctx context.Context, auditID string, result *AuditResult) error {
|
||||
if c.service == nil || auditID == "" || result == nil {
|
||||
return fmt.Errorf("invalid parameters")
|
||||
}
|
||||
|
||||
zap.L().Debug("Processing image audit callback",
|
||||
zap.String("auditID", auditID),
|
||||
zap.Bool("pass", result.Pass),
|
||||
zap.String("risk", result.Risk),
|
||||
)
|
||||
|
||||
// 根据审核结果执行相应操作
|
||||
// 例如: 更新图片状态、删除违规图片等
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
// IsContentSafe 判断内容是否安全
|
||||
func IsContentSafe(result *AuditResult) bool {
|
||||
if result == nil {
|
||||
return true
|
||||
}
|
||||
return result.Pass && result.Suggest != "block"
|
||||
}
|
||||
|
||||
// NeedReview 判断内容是否需要人工复审
|
||||
func NeedReview(result *AuditResult) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
return result.Suggest == "review"
|
||||
}
|
||||
|
||||
// GetRiskLevel 获取风险等级
|
||||
func GetRiskLevel(result *AuditResult) string {
|
||||
if result == nil {
|
||||
return "low"
|
||||
}
|
||||
return result.Risk
|
||||
}
|
||||
|
||||
// FormatAuditResult 格式化审核结果为字符串
|
||||
func FormatAuditResult(result *AuditResult) string {
|
||||
if result == nil {
|
||||
return "{}"
|
||||
}
|
||||
data, _ := json.Marshal(result)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// ParseAuditResult 从字符串解析审核结果
|
||||
func ParseAuditResult(data string) (*AuditResult, error) {
|
||||
if data == "" {
|
||||
return nil, fmt.Errorf("empty data")
|
||||
}
|
||||
var result AuditResult
|
||||
if err := json.Unmarshal([]byte(data), &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ==================== 审核日志查询 ====================
|
||||
|
||||
// GetAuditLogs 获取审核日志列表
|
||||
func GetAuditLogs(db *gorm.DB, targetType string, targetID string, result string, page, pageSize int) ([]model.AuditLog, int64, error) {
|
||||
query := db.Model(&model.AuditLog{})
|
||||
|
||||
if targetType != "" {
|
||||
query = query.Where("target_type = ?", targetType)
|
||||
}
|
||||
if targetID != "" {
|
||||
query = query.Where("target_id = ?", targetID)
|
||||
}
|
||||
if result != "" {
|
||||
query = query.Where("result = ?", result)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var logs []model.AuditLog
|
||||
offset := (page - 1) * pageSize
|
||||
if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&logs).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return logs, total, nil
|
||||
}
|
||||
|
||||
// ==================== 定时任务 ====================
|
||||
|
||||
// AuditScheduler 审核调度器
|
||||
type AuditScheduler struct {
|
||||
db *gorm.DB
|
||||
service AuditService
|
||||
interval time.Duration
|
||||
stopCh chan bool
|
||||
}
|
||||
|
||||
// NewAuditScheduler 创建审核调度器
|
||||
func NewAuditScheduler(db *gorm.DB, service AuditService, interval time.Duration) *AuditScheduler {
|
||||
return &AuditScheduler{
|
||||
db: db,
|
||||
service: service,
|
||||
interval: interval,
|
||||
stopCh: make(chan bool),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动调度器
|
||||
func (s *AuditScheduler) Start() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(s.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.processPendingAudits()
|
||||
case <-s.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop 停止调度器
|
||||
func (s *AuditScheduler) Stop() {
|
||||
s.stopCh <- true
|
||||
}
|
||||
|
||||
// processPendingAudits 处理待审核内容
|
||||
func (s *AuditScheduler) processPendingAudits() {
|
||||
// 查询待审核的内容
|
||||
// 1. 查询审核状态为 pending 的记录
|
||||
// 2. 调用审核服务
|
||||
// 3. 更新审核状态
|
||||
|
||||
// 示例逻辑,实际需要根据业务需求实现
|
||||
zap.L().Debug("Processing pending audits")
|
||||
}
|
||||
|
||||
// CleanupOldLogs 清理旧的审核日志
|
||||
func CleanupOldLogs(db *gorm.DB, days int) error {
|
||||
// 清理指定天数之前的审核日志
|
||||
cutoffTime := time.Now().AddDate(0, 0, -days)
|
||||
return db.Where("created_at < ? AND result = ?", cutoffTime, model.AuditResultPass).Delete(&model.AuditLog{}).Error
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func (s *CommentService) reviewCommentAsync(
|
||||
postOwnerID string,
|
||||
) {
|
||||
if s.hookManager == nil {
|
||||
if err := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished); err != nil {
|
||||
if err := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished, "", "system"); err != nil {
|
||||
zap.L().Warn("Failed to publish comment without hook manager",
|
||||
zap.Error(err),
|
||||
)
|
||||
@@ -149,6 +149,10 @@ func (s *CommentService) reviewCommentAsync(
|
||||
})
|
||||
|
||||
if !result.Approved {
|
||||
if result.NeedsReview {
|
||||
s.invalidatePostCaches(postID)
|
||||
return
|
||||
}
|
||||
if delErr := s.commentRepo.Delete(commentID); delErr != nil {
|
||||
zap.L().Warn("Failed to delete rejected comment",
|
||||
zap.String("commentID", commentID),
|
||||
@@ -159,7 +163,7 @@ func (s *CommentService) reviewCommentAsync(
|
||||
return
|
||||
}
|
||||
|
||||
if updateErr := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished); updateErr != nil {
|
||||
if updateErr := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished, "", result.ReviewedBy); updateErr != nil {
|
||||
zap.L().Warn("Failed to publish comment",
|
||||
zap.String("commentID", commentID),
|
||||
zap.Error(updateErr),
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// PostModerationRejectedError 帖子审核拒绝错误
|
||||
type PostModerationRejectedError struct {
|
||||
Reason string
|
||||
}
|
||||
@@ -21,7 +20,6 @@ func (e *PostModerationRejectedError) Error() string {
|
||||
return "post rejected by moderation: " + e.Reason
|
||||
}
|
||||
|
||||
// UserMessage 返回给前端的用户可读文案
|
||||
func (e *PostModerationRejectedError) UserMessage() string {
|
||||
if strings.TrimSpace(e.Reason) == "" {
|
||||
return "内容未通过审核,请修改后重试"
|
||||
@@ -29,7 +27,28 @@ func (e *PostModerationRejectedError) UserMessage() string {
|
||||
return strings.TrimSpace(e.Reason)
|
||||
}
|
||||
|
||||
// CommentModerationRejectedError 评论审核拒绝错误
|
||||
type PostModerationReviewError struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *PostModerationReviewError) Error() string {
|
||||
if strings.TrimSpace(e.Reason) == "" {
|
||||
return "post needs manual review"
|
||||
}
|
||||
return "post needs manual review: " + e.Reason
|
||||
}
|
||||
|
||||
func (e *PostModerationReviewError) UserMessage() string {
|
||||
if strings.TrimSpace(e.Reason) == "" {
|
||||
return "内容需要人工复审,请耐心等待"
|
||||
}
|
||||
return strings.TrimSpace(e.Reason)
|
||||
}
|
||||
|
||||
func (e *PostModerationReviewError) IsReview() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type CommentModerationRejectedError struct {
|
||||
Reason string
|
||||
}
|
||||
@@ -41,7 +60,6 @@ func (e *CommentModerationRejectedError) Error() string {
|
||||
return "comment rejected by moderation: " + e.Reason
|
||||
}
|
||||
|
||||
// UserMessage 返回给前端的用户可读文案
|
||||
func (e *CommentModerationRejectedError) UserMessage() string {
|
||||
if strings.TrimSpace(e.Reason) == "" {
|
||||
return "评论未通过审核,请修改后重试"
|
||||
@@ -49,6 +67,28 @@ func (e *CommentModerationRejectedError) UserMessage() string {
|
||||
return strings.TrimSpace(e.Reason)
|
||||
}
|
||||
|
||||
type CommentModerationReviewError struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *CommentModerationReviewError) Error() string {
|
||||
if strings.TrimSpace(e.Reason) == "" {
|
||||
return "comment needs manual review"
|
||||
}
|
||||
return "comment needs manual review: " + e.Reason
|
||||
}
|
||||
|
||||
func (e *CommentModerationReviewError) UserMessage() string {
|
||||
if strings.TrimSpace(e.Reason) == "" {
|
||||
return "评论需要人工复审,请耐心等待"
|
||||
}
|
||||
return strings.TrimSpace(e.Reason)
|
||||
}
|
||||
|
||||
func (e *CommentModerationReviewError) IsReview() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type PostAIService struct {
|
||||
openAIClient openai.Client
|
||||
}
|
||||
@@ -63,13 +103,12 @@ func (s *PostAIService) IsEnabled() bool {
|
||||
return s != nil && s.openAIClient != nil && s.openAIClient.IsEnabled()
|
||||
}
|
||||
|
||||
// ModeratePost 审核帖子内容,返回 nil 表示通过
|
||||
func (s *PostAIService) ModeratePost(ctx context.Context, title, content string, images []string) error {
|
||||
if !s.IsEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
approved, reason, err := s.openAIClient.ModeratePost(ctx, title, content, images)
|
||||
resp, err := s.openAIClient.ModeratePost(ctx, title, content, images)
|
||||
if err != nil {
|
||||
if s.openAIClient.Config().StrictModeration {
|
||||
return err
|
||||
@@ -79,19 +118,23 @@ func (s *PostAIService) ModeratePost(ctx context.Context, title, content string,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if !approved {
|
||||
return &PostModerationRejectedError{Reason: reason}
|
||||
|
||||
switch resp.Result {
|
||||
case openai.ModerationResultBlock:
|
||||
return &PostModerationRejectedError{Reason: resp.Reason}
|
||||
case openai.ModerationResultReview:
|
||||
return &PostModerationReviewError{Reason: resp.Reason}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ModerateComment 审核评论内容,返回 nil 表示通过
|
||||
func (s *PostAIService) ModerateComment(ctx context.Context, content string, images []string) error {
|
||||
if !s.IsEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
approved, reason, err := s.openAIClient.ModerateComment(ctx, content, images)
|
||||
resp, err := s.openAIClient.ModerateComment(ctx, content, images)
|
||||
if err != nil {
|
||||
if s.openAIClient.Config().StrictModeration {
|
||||
return err
|
||||
@@ -101,8 +144,13 @@ func (s *PostAIService) ModerateComment(ctx context.Context, content string, ima
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if !approved {
|
||||
return &CommentModerationRejectedError{Reason: reason}
|
||||
|
||||
switch resp.Result {
|
||||
case openai.ModerationResultBlock:
|
||||
return &CommentModerationRejectedError{Reason: resp.Reason}
|
||||
case openai.ModerationResultReview:
|
||||
return &CommentModerationReviewError{Reason: resp.Reason}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -178,6 +178,10 @@ func (s *postServiceImpl) reviewPostAsync(postID, userID, title, content string,
|
||||
})
|
||||
|
||||
if !result.Approved {
|
||||
if result.NeedsReview {
|
||||
s.invalidatePostCaches(postID)
|
||||
return
|
||||
}
|
||||
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusRejected, result.RejectReason, result.ReviewedBy); updateErr != nil {
|
||||
log.Printf("[WARN] Failed to reject post %s: %v", postID, updateErr)
|
||||
} else {
|
||||
|
||||
@@ -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