diff --git a/cmd/server/wire_gen.go b/cmd/server/wire_gen.go index 7832846..cf97824 100644 --- a/cmd/server/wire_gen.go +++ b/cmd/server/wire_gen.go @@ -88,7 +88,7 @@ func InitializeApp() (*App, error) { stickerService := wire.ProvideStickerService(stickerRepository) stickerHandler := handler.NewStickerHandler(stickerService) voteRepository := repository.NewVoteRepository(db) - voteService := wire.ProvideVoteService(voteRepository, postRepository, cache, postAIService, systemMessageService) + voteService := wire.ProvideVoteService(voteRepository, postRepository, cache, postAIService, systemMessageService, manager, logService) voteHandler := handler.NewVoteHandler(voteService, postService) channelHandler := handler.NewChannelHandler(channelService) scheduleRepository := repository.NewScheduleRepository(db) diff --git a/internal/dto/dto.go b/internal/dto/dto.go index bb91481..a17661c 100644 --- a/internal/dto/dto.go +++ b/internal/dto/dto.go @@ -1014,6 +1014,9 @@ type AdminCommentListResponse struct { Content string `json:"content"` Images []CommentImageResponse `json:"images"` Status string `json:"status"` + RejectReason string `json:"reject_reason,omitempty"` + ReviewedAt string `json:"reviewed_at,omitempty"` + ReviewedBy string `json:"reviewed_by,omitempty"` LikesCount int `json:"likes_count"` RepliesCount int `json:"replies_count"` CreatedAt string `json:"created_at"` @@ -1038,13 +1041,16 @@ type AdminCommentDetailResponse struct { Content string `json:"content"` Images []CommentImageResponse `json:"images"` Status string `json:"status"` + RejectReason string `json:"reject_reason,omitempty"` + ReviewedAt string `json:"reviewed_at,omitempty"` + ReviewedBy string `json:"reviewed_by,omitempty"` LikesCount int `json:"likes_count"` RepliesCount int `json:"replies_count"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` Author *UserResponse `json:"author"` Post *AdminCommentPostInfo `json:"post"` - ReplyToUser *UserResponse `json:"reply_to_user,omitempty"` // 被回复的用户信息 + ReplyToUser *UserResponse `json:"reply_to_user,omitempty"` } // AdminBatchDeleteCommentsRequest 批量删除评论请求 @@ -1052,6 +1058,18 @@ type AdminBatchDeleteCommentsRequest struct { IDs []string `json:"ids" binding:"required,min=1,max=100,dive,required"` } +// AdminModerateCommentRequest 审核评论请求 +type AdminModerateCommentRequest struct { + Status string `json:"status" binding:"required,oneof=published rejected"` + Reason string `json:"reason"` +} + +// AdminBatchModerateCommentsRequest 批量审核评论请求 +type AdminBatchModerateCommentsRequest struct { + IDs []string `json:"ids" binding:"required,min=1,max=100,dive,required"` + Status string `json:"status" binding:"required,oneof=published rejected"` +} + // ==================== Admin Group DTOs ==================== // AdminGroupListResponse 管理端群组列表响应 diff --git a/internal/handler/admin_comment_handler.go b/internal/handler/admin_comment_handler.go index b5f8250..3d943e1 100644 --- a/internal/handler/admin_comment_handler.go +++ b/internal/handler/admin_comment_handler.go @@ -121,6 +121,58 @@ func (h *AdminCommentHandler) BatchDeleteComments(c *gin.Context) { response.Success(c, result) } +func (h *AdminCommentHandler) ModerateComment(c *gin.Context) { + commentID := c.Param("id") + if commentID == "" { + response.BadRequest(c, "comment id is required") + return + } + + var req dto.AdminModerateCommentRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "invalid request body: "+err.Error()) + return + } + + if req.Status != string(model.CommentStatusPublished) && req.Status != string(model.CommentStatusRejected) { + response.BadRequest(c, "invalid status value, must be 'published' or 'rejected'") + return + } + + reviewedBy := c.GetString("user_id") + + commentDetail, err := h.adminCommentService.ModerateComment(c.Request.Context(), commentID, model.CommentStatus(req.Status), req.Reason, reviewedBy) + if err != nil { + response.HandleError(c, err, "failed to moderate comment") + return + } + + response.Success(c, commentDetail) +} + +func (h *AdminCommentHandler) BatchModerateComments(c *gin.Context) { + var req dto.AdminBatchModerateCommentsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "invalid request body: "+err.Error()) + return + } + + if req.Status != string(model.CommentStatusPublished) && req.Status != string(model.CommentStatusRejected) { + response.BadRequest(c, "invalid status value, must be 'published' or 'rejected'") + return + } + + reviewedBy := c.GetString("user_id") + + result, err := h.adminCommentService.BatchModerateComments(c.Request.Context(), req.IDs, model.CommentStatus(req.Status), reviewedBy) + if err != nil { + response.InternalServerError(c, "failed to batch moderate comments") + return + } + + response.Success(c, result) +} + // GetPostComments 获取帖子的评论列表 // GET /api/v1/admin/posts/:postId/comments func (h *AdminCommentHandler) GetPostComments(c *gin.Context) { diff --git a/internal/handler/comment_handler.go b/internal/handler/comment_handler.go index 34e5535..347c26e 100644 --- a/internal/handler/comment_handler.go +++ b/internal/handler/comment_handler.go @@ -2,7 +2,6 @@ package handler import ( "encoding/json" - "errors" "strconv" "github.com/gin-gonic/gin" @@ -61,11 +60,6 @@ func (h *CommentHandler) Create(c *gin.Context) { comment, err := h.commentService.Create(c.Request.Context(), req.PostID, userID, req.Content, req.ParentID, imagesJSON, req.Images) if err != nil { - var moderationErr *service.CommentModerationRejectedError - if errors.As(err, &moderationErr) { - response.BadRequest(c, moderationErr.UserMessage()) - return - } response.InternalServerError(c, "failed to create comment") return } diff --git a/internal/model/comment.go b/internal/model/comment.go index d7ee53c..38d5083 100644 --- a/internal/model/comment.go +++ b/internal/model/comment.go @@ -33,7 +33,10 @@ type Comment struct { Replies []*Comment `json:"-" gorm:"-"` // 子回复(手动加载,非 GORM 关联) // 审核状态 - Status CommentStatus `json:"status" gorm:"type:varchar(20);default:published;index:idx_comments_post_parent_status_created,priority:3;index:idx_comments_root_status_created,priority:2"` + Status CommentStatus `json:"status" gorm:"type:varchar(20);default:published;index:idx_comments_post_parent_status_created,priority:3;index:idx_comments_root_status_created,priority:2"` + ReviewedAt *time.Time `json:"reviewed_at" gorm:"type:timestamp"` + ReviewedBy string `json:"reviewed_by" gorm:"type:varchar(50)"` + RejectReason string `json:"reject_reason" gorm:"type:varchar(500)"` // 统计 LikesCount int `json:"likes_count" gorm:"default:0"` diff --git a/internal/pkg/hook/moderation_hooks.go b/internal/pkg/hook/moderation_hooks.go index 296130f..3c90b9e 100644 --- a/internal/pkg/hook/moderation_hooks.go +++ b/internal/pkg/hook/moderation_hooks.go @@ -10,11 +10,8 @@ import ( "go.uber.org/zap" ) -type PostModerationHook struct { - name string - postAIService PostAIService - postRepo repository.PostRepository - strictMode bool +type reviewableError interface { + IsReview() bool } type PostAIService interface { @@ -23,6 +20,13 @@ type PostAIService interface { ModerateComment(ctx context.Context, content string, images []string) error } +type PostModerationHook struct { + name string + postAIService PostAIService + postRepo repository.PostRepository + strictMode bool +} + func NewPostModerationHook(postAIService PostAIService, postRepo repository.PostRepository, strictMode bool) *PostModerationHook { return &PostModerationHook{ name: "moderation.post.ai", @@ -36,9 +40,9 @@ func (h *PostModerationHook) Register(manager *Manager) { manager.Register(&Hook{ Name: h.name, HookType: HookPostPreModerate, - Priority: PriorityHighest, + Priority: PriorityHigh, Func: h.Execute, - Async: false, // 必须同步执行,因为调用方需要审核结果 + Async: false, Timeout: 30 * time.Second, }) } @@ -54,20 +58,32 @@ func (h *PostModerationHook) Execute(ctx *HookContext) error { return nil } + if result.RejectReason != "" || result.NeedsReview { + return nil + } + if h.postAIService == nil || !h.postAIService.IsEnabled() { result.Approved = true result.ReviewedBy = "system" - zap.L().Debug("AI moderation disabled, auto approve post", - zap.String("post_id", data.PostID), - ) return nil } err := h.postAIService.ModeratePost(ctx.Ctx, data.Title, data.Content, data.Images) if err != nil { - if rejectErr, ok := err.(interface{ UserMessage() string }); ok { + if userErr, ok := err.(interface{ UserMessage() string }); ok { + if revErr, ok := err.(reviewableError); ok && revErr.IsReview() { + result.Approved = false + result.NeedsReview = true + result.RejectReason = userErr.UserMessage() + result.ReviewedBy = "ai" + zap.L().Info("Post flagged for manual review by AI", + zap.String("post_id", data.PostID), + zap.String("reason", result.RejectReason), + ) + return nil + } result.Approved = false - result.RejectReason = rejectErr.UserMessage() + result.RejectReason = userErr.UserMessage() result.ReviewedBy = "ai" zap.L().Info("Post rejected by AI moderation", zap.String("post_id", data.PostID), @@ -98,9 +114,6 @@ func (h *PostModerationHook) Execute(ctx *HookContext) error { result.Approved = true result.ReviewedBy = "ai" - zap.L().Debug("Post approved by AI moderation", - zap.String("post_id", data.PostID), - ) return nil } @@ -124,9 +137,9 @@ func (h *CommentModerationHook) Register(manager *Manager) { manager.Register(&Hook{ Name: h.name, HookType: HookCommentPreModerate, - Priority: PriorityHighest, + Priority: PriorityHigh, Func: h.Execute, - Async: false, // 必须同步执行,因为调用方需要审核结果 + Async: false, Timeout: 30 * time.Second, }) } @@ -142,20 +155,32 @@ func (h *CommentModerationHook) Execute(ctx *HookContext) error { return nil } + if result.RejectReason != "" || result.NeedsReview { + return nil + } + if h.postAIService == nil || !h.postAIService.IsEnabled() { result.Approved = true result.ReviewedBy = "system" - zap.L().Debug("AI moderation disabled, auto approve comment", - zap.String("comment_id", data.CommentID), - ) return nil } err := h.postAIService.ModerateComment(ctx.Ctx, data.Content, data.Images) if err != nil { - if rejectErr, ok := err.(interface{ UserMessage() string }); ok { + if userErr, ok := err.(interface{ UserMessage() string }); ok { + if revErr, ok := err.(reviewableError); ok && revErr.IsReview() { + result.Approved = false + result.NeedsReview = true + result.RejectReason = userErr.UserMessage() + result.ReviewedBy = "ai" + zap.L().Info("Comment flagged for manual review by AI", + zap.String("comment_id", data.CommentID), + zap.String("reason", result.RejectReason), + ) + return nil + } result.Approved = false - result.RejectReason = rejectErr.UserMessage() + result.RejectReason = userErr.UserMessage() result.ReviewedBy = "ai" zap.L().Info("Comment rejected by AI moderation", zap.String("comment_id", data.CommentID), @@ -177,7 +202,7 @@ func (h *CommentModerationHook) Execute(ctx *HookContext) error { result.Approved = true result.ReviewedBy = "system" - zap.L().Warn("AI comment moderation error, fallback approve", + zap.L().Warn("AI moderation error, fallback approve", zap.String("comment_id", data.CommentID), zap.Error(err), ) @@ -186,23 +211,20 @@ func (h *CommentModerationHook) Execute(ctx *HookContext) error { result.Approved = true result.ReviewedBy = "ai" - zap.L().Debug("Comment approved by AI moderation", - zap.String("comment_id", data.CommentID), - ) return nil } +type SensitiveService interface { + Check(ctx context.Context, text string) (bool, []string) + Replace(ctx context.Context, text string, repl string) string +} + type SensitiveWordHook struct { name string sensitive SensitiveService replaceStr string } -type SensitiveService interface { - Check(ctx context.Context, text string) (bool, []string) - Replace(ctx context.Context, text string, repl string) string -} - func NewSensitiveWordHook(sensitive SensitiveService, replaceStr string) *SensitiveWordHook { return &SensitiveWordHook{ name: "moderation.sensitive_word", @@ -215,7 +237,7 @@ func (h *SensitiveWordHook) RegisterPostHook(manager *Manager) { manager.Register(&Hook{ Name: h.name + ".post", HookType: HookPostPreModerate, - Priority: PriorityHigh, + Priority: PriorityHighest, Func: h.executePost, Async: false, }) @@ -225,7 +247,7 @@ func (h *SensitiveWordHook) RegisterCommentHook(manager *Manager) { manager.Register(&Hook{ Name: h.name + ".comment", HookType: HookCommentPreModerate, - Priority: PriorityHigh, + Priority: PriorityHighest, Func: h.executeComment, Async: false, }) @@ -325,18 +347,18 @@ func NewModerationHooks( } func (m *ModerationHooks) RegisterAll(manager *Manager) { - postHook := NewPostModerationHook(m.postAIService, m.postRepo, m.strictMode) - postHook.Register(manager) - - commentHook := NewCommentModerationHook(m.postAIService, m.commentRepo, m.strictMode) - commentHook.Register(manager) - if m.sensitiveService != nil { sensitiveHook := NewSensitiveWordHook(m.sensitiveService, m.replaceStr) sensitiveHook.RegisterPostHook(manager) sensitiveHook.RegisterCommentHook(manager) } + postHook := NewPostModerationHook(m.postAIService, m.postRepo, m.strictMode) + postHook.Register(manager) + + commentHook := NewCommentModerationHook(m.postAIService, m.commentRepo, m.strictMode) + commentHook.Register(manager) + zap.L().Info("Registered moderation hooks", zap.Bool("ai_enabled", m.postAIService != nil && m.postAIService.IsEnabled()), zap.Bool("sensitive_enabled", m.sensitiveService != nil), diff --git a/internal/pkg/hook/types.go b/internal/pkg/hook/types.go index 1db7522..56cb6ef 100644 --- a/internal/pkg/hook/types.go +++ b/internal/pkg/hook/types.go @@ -103,6 +103,7 @@ type CommentModeratedHookData struct { type ModerationResult struct { Approved bool + NeedsReview bool RejectReason string ReviewedBy string Error error diff --git a/internal/pkg/openai/client.go b/internal/pkg/openai/client.go index 6fe5a6a..9082246 100644 --- a/internal/pkg/openai/client.go +++ b/internal/pkg/openai/client.go @@ -19,7 +19,50 @@ import ( xdraw "golang.org/x/image/draw" ) -const moderationSystemPrompt = "你是中文社区的内容审核助手,负责对“帖子标题、正文、配图”做联合审核。目标是平衡社区安全与正常交流:必须拦截高风险违规内容,但不要误伤正常玩梗、二创、吐槽和轻度调侃。请只输出指定JSON。\n\n审核流程:\n1) 先判断是否命中硬性违规;\n2) 再判断语境(玩笑/自嘲/朋友间互动/作品讨论);\n3) 做文图交叉判断(文本+图片合并理解);\n4) 给出 approved 与简短 reason。\n\n硬性违规(命中任一项必须 approved=false):\nA. 宣传对立与煽动撕裂:\n- 明确煽动群体对立、地域对立、性别对立、民族宗教对立,鼓动仇恨、排斥、报复。\nB. 严重人身攻击与网暴引导:\n- 持续性侮辱贬损、羞辱人格、号召围攻/骚扰/挂人/线下冲突。\nC. 开盒/人肉/隐私暴露:\n- 故意公开、拼接、索取他人可识别隐私信息(姓名+联系方式、身份证号、住址、学校单位、车牌、定位轨迹等);\n- 图片/截图中出现可识别隐私信息并伴随曝光意图,也按违规处理。\nD. 其他高危违规:\n- 违法犯罪、暴力威胁、极端仇恨、色情低俗、诈骗引流、恶意广告等。\n\n放行规则(以下通常 approved=true):\n- 正常玩梗、表情包、谐音梗、二次创作、无恶意的吐槽;\n- 非定向、轻度口语化吐槽(无明确攻击对象、无网暴号召、无隐私暴露);\n- 对社会事件/作品的理性讨论、观点争论(即使语气尖锐,但未煽动对立或人身攻击)。\n\n边界判定:\n- 若只是“梗文化表达”且不指向现实伤害,优先通过;\n- 若存在明确伤害意图(煽动、围攻、曝光隐私),必须拒绝;\n- 对模糊内容不因个别粗口直接拒绝,需结合对象、意图、号召性和可执行性综合判断。\n\nreason 要求:\n- approved=false 时:中文10-30字,说明核心违规点;\n- approved=true 时:reason 为空字符串。\n\n输出格式(严格):\n仅输出一行JSON对象,不要Markdown,不要额外解释:\n{\"approved\": true/false, \"reason\": \"...\"}" +const moderationSystemPrompt = `你是中文社区的内容审核助手,负责对"帖子标题、正文、配图"做联合审核。目标是平衡社区安全与正常交流:必须拦截高风险违规内容,但不要误伤正常玩梗、二创、吐槽和轻度调侃。请只输出指定JSON。 + +审核流程: +1) 先判断是否命中硬性违规; +2) 再判断语境(玩笑/自嘲/朋友间互动/作品讨论); +3) 做文图交叉判断(文本+图片合并理解); +4) 给出 result 与简短 reason。 + +硬性违规(命中任一项必须 result=block): +A. 宣传对立与煽动撕裂: +- 明确煽动群体对立、地域对立、性别对立、民族宗教对立,鼓动仇恨、排斥、报复。 +B. 严重人身攻击与网暴引导: +- 持续性侮辱贬损、羞辱人格、号召围攻/骚扰/挂人/线下冲突。 +C. 开盒/人肉/隐私暴露: +- 故意公开、拼接、索取他人可识别隐私信息(姓名+联系方式、身份证号、住址、学校单位、车牌、定位轨迹等); +- 图片/截图中出现可识别隐私信息并伴随曝光意图,也按违规处理。 +D. 其他高危违规: +- 违法犯罪、暴力威胁、极端仇恨、色情低俗、诈骗引流、恶意广告等。 + +可疑内容(需要人工复审,result=review): +- 内容含义模糊,无法确定是否存在违规意图; +- 涉及敏感话题但未明确违规(如政治人物、社会热点事件等); +- 存在轻微争议性表达,但不完全符合硬性违规标准; +- AI无法准确判断的文化/地域特定表达。 + +放行规则(以下通常 result=pass): +- 正常玩梗、表情包、谐音梗、二次创作、无恶意的吐槽; +- 非定向、轻度口语化吐槽(无明确攻击对象、无网暴号召、无隐私暴露); +- 对社会事件/作品的理性讨论、观点争论(即使语气尖锐,但未煽动对立或人身攻击)。 + +边界判定: +- 若只是"梗文化表达"且不指向现实伤害,优先通过; +- 若存在明确伤害意图(煽动、围攻、曝光隐私),必须拒绝; +- 对模糊内容不因个别粗口直接拒绝,需结合对象、意图、号召性和可执行性综合判断; +- 如果实在无法确定是否违规,标记为 review 状态交由人工复审。 + +reason 要求: +- result=block 时:中文10-30字,说明核心违规点; +- result=review 时:中文10-30字,说明可疑原因; +- result=pass 时:reason 为空字符串。 + +输出格式(严格): +仅输出一行JSON对象,不要Markdown,不要额外解释: +{"result": "pass"/"review"/"block", "reason": "..."}` const ( defaultMaxImagesPerModerationRequest = 1 @@ -32,11 +75,24 @@ const ( maxCompressedPayloadBytes = 1536 * 1024 ) +type ModerationResult string + +const ( + ModerationResultPass ModerationResult = "pass" + ModerationResultReview ModerationResult = "review" + ModerationResultBlock ModerationResult = "block" +) + +type ModerationResponse struct { + Result ModerationResult `json:"result"` + Reason string `json:"reason"` +} + type Client interface { IsEnabled() bool Config() Config - ModeratePost(ctx context.Context, title, content string, images []string) (bool, string, error) - ModerateComment(ctx context.Context, content string, images []string) (bool, string, error) + ModeratePost(ctx context.Context, title, content string, images []string) (*ModerationResponse, error) + ModerateComment(ctx context.Context, content string, images []string) (*ModerationResponse, error) } type clientImpl struct { @@ -65,12 +121,11 @@ func (c *clientImpl) Config() Config { return c.cfg } -func (c *clientImpl) ModeratePost(ctx context.Context, title, content string, images []string) (bool, string, error) { +func (c *clientImpl) ModeratePost(ctx context.Context, title, content string, images []string) (*ModerationResponse, error) { if !c.IsEnabled() { - return true, "", nil + return &ModerationResponse{Result: ModerationResultPass}, nil } - // 构建帖子提示词,处理标题或内容为空的情况 var prompt strings.Builder if strings.TrimSpace(title) != "" { prompt.WriteString("帖子标题:" + title) @@ -81,7 +136,6 @@ func (c *clientImpl) ModeratePost(ctx context.Context, title, content string, im } prompt.WriteString("帖子内容:" + content) } - // 如果标题和内容都为空但有图片,说明是纯图片帖子 if prompt.Len() == 0 && len(normalizeImageURLs(images)) > 0 { prompt.WriteString("帖子内容:[仅包含图片]") } @@ -92,17 +146,15 @@ func (c *clientImpl) ModeratePost(ctx context.Context, title, content string, im return c.moderateContentInBatches(ctx, prompt.String(), images) } -func (c *clientImpl) ModerateComment(ctx context.Context, content string, images []string) (bool, string, error) { +func (c *clientImpl) ModerateComment(ctx context.Context, content string, images []string) (*ModerationResponse, error) { if !c.IsEnabled() { - return true, "", nil + return &ModerationResponse{Result: ModerationResultPass}, nil } - // 构建评论提示词,处理纯图片评论的情况 var prompt strings.Builder if strings.TrimSpace(content) != "" { prompt.WriteString("评论内容:" + content) } else if len(normalizeImageURLs(images)) > 0 { - // 评论只有图片没有文字,说明是纯图片评论 prompt.WriteString("评论内容:[仅包含图片]") } else { prompt.WriteString("评论内容:[空]") @@ -111,7 +163,7 @@ func (c *clientImpl) ModerateComment(ctx context.Context, content string, images return c.moderateContentInBatches(ctx, prompt.String(), images) } -func (c *clientImpl) moderateContentInBatches(ctx context.Context, contentPrompt string, images []string) (bool, string, error) { +func (c *clientImpl) moderateContentInBatches(ctx context.Context, contentPrompt string, images []string) (*ModerationResponse, error) { cleanImages := normalizeImageURLs(images) optimizedImages := c.optimizeImagesForModeration(ctx, cleanImages) maxImagesPerRequest := c.maxImagesPerModerationRequest() @@ -120,7 +172,6 @@ func (c *clientImpl) moderateContentInBatches(ctx context.Context, contentPrompt totalBatches = (len(optimizedImages) + maxImagesPerRequest - 1) / maxImagesPerRequest } - // 图片超过单批上限时分批审核,任一批次拒绝即整体拒绝 for i := 0; i < totalBatches; i++ { start := i * maxImagesPerRequest end := start + maxImagesPerRequest @@ -133,19 +184,19 @@ func (c *clientImpl) moderateContentInBatches(ctx context.Context, contentPrompt batchImages = optimizedImages[start:end] } - approved, reason, err := c.moderateSingleBatch(ctx, contentPrompt, batchImages, i+1, totalBatches) + resp, err := c.moderateSingleBatch(ctx, contentPrompt, batchImages, i+1, totalBatches) if err != nil { - return false, "", err + return nil, err } - if !approved { - if strings.TrimSpace(reason) != "" && totalBatches > 1 { - reason = fmt.Sprintf("第%d/%d批图片未通过:%s", i+1, totalBatches, reason) + if resp.Result != ModerationResultPass { + if totalBatches > 1 && strings.TrimSpace(resp.Reason) != "" { + resp.Reason = fmt.Sprintf("第%d/%d批图片未通过:%s", i+1, totalBatches, resp.Reason) } - return false, reason, nil + return resp, nil } } - return true, "", nil + return &ModerationResponse{Result: ModerationResultPass}, nil } func (c *clientImpl) moderateSingleBatch( @@ -153,7 +204,7 @@ func (c *clientImpl) moderateSingleBatch( contentPrompt string, images []string, batchNo, totalBatches int, -) (bool, string, error) { +) (*ModerationResponse, error) { userPrompt := fmt.Sprintf( "%s\n图片批次:%d/%d(本次仅提供当前批次图片)", contentPrompt, @@ -168,13 +219,23 @@ func (c *clientImpl) moderateSingleBatch( lastErr = err } else { parsed := struct { - Approved bool `json:"approved"` - Reason string `json:"reason"` + Result string `json:"result"` + Reason string `json:"reason"` }{} if err := json.Unmarshal([]byte(extractJSONObject(replyText)), &parsed); err != nil { lastErr = fmt.Errorf("failed to parse moderation result: %w", err) } else { - return parsed.Approved, parsed.Reason, nil + result := ModerationResultPass + switch parsed.Result { + case "review": + result = ModerationResultReview + case "block": + result = ModerationResultBlock + } + return &ModerationResponse{ + Result: result, + Reason: parsed.Reason, + }, nil } } @@ -182,11 +243,11 @@ func (c *clientImpl) moderateSingleBatch( break } if sleepErr := sleepWithBackoff(ctx, attempt); sleepErr != nil { - return false, "", sleepErr + return nil, sleepErr } } - return false, "", fmt.Errorf( + return nil, fmt.Errorf( "moderation batch %d/%d failed after %d attempts: %w", batchNo, totalBatches, @@ -200,13 +261,13 @@ type chatCompletionsRequest struct { Messages []chatMessage `json:"messages"` Temperature float64 `json:"temperature,omitempty"` MaxTokens int `json:"max_tokens,omitempty"` - EnableThinking *bool `json:"enable_thinking,omitempty"` // qwen3.5思考模式控制 - ThinkingBudget *int `json:"thinking_budget,omitempty"` // 思考过程最大token数 - ResponseFormat *responseFormatConfig `json:"response_format,omitempty"` // 响应格式 + EnableThinking *bool `json:"enable_thinking,omitempty"` + ThinkingBudget *int `json:"thinking_budget,omitempty"` + ResponseFormat *responseFormatConfig `json:"response_format,omitempty"` } type responseFormatConfig struct { - Type string `json:"type"` // "text" or "json_object" + Type string `json:"type"` } type chatMessage struct { @@ -266,12 +327,10 @@ func (c *clientImpl) chatCompletion( Temperature: temperature, MaxTokens: maxTokens, } - // 禁用qwen3.5的思考模式,避免产生大量不必要的token消耗 falseVal := false reqBody.EnableThinking = &falseVal zero := 0 reqBody.ThinkingBudget = &zero - // 使用JSON输出格式 reqBody.ResponseFormat = &responseFormatConfig{Type: "json_object"} data, err := json.Marshal(reqBody) @@ -381,7 +440,6 @@ func extractJSONObject(raw string) string { } func (c *clientImpl) maxImagesPerModerationRequest() int { - // 审核固定单图请求,降低单次payload体积,减少超时风险。 if c.cfg.ModerationMaxImagesPerRequest <= 0 { return defaultMaxImagesPerModerationRequest } @@ -447,7 +505,6 @@ func (c *clientImpl) tryCompressImageForModeration(ctx context.Context, imageURL if len(compressed) == 0 || len(compressed) > maxCompressedPayloadBytes { return imageURL } - // 压缩效果不明显时直接使用原图URL,避免增大请求体。 if len(compressed) >= int(float64(len(originBytes))*0.95) { return imageURL } diff --git a/internal/repository/comment_repo.go b/internal/repository/comment_repo.go index 4548e84..390f430 100644 --- a/internal/repository/comment_repo.go +++ b/internal/repository/comment_repo.go @@ -13,7 +13,7 @@ type CommentRepository interface { Create(comment *model.Comment) error GetByID(id string) (*model.Comment, error) Update(comment *model.Comment) error - UpdateModerationStatus(commentID string, status model.CommentStatus) error + UpdateModerationStatus(commentID string, status model.CommentStatus, rejectReason string, reviewedBy string) error Delete(id string) error ApplyPublishedStats(comment *model.Comment) error GetByPostID(postID string, page, pageSize int) ([]*model.Comment, int64, error) @@ -63,11 +63,17 @@ func (r *commentRepository) Update(comment *model.Comment) error { } // UpdateModerationStatus 更新评论审核状态 -func (r *commentRepository) UpdateModerationStatus(commentID string, status model.CommentStatus) error { - // 审核状态更新属于管理操作,不应影响评论内容更新时间(updated_at) +func (r *commentRepository) UpdateModerationStatus(commentID string, status model.CommentStatus, rejectReason string, reviewedBy string) error { + updates := map[string]any{ + "status": status, + "reviewed_at": gorm.Expr("CURRENT_TIMESTAMP"), + "reviewed_by": reviewedBy, + "reject_reason": rejectReason, + "updated_at": gorm.Expr("updated_at"), + } return r.db.Model(&model.Comment{}). Where("id = ?", commentID). - UpdateColumn("status", status).Error + UpdateColumns(updates).Error } // Delete 删除评论(软删除,同时清理关联数据) diff --git a/internal/router/router.go b/internal/router/router.go index b7d1ab5..085ec63 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -552,7 +552,9 @@ func (r *Router) setupRoutes() { admin.GET("/comments", r.adminCommentHandler.GetCommentList) admin.GET("/comments/:id", r.adminCommentHandler.GetCommentDetail) admin.DELETE("/comments/:id", r.adminCommentHandler.DeleteComment) + admin.PUT("/comments/:id/moderate", r.adminCommentHandler.ModerateComment) admin.POST("/comments/batch-delete", r.adminCommentHandler.BatchDeleteComments) + admin.POST("/comments/batch-moderate", r.adminCommentHandler.BatchModerateComments) admin.GET("/comments/post/:postId", r.adminCommentHandler.GetPostComments) } diff --git a/internal/service/admin_comment_service.go b/internal/service/admin_comment_service.go index e511189..e41068f 100644 --- a/internal/service/admin_comment_service.go +++ b/internal/service/admin_comment_service.go @@ -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"), diff --git a/internal/service/audit_service.go b/internal/service/audit_service.go deleted file mode 100644 index 2428bb9..0000000 --- a/internal/service/audit_service.go +++ /dev/null @@ -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 -} diff --git a/internal/service/comment_service.go b/internal/service/comment_service.go index 6e2800d..e4530ba 100644 --- a/internal/service/comment_service.go +++ b/internal/service/comment_service.go @@ -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), diff --git a/internal/service/post_ai_service.go b/internal/service/post_ai_service.go index e27a00d..24d1bff 100644 --- a/internal/service/post_ai_service.go +++ b/internal/service/post_ai_service.go @@ -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 } diff --git a/internal/service/post_service.go b/internal/service/post_service.go index 046e122..98898d6 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -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 { diff --git a/internal/service/vote_service.go b/internal/service/vote_service.go index 713dbb5..3c5b297 100644 --- a/internal/service/vote_service.go +++ b/internal/service/vote_service.go @@ -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), diff --git a/internal/wire/service.go b/internal/wire/service.go index 6e676d3..39f07ed 100644 --- a/internal/wire/service.go +++ b/internal/wire/service.go @@ -179,8 +179,10 @@ func ProvideVoteService( cache cache.Cache, postAIService *service.PostAIService, systemMessageService service.SystemMessageService, + hookManager *hook.Manager, + logService *service.LogService, ) *service.VoteService { - return service.NewVoteService(voteRepo, postRepo, cache, postAIService, systemMessageService) + return service.NewVoteService(voteRepo, postRepo, cache, postAIService, systemMessageService, hookManager, logService) } func ProvideChannelService(channelRepo repository.ChannelRepository, cacheBackend cache.Cache) service.ChannelService {