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:
@@ -88,7 +88,7 @@ func InitializeApp() (*App, error) {
|
|||||||
stickerService := wire.ProvideStickerService(stickerRepository)
|
stickerService := wire.ProvideStickerService(stickerRepository)
|
||||||
stickerHandler := handler.NewStickerHandler(stickerService)
|
stickerHandler := handler.NewStickerHandler(stickerService)
|
||||||
voteRepository := repository.NewVoteRepository(db)
|
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)
|
voteHandler := handler.NewVoteHandler(voteService, postService)
|
||||||
channelHandler := handler.NewChannelHandler(channelService)
|
channelHandler := handler.NewChannelHandler(channelService)
|
||||||
scheduleRepository := repository.NewScheduleRepository(db)
|
scheduleRepository := repository.NewScheduleRepository(db)
|
||||||
|
|||||||
@@ -1014,6 +1014,9 @@ type AdminCommentListResponse struct {
|
|||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Images []CommentImageResponse `json:"images"`
|
Images []CommentImageResponse `json:"images"`
|
||||||
Status string `json:"status"`
|
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"`
|
LikesCount int `json:"likes_count"`
|
||||||
RepliesCount int `json:"replies_count"`
|
RepliesCount int `json:"replies_count"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
@@ -1038,13 +1041,16 @@ type AdminCommentDetailResponse struct {
|
|||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Images []CommentImageResponse `json:"images"`
|
Images []CommentImageResponse `json:"images"`
|
||||||
Status string `json:"status"`
|
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"`
|
LikesCount int `json:"likes_count"`
|
||||||
RepliesCount int `json:"replies_count"`
|
RepliesCount int `json:"replies_count"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
Author *UserResponse `json:"author"`
|
Author *UserResponse `json:"author"`
|
||||||
Post *AdminCommentPostInfo `json:"post"`
|
Post *AdminCommentPostInfo `json:"post"`
|
||||||
ReplyToUser *UserResponse `json:"reply_to_user,omitempty"` // 被回复的用户信息
|
ReplyToUser *UserResponse `json:"reply_to_user,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminBatchDeleteCommentsRequest 批量删除评论请求
|
// AdminBatchDeleteCommentsRequest 批量删除评论请求
|
||||||
@@ -1052,6 +1058,18 @@ type AdminBatchDeleteCommentsRequest struct {
|
|||||||
IDs []string `json:"ids" binding:"required,min=1,max=100,dive,required"`
|
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 ====================
|
// ==================== Admin Group DTOs ====================
|
||||||
|
|
||||||
// AdminGroupListResponse 管理端群组列表响应
|
// AdminGroupListResponse 管理端群组列表响应
|
||||||
|
|||||||
@@ -121,6 +121,58 @@ func (h *AdminCommentHandler) BatchDeleteComments(c *gin.Context) {
|
|||||||
response.Success(c, result)
|
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 获取帖子的评论列表
|
// GetPostComments 获取帖子的评论列表
|
||||||
// GET /api/v1/admin/posts/:postId/comments
|
// GET /api/v1/admin/posts/:postId/comments
|
||||||
func (h *AdminCommentHandler) GetPostComments(c *gin.Context) {
|
func (h *AdminCommentHandler) GetPostComments(c *gin.Context) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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)
|
comment, err := h.commentService.Create(c.Request.Context(), req.PostID, userID, req.Content, req.ParentID, imagesJSON, req.Images)
|
||||||
if err != nil {
|
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")
|
response.InternalServerError(c, "failed to create comment")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ type Comment struct {
|
|||||||
Replies []*Comment `json:"-" gorm:"-"` // 子回复(手动加载,非 GORM 关联)
|
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"`
|
LikesCount int `json:"likes_count" gorm:"default:0"`
|
||||||
|
|||||||
@@ -10,11 +10,8 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PostModerationHook struct {
|
type reviewableError interface {
|
||||||
name string
|
IsReview() bool
|
||||||
postAIService PostAIService
|
|
||||||
postRepo repository.PostRepository
|
|
||||||
strictMode bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PostAIService interface {
|
type PostAIService interface {
|
||||||
@@ -23,6 +20,13 @@ type PostAIService interface {
|
|||||||
ModerateComment(ctx context.Context, content string, images []string) error
|
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 {
|
func NewPostModerationHook(postAIService PostAIService, postRepo repository.PostRepository, strictMode bool) *PostModerationHook {
|
||||||
return &PostModerationHook{
|
return &PostModerationHook{
|
||||||
name: "moderation.post.ai",
|
name: "moderation.post.ai",
|
||||||
@@ -36,9 +40,9 @@ func (h *PostModerationHook) Register(manager *Manager) {
|
|||||||
manager.Register(&Hook{
|
manager.Register(&Hook{
|
||||||
Name: h.name,
|
Name: h.name,
|
||||||
HookType: HookPostPreModerate,
|
HookType: HookPostPreModerate,
|
||||||
Priority: PriorityHighest,
|
Priority: PriorityHigh,
|
||||||
Func: h.Execute,
|
Func: h.Execute,
|
||||||
Async: false, // 必须同步执行,因为调用方需要审核结果
|
Async: false,
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -54,20 +58,32 @@ func (h *PostModerationHook) Execute(ctx *HookContext) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if result.RejectReason != "" || result.NeedsReview {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if h.postAIService == nil || !h.postAIService.IsEnabled() {
|
if h.postAIService == nil || !h.postAIService.IsEnabled() {
|
||||||
result.Approved = true
|
result.Approved = true
|
||||||
result.ReviewedBy = "system"
|
result.ReviewedBy = "system"
|
||||||
zap.L().Debug("AI moderation disabled, auto approve post",
|
|
||||||
zap.String("post_id", data.PostID),
|
|
||||||
)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.postAIService.ModeratePost(ctx.Ctx, data.Title, data.Content, data.Images)
|
err := h.postAIService.ModeratePost(ctx.Ctx, data.Title, data.Content, data.Images)
|
||||||
if err != nil {
|
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.Approved = false
|
||||||
result.RejectReason = rejectErr.UserMessage()
|
result.RejectReason = userErr.UserMessage()
|
||||||
result.ReviewedBy = "ai"
|
result.ReviewedBy = "ai"
|
||||||
zap.L().Info("Post rejected by AI moderation",
|
zap.L().Info("Post rejected by AI moderation",
|
||||||
zap.String("post_id", data.PostID),
|
zap.String("post_id", data.PostID),
|
||||||
@@ -98,9 +114,6 @@ func (h *PostModerationHook) Execute(ctx *HookContext) error {
|
|||||||
|
|
||||||
result.Approved = true
|
result.Approved = true
|
||||||
result.ReviewedBy = "ai"
|
result.ReviewedBy = "ai"
|
||||||
zap.L().Debug("Post approved by AI moderation",
|
|
||||||
zap.String("post_id", data.PostID),
|
|
||||||
)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,9 +137,9 @@ func (h *CommentModerationHook) Register(manager *Manager) {
|
|||||||
manager.Register(&Hook{
|
manager.Register(&Hook{
|
||||||
Name: h.name,
|
Name: h.name,
|
||||||
HookType: HookCommentPreModerate,
|
HookType: HookCommentPreModerate,
|
||||||
Priority: PriorityHighest,
|
Priority: PriorityHigh,
|
||||||
Func: h.Execute,
|
Func: h.Execute,
|
||||||
Async: false, // 必须同步执行,因为调用方需要审核结果
|
Async: false,
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -142,20 +155,32 @@ func (h *CommentModerationHook) Execute(ctx *HookContext) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if result.RejectReason != "" || result.NeedsReview {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if h.postAIService == nil || !h.postAIService.IsEnabled() {
|
if h.postAIService == nil || !h.postAIService.IsEnabled() {
|
||||||
result.Approved = true
|
result.Approved = true
|
||||||
result.ReviewedBy = "system"
|
result.ReviewedBy = "system"
|
||||||
zap.L().Debug("AI moderation disabled, auto approve comment",
|
|
||||||
zap.String("comment_id", data.CommentID),
|
|
||||||
)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.postAIService.ModerateComment(ctx.Ctx, data.Content, data.Images)
|
err := h.postAIService.ModerateComment(ctx.Ctx, data.Content, data.Images)
|
||||||
if err != nil {
|
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.Approved = false
|
||||||
result.RejectReason = rejectErr.UserMessage()
|
result.RejectReason = userErr.UserMessage()
|
||||||
result.ReviewedBy = "ai"
|
result.ReviewedBy = "ai"
|
||||||
zap.L().Info("Comment rejected by AI moderation",
|
zap.L().Info("Comment rejected by AI moderation",
|
||||||
zap.String("comment_id", data.CommentID),
|
zap.String("comment_id", data.CommentID),
|
||||||
@@ -177,7 +202,7 @@ func (h *CommentModerationHook) Execute(ctx *HookContext) error {
|
|||||||
|
|
||||||
result.Approved = true
|
result.Approved = true
|
||||||
result.ReviewedBy = "system"
|
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.String("comment_id", data.CommentID),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
@@ -186,23 +211,20 @@ func (h *CommentModerationHook) Execute(ctx *HookContext) error {
|
|||||||
|
|
||||||
result.Approved = true
|
result.Approved = true
|
||||||
result.ReviewedBy = "ai"
|
result.ReviewedBy = "ai"
|
||||||
zap.L().Debug("Comment approved by AI moderation",
|
|
||||||
zap.String("comment_id", data.CommentID),
|
|
||||||
)
|
|
||||||
return nil
|
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 {
|
type SensitiveWordHook struct {
|
||||||
name string
|
name string
|
||||||
sensitive SensitiveService
|
sensitive SensitiveService
|
||||||
replaceStr string
|
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 {
|
func NewSensitiveWordHook(sensitive SensitiveService, replaceStr string) *SensitiveWordHook {
|
||||||
return &SensitiveWordHook{
|
return &SensitiveWordHook{
|
||||||
name: "moderation.sensitive_word",
|
name: "moderation.sensitive_word",
|
||||||
@@ -215,7 +237,7 @@ func (h *SensitiveWordHook) RegisterPostHook(manager *Manager) {
|
|||||||
manager.Register(&Hook{
|
manager.Register(&Hook{
|
||||||
Name: h.name + ".post",
|
Name: h.name + ".post",
|
||||||
HookType: HookPostPreModerate,
|
HookType: HookPostPreModerate,
|
||||||
Priority: PriorityHigh,
|
Priority: PriorityHighest,
|
||||||
Func: h.executePost,
|
Func: h.executePost,
|
||||||
Async: false,
|
Async: false,
|
||||||
})
|
})
|
||||||
@@ -225,7 +247,7 @@ func (h *SensitiveWordHook) RegisterCommentHook(manager *Manager) {
|
|||||||
manager.Register(&Hook{
|
manager.Register(&Hook{
|
||||||
Name: h.name + ".comment",
|
Name: h.name + ".comment",
|
||||||
HookType: HookCommentPreModerate,
|
HookType: HookCommentPreModerate,
|
||||||
Priority: PriorityHigh,
|
Priority: PriorityHighest,
|
||||||
Func: h.executeComment,
|
Func: h.executeComment,
|
||||||
Async: false,
|
Async: false,
|
||||||
})
|
})
|
||||||
@@ -325,18 +347,18 @@ func NewModerationHooks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *ModerationHooks) RegisterAll(manager *Manager) {
|
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 {
|
if m.sensitiveService != nil {
|
||||||
sensitiveHook := NewSensitiveWordHook(m.sensitiveService, m.replaceStr)
|
sensitiveHook := NewSensitiveWordHook(m.sensitiveService, m.replaceStr)
|
||||||
sensitiveHook.RegisterPostHook(manager)
|
sensitiveHook.RegisterPostHook(manager)
|
||||||
sensitiveHook.RegisterCommentHook(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.L().Info("Registered moderation hooks",
|
||||||
zap.Bool("ai_enabled", m.postAIService != nil && m.postAIService.IsEnabled()),
|
zap.Bool("ai_enabled", m.postAIService != nil && m.postAIService.IsEnabled()),
|
||||||
zap.Bool("sensitive_enabled", m.sensitiveService != nil),
|
zap.Bool("sensitive_enabled", m.sensitiveService != nil),
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ type CommentModeratedHookData struct {
|
|||||||
|
|
||||||
type ModerationResult struct {
|
type ModerationResult struct {
|
||||||
Approved bool
|
Approved bool
|
||||||
|
NeedsReview bool
|
||||||
RejectReason string
|
RejectReason string
|
||||||
ReviewedBy string
|
ReviewedBy string
|
||||||
Error error
|
Error error
|
||||||
|
|||||||
@@ -19,7 +19,50 @@ import (
|
|||||||
xdraw "golang.org/x/image/draw"
|
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 (
|
const (
|
||||||
defaultMaxImagesPerModerationRequest = 1
|
defaultMaxImagesPerModerationRequest = 1
|
||||||
@@ -32,11 +75,24 @@ const (
|
|||||||
maxCompressedPayloadBytes = 1536 * 1024
|
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 {
|
type Client interface {
|
||||||
IsEnabled() bool
|
IsEnabled() bool
|
||||||
Config() Config
|
Config() Config
|
||||||
ModeratePost(ctx context.Context, title, 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) (bool, string, error)
|
ModerateComment(ctx context.Context, content string, images []string) (*ModerationResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type clientImpl struct {
|
type clientImpl struct {
|
||||||
@@ -65,12 +121,11 @@ func (c *clientImpl) Config() Config {
|
|||||||
return c.cfg
|
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() {
|
if !c.IsEnabled() {
|
||||||
return true, "", nil
|
return &ModerationResponse{Result: ModerationResultPass}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建帖子提示词,处理标题或内容为空的情况
|
|
||||||
var prompt strings.Builder
|
var prompt strings.Builder
|
||||||
if strings.TrimSpace(title) != "" {
|
if strings.TrimSpace(title) != "" {
|
||||||
prompt.WriteString("帖子标题:" + title)
|
prompt.WriteString("帖子标题:" + title)
|
||||||
@@ -81,7 +136,6 @@ func (c *clientImpl) ModeratePost(ctx context.Context, title, content string, im
|
|||||||
}
|
}
|
||||||
prompt.WriteString("帖子内容:" + content)
|
prompt.WriteString("帖子内容:" + content)
|
||||||
}
|
}
|
||||||
// 如果标题和内容都为空但有图片,说明是纯图片帖子
|
|
||||||
if prompt.Len() == 0 && len(normalizeImageURLs(images)) > 0 {
|
if prompt.Len() == 0 && len(normalizeImageURLs(images)) > 0 {
|
||||||
prompt.WriteString("帖子内容:[仅包含图片]")
|
prompt.WriteString("帖子内容:[仅包含图片]")
|
||||||
}
|
}
|
||||||
@@ -92,17 +146,15 @@ func (c *clientImpl) ModeratePost(ctx context.Context, title, content string, im
|
|||||||
return c.moderateContentInBatches(ctx, prompt.String(), images)
|
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() {
|
if !c.IsEnabled() {
|
||||||
return true, "", nil
|
return &ModerationResponse{Result: ModerationResultPass}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建评论提示词,处理纯图片评论的情况
|
|
||||||
var prompt strings.Builder
|
var prompt strings.Builder
|
||||||
if strings.TrimSpace(content) != "" {
|
if strings.TrimSpace(content) != "" {
|
||||||
prompt.WriteString("评论内容:" + content)
|
prompt.WriteString("评论内容:" + content)
|
||||||
} else if len(normalizeImageURLs(images)) > 0 {
|
} else if len(normalizeImageURLs(images)) > 0 {
|
||||||
// 评论只有图片没有文字,说明是纯图片评论
|
|
||||||
prompt.WriteString("评论内容:[仅包含图片]")
|
prompt.WriteString("评论内容:[仅包含图片]")
|
||||||
} else {
|
} else {
|
||||||
prompt.WriteString("评论内容:[空]")
|
prompt.WriteString("评论内容:[空]")
|
||||||
@@ -111,7 +163,7 @@ func (c *clientImpl) ModerateComment(ctx context.Context, content string, images
|
|||||||
return c.moderateContentInBatches(ctx, prompt.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)
|
cleanImages := normalizeImageURLs(images)
|
||||||
optimizedImages := c.optimizeImagesForModeration(ctx, cleanImages)
|
optimizedImages := c.optimizeImagesForModeration(ctx, cleanImages)
|
||||||
maxImagesPerRequest := c.maxImagesPerModerationRequest()
|
maxImagesPerRequest := c.maxImagesPerModerationRequest()
|
||||||
@@ -120,7 +172,6 @@ func (c *clientImpl) moderateContentInBatches(ctx context.Context, contentPrompt
|
|||||||
totalBatches = (len(optimizedImages) + maxImagesPerRequest - 1) / maxImagesPerRequest
|
totalBatches = (len(optimizedImages) + maxImagesPerRequest - 1) / maxImagesPerRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
// 图片超过单批上限时分批审核,任一批次拒绝即整体拒绝
|
|
||||||
for i := 0; i < totalBatches; i++ {
|
for i := 0; i < totalBatches; i++ {
|
||||||
start := i * maxImagesPerRequest
|
start := i * maxImagesPerRequest
|
||||||
end := start + maxImagesPerRequest
|
end := start + maxImagesPerRequest
|
||||||
@@ -133,19 +184,19 @@ func (c *clientImpl) moderateContentInBatches(ctx context.Context, contentPrompt
|
|||||||
batchImages = optimizedImages[start:end]
|
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 {
|
if err != nil {
|
||||||
return false, "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !approved {
|
if resp.Result != ModerationResultPass {
|
||||||
if strings.TrimSpace(reason) != "" && totalBatches > 1 {
|
if totalBatches > 1 && strings.TrimSpace(resp.Reason) != "" {
|
||||||
reason = fmt.Sprintf("第%d/%d批图片未通过:%s", i+1, totalBatches, 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(
|
func (c *clientImpl) moderateSingleBatch(
|
||||||
@@ -153,7 +204,7 @@ func (c *clientImpl) moderateSingleBatch(
|
|||||||
contentPrompt string,
|
contentPrompt string,
|
||||||
images []string,
|
images []string,
|
||||||
batchNo, totalBatches int,
|
batchNo, totalBatches int,
|
||||||
) (bool, string, error) {
|
) (*ModerationResponse, error) {
|
||||||
userPrompt := fmt.Sprintf(
|
userPrompt := fmt.Sprintf(
|
||||||
"%s\n图片批次:%d/%d(本次仅提供当前批次图片)",
|
"%s\n图片批次:%d/%d(本次仅提供当前批次图片)",
|
||||||
contentPrompt,
|
contentPrompt,
|
||||||
@@ -168,13 +219,23 @@ func (c *clientImpl) moderateSingleBatch(
|
|||||||
lastErr = err
|
lastErr = err
|
||||||
} else {
|
} else {
|
||||||
parsed := struct {
|
parsed := struct {
|
||||||
Approved bool `json:"approved"`
|
Result string `json:"result"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
}{}
|
}{}
|
||||||
if err := json.Unmarshal([]byte(extractJSONObject(replyText)), &parsed); err != nil {
|
if err := json.Unmarshal([]byte(extractJSONObject(replyText)), &parsed); err != nil {
|
||||||
lastErr = fmt.Errorf("failed to parse moderation result: %w", err)
|
lastErr = fmt.Errorf("failed to parse moderation result: %w", err)
|
||||||
} else {
|
} 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
|
break
|
||||||
}
|
}
|
||||||
if sleepErr := sleepWithBackoff(ctx, attempt); sleepErr != nil {
|
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",
|
"moderation batch %d/%d failed after %d attempts: %w",
|
||||||
batchNo,
|
batchNo,
|
||||||
totalBatches,
|
totalBatches,
|
||||||
@@ -200,13 +261,13 @@ type chatCompletionsRequest struct {
|
|||||||
Messages []chatMessage `json:"messages"`
|
Messages []chatMessage `json:"messages"`
|
||||||
Temperature float64 `json:"temperature,omitempty"`
|
Temperature float64 `json:"temperature,omitempty"`
|
||||||
MaxTokens int `json:"max_tokens,omitempty"`
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
EnableThinking *bool `json:"enable_thinking,omitempty"` // qwen3.5思考模式控制
|
EnableThinking *bool `json:"enable_thinking,omitempty"`
|
||||||
ThinkingBudget *int `json:"thinking_budget,omitempty"` // 思考过程最大token数
|
ThinkingBudget *int `json:"thinking_budget,omitempty"`
|
||||||
ResponseFormat *responseFormatConfig `json:"response_format,omitempty"` // 响应格式
|
ResponseFormat *responseFormatConfig `json:"response_format,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type responseFormatConfig struct {
|
type responseFormatConfig struct {
|
||||||
Type string `json:"type"` // "text" or "json_object"
|
Type string `json:"type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type chatMessage struct {
|
type chatMessage struct {
|
||||||
@@ -266,12 +327,10 @@ func (c *clientImpl) chatCompletion(
|
|||||||
Temperature: temperature,
|
Temperature: temperature,
|
||||||
MaxTokens: maxTokens,
|
MaxTokens: maxTokens,
|
||||||
}
|
}
|
||||||
// 禁用qwen3.5的思考模式,避免产生大量不必要的token消耗
|
|
||||||
falseVal := false
|
falseVal := false
|
||||||
reqBody.EnableThinking = &falseVal
|
reqBody.EnableThinking = &falseVal
|
||||||
zero := 0
|
zero := 0
|
||||||
reqBody.ThinkingBudget = &zero
|
reqBody.ThinkingBudget = &zero
|
||||||
// 使用JSON输出格式
|
|
||||||
reqBody.ResponseFormat = &responseFormatConfig{Type: "json_object"}
|
reqBody.ResponseFormat = &responseFormatConfig{Type: "json_object"}
|
||||||
|
|
||||||
data, err := json.Marshal(reqBody)
|
data, err := json.Marshal(reqBody)
|
||||||
@@ -381,7 +440,6 @@ func extractJSONObject(raw string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *clientImpl) maxImagesPerModerationRequest() int {
|
func (c *clientImpl) maxImagesPerModerationRequest() int {
|
||||||
// 审核固定单图请求,降低单次payload体积,减少超时风险。
|
|
||||||
if c.cfg.ModerationMaxImagesPerRequest <= 0 {
|
if c.cfg.ModerationMaxImagesPerRequest <= 0 {
|
||||||
return defaultMaxImagesPerModerationRequest
|
return defaultMaxImagesPerModerationRequest
|
||||||
}
|
}
|
||||||
@@ -447,7 +505,6 @@ func (c *clientImpl) tryCompressImageForModeration(ctx context.Context, imageURL
|
|||||||
if len(compressed) == 0 || len(compressed) > maxCompressedPayloadBytes {
|
if len(compressed) == 0 || len(compressed) > maxCompressedPayloadBytes {
|
||||||
return imageURL
|
return imageURL
|
||||||
}
|
}
|
||||||
// 压缩效果不明显时直接使用原图URL,避免增大请求体。
|
|
||||||
if len(compressed) >= int(float64(len(originBytes))*0.95) {
|
if len(compressed) >= int(float64(len(originBytes))*0.95) {
|
||||||
return imageURL
|
return imageURL
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ type CommentRepository interface {
|
|||||||
Create(comment *model.Comment) error
|
Create(comment *model.Comment) error
|
||||||
GetByID(id string) (*model.Comment, error)
|
GetByID(id string) (*model.Comment, error)
|
||||||
Update(comment *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
|
Delete(id string) error
|
||||||
ApplyPublishedStats(comment *model.Comment) error
|
ApplyPublishedStats(comment *model.Comment) error
|
||||||
GetByPostID(postID string, page, pageSize int) ([]*model.Comment, int64, error)
|
GetByPostID(postID string, page, pageSize int) ([]*model.Comment, int64, error)
|
||||||
@@ -63,11 +63,17 @@ func (r *commentRepository) Update(comment *model.Comment) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateModerationStatus 更新评论审核状态
|
// UpdateModerationStatus 更新评论审核状态
|
||||||
func (r *commentRepository) UpdateModerationStatus(commentID string, status model.CommentStatus) error {
|
func (r *commentRepository) UpdateModerationStatus(commentID string, status model.CommentStatus, rejectReason string, reviewedBy string) error {
|
||||||
// 审核状态更新属于管理操作,不应影响评论内容更新时间(updated_at)
|
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{}).
|
return r.db.Model(&model.Comment{}).
|
||||||
Where("id = ?", commentID).
|
Where("id = ?", commentID).
|
||||||
UpdateColumn("status", status).Error
|
UpdateColumns(updates).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete 删除评论(软删除,同时清理关联数据)
|
// Delete 删除评论(软删除,同时清理关联数据)
|
||||||
|
|||||||
@@ -552,7 +552,9 @@ func (r *Router) setupRoutes() {
|
|||||||
admin.GET("/comments", r.adminCommentHandler.GetCommentList)
|
admin.GET("/comments", r.adminCommentHandler.GetCommentList)
|
||||||
admin.GET("/comments/:id", r.adminCommentHandler.GetCommentDetail)
|
admin.GET("/comments/:id", r.adminCommentHandler.GetCommentDetail)
|
||||||
admin.DELETE("/comments/:id", r.adminCommentHandler.DeleteComment)
|
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-delete", r.adminCommentHandler.BatchDeleteComments)
|
||||||
|
admin.POST("/comments/batch-moderate", r.adminCommentHandler.BatchModerateComments)
|
||||||
admin.GET("/comments/post/:postId", r.adminCommentHandler.GetPostComments)
|
admin.GET("/comments/post/:postId", r.adminCommentHandler.GetPostComments)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,15 +11,12 @@ import (
|
|||||||
|
|
||||||
// AdminCommentService 管理端评论服务接口
|
// AdminCommentService 管理端评论服务接口
|
||||||
type AdminCommentService interface {
|
type AdminCommentService interface {
|
||||||
// GetCommentList 获取评论列表(分页、搜索、状态筛选)
|
|
||||||
GetCommentList(ctx context.Context, page, pageSize int, keyword, postID, authorID, status, startDate, endDate string) ([]dto.AdminCommentListResponse, int64, error)
|
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)
|
GetCommentDetail(ctx context.Context, commentID string) (*dto.AdminCommentDetailResponse, error)
|
||||||
// DeleteComment 删除评论
|
|
||||||
DeleteComment(ctx context.Context, commentID string) error
|
DeleteComment(ctx context.Context, commentID string) error
|
||||||
// BatchDeleteComments 批量删除评论
|
|
||||||
BatchDeleteComments(ctx context.Context, ids []string) (*dto.AdminBatchOperationResponse, error)
|
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)
|
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
|
}, 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 获取帖子的评论列表
|
// GetCommentsByPostID 获取帖子的评论列表
|
||||||
func (s *adminCommentServiceImpl) GetCommentsByPostID(ctx context.Context, postID string, page, pageSize int) ([]dto.AdminCommentListResponse, int64, error) {
|
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)
|
comments, total, err := s.commentRepo.GetCommentsByPostIDForAdmin(postID, page, pageSize)
|
||||||
@@ -160,6 +205,9 @@ func (s *adminCommentServiceImpl) convertCommentToAdminListResponse(comment *mod
|
|||||||
Content: comment.Content,
|
Content: comment.Content,
|
||||||
Images: images,
|
Images: images,
|
||||||
Status: string(comment.Status),
|
Status: string(comment.Status),
|
||||||
|
RejectReason: comment.RejectReason,
|
||||||
|
ReviewedAt: dto.FormatTimePointer(comment.ReviewedAt),
|
||||||
|
ReviewedBy: comment.ReviewedBy,
|
||||||
LikesCount: comment.LikesCount,
|
LikesCount: comment.LikesCount,
|
||||||
RepliesCount: comment.RepliesCount,
|
RepliesCount: comment.RepliesCount,
|
||||||
CreatedAt: comment.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
CreatedAt: comment.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
@@ -211,6 +259,9 @@ func (s *adminCommentServiceImpl) convertCommentToAdminDetailResponse(comment *m
|
|||||||
Content: comment.Content,
|
Content: comment.Content,
|
||||||
Images: images,
|
Images: images,
|
||||||
Status: string(comment.Status),
|
Status: string(comment.Status),
|
||||||
|
RejectReason: comment.RejectReason,
|
||||||
|
ReviewedAt: dto.FormatTimePointer(comment.ReviewedAt),
|
||||||
|
ReviewedBy: comment.ReviewedBy,
|
||||||
LikesCount: comment.LikesCount,
|
LikesCount: comment.LikesCount,
|
||||||
RepliesCount: comment.RepliesCount,
|
RepliesCount: comment.RepliesCount,
|
||||||
CreatedAt: comment.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
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,
|
postOwnerID string,
|
||||||
) {
|
) {
|
||||||
if s.hookManager == nil {
|
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.L().Warn("Failed to publish comment without hook manager",
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
@@ -149,6 +149,10 @@ func (s *CommentService) reviewCommentAsync(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if !result.Approved {
|
if !result.Approved {
|
||||||
|
if result.NeedsReview {
|
||||||
|
s.invalidatePostCaches(postID)
|
||||||
|
return
|
||||||
|
}
|
||||||
if delErr := s.commentRepo.Delete(commentID); delErr != nil {
|
if delErr := s.commentRepo.Delete(commentID); delErr != nil {
|
||||||
zap.L().Warn("Failed to delete rejected comment",
|
zap.L().Warn("Failed to delete rejected comment",
|
||||||
zap.String("commentID", commentID),
|
zap.String("commentID", commentID),
|
||||||
@@ -159,7 +163,7 @@ func (s *CommentService) reviewCommentAsync(
|
|||||||
return
|
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.L().Warn("Failed to publish comment",
|
||||||
zap.String("commentID", commentID),
|
zap.String("commentID", commentID),
|
||||||
zap.Error(updateErr),
|
zap.Error(updateErr),
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PostModerationRejectedError 帖子审核拒绝错误
|
|
||||||
type PostModerationRejectedError struct {
|
type PostModerationRejectedError struct {
|
||||||
Reason string
|
Reason string
|
||||||
}
|
}
|
||||||
@@ -21,7 +20,6 @@ func (e *PostModerationRejectedError) Error() string {
|
|||||||
return "post rejected by moderation: " + e.Reason
|
return "post rejected by moderation: " + e.Reason
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserMessage 返回给前端的用户可读文案
|
|
||||||
func (e *PostModerationRejectedError) UserMessage() string {
|
func (e *PostModerationRejectedError) UserMessage() string {
|
||||||
if strings.TrimSpace(e.Reason) == "" {
|
if strings.TrimSpace(e.Reason) == "" {
|
||||||
return "内容未通过审核,请修改后重试"
|
return "内容未通过审核,请修改后重试"
|
||||||
@@ -29,7 +27,28 @@ func (e *PostModerationRejectedError) UserMessage() string {
|
|||||||
return strings.TrimSpace(e.Reason)
|
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 {
|
type CommentModerationRejectedError struct {
|
||||||
Reason string
|
Reason string
|
||||||
}
|
}
|
||||||
@@ -41,7 +60,6 @@ func (e *CommentModerationRejectedError) Error() string {
|
|||||||
return "comment rejected by moderation: " + e.Reason
|
return "comment rejected by moderation: " + e.Reason
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserMessage 返回给前端的用户可读文案
|
|
||||||
func (e *CommentModerationRejectedError) UserMessage() string {
|
func (e *CommentModerationRejectedError) UserMessage() string {
|
||||||
if strings.TrimSpace(e.Reason) == "" {
|
if strings.TrimSpace(e.Reason) == "" {
|
||||||
return "评论未通过审核,请修改后重试"
|
return "评论未通过审核,请修改后重试"
|
||||||
@@ -49,6 +67,28 @@ func (e *CommentModerationRejectedError) UserMessage() string {
|
|||||||
return strings.TrimSpace(e.Reason)
|
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 {
|
type PostAIService struct {
|
||||||
openAIClient openai.Client
|
openAIClient openai.Client
|
||||||
}
|
}
|
||||||
@@ -63,13 +103,12 @@ func (s *PostAIService) IsEnabled() bool {
|
|||||||
return s != nil && s.openAIClient != nil && s.openAIClient.IsEnabled()
|
return s != nil && s.openAIClient != nil && s.openAIClient.IsEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModeratePost 审核帖子内容,返回 nil 表示通过
|
|
||||||
func (s *PostAIService) ModeratePost(ctx context.Context, title, content string, images []string) error {
|
func (s *PostAIService) ModeratePost(ctx context.Context, title, content string, images []string) error {
|
||||||
if !s.IsEnabled() {
|
if !s.IsEnabled() {
|
||||||
return nil
|
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 err != nil {
|
||||||
if s.openAIClient.Config().StrictModeration {
|
if s.openAIClient.Config().StrictModeration {
|
||||||
return err
|
return err
|
||||||
@@ -79,19 +118,23 @@ func (s *PostAIService) ModeratePost(ctx context.Context, title, content string,
|
|||||||
)
|
)
|
||||||
return nil
|
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 {
|
func (s *PostAIService) ModerateComment(ctx context.Context, content string, images []string) error {
|
||||||
if !s.IsEnabled() {
|
if !s.IsEnabled() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
approved, reason, err := s.openAIClient.ModerateComment(ctx, content, images)
|
resp, err := s.openAIClient.ModerateComment(ctx, content, images)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s.openAIClient.Config().StrictModeration {
|
if s.openAIClient.Config().StrictModeration {
|
||||||
return err
|
return err
|
||||||
@@ -101,8 +144,13 @@ func (s *PostAIService) ModerateComment(ctx context.Context, content string, ima
|
|||||||
)
|
)
|
||||||
return nil
|
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.Approved {
|
||||||
|
if result.NeedsReview {
|
||||||
|
s.invalidatePostCaches(postID)
|
||||||
|
return
|
||||||
|
}
|
||||||
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusRejected, result.RejectReason, result.ReviewedBy); updateErr != nil {
|
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusRejected, result.RejectReason, result.ReviewedBy); updateErr != nil {
|
||||||
log.Printf("[WARN] Failed to reject post %s: %v", postID, updateErr)
|
log.Printf("[WARN] Failed to reject post %s: %v", postID, updateErr)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"carrot_bbs/internal/cache"
|
"carrot_bbs/internal/cache"
|
||||||
"carrot_bbs/internal/dto"
|
"carrot_bbs/internal/dto"
|
||||||
"carrot_bbs/internal/model"
|
"carrot_bbs/internal/model"
|
||||||
|
"carrot_bbs/internal/pkg/hook"
|
||||||
"carrot_bbs/internal/repository"
|
"carrot_bbs/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,15 +22,18 @@ type VoteService struct {
|
|||||||
cache cache.Cache
|
cache cache.Cache
|
||||||
postAIService *PostAIService
|
postAIService *PostAIService
|
||||||
systemMessageService SystemMessageService
|
systemMessageService SystemMessageService
|
||||||
|
hookManager *hook.Manager
|
||||||
|
logService *LogService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewVoteService 创建投票服务
|
|
||||||
func NewVoteService(
|
func NewVoteService(
|
||||||
voteRepo repository.VoteRepository,
|
voteRepo repository.VoteRepository,
|
||||||
postRepo repository.PostRepository,
|
postRepo repository.PostRepository,
|
||||||
cache cache.Cache,
|
cache cache.Cache,
|
||||||
postAIService *PostAIService,
|
postAIService *PostAIService,
|
||||||
systemMessageService SystemMessageService,
|
systemMessageService SystemMessageService,
|
||||||
|
hookManager *hook.Manager,
|
||||||
|
logService *LogService,
|
||||||
) *VoteService {
|
) *VoteService {
|
||||||
return &VoteService{
|
return &VoteService{
|
||||||
voteRepo: voteRepo,
|
voteRepo: voteRepo,
|
||||||
@@ -37,6 +41,8 @@ func NewVoteService(
|
|||||||
cache: cache,
|
cache: cache,
|
||||||
postAIService: postAIService,
|
postAIService: postAIService,
|
||||||
systemMessageService: systemMessageService,
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.postAIService.ModeratePost(context.Background(), title, content, images)
|
var authorID uint
|
||||||
if err != nil {
|
fmt.Sscanf(userID, "%d", &authorID)
|
||||||
var rejectedErr *PostModerationRejectedError
|
|
||||||
if errors.As(err, &rejectedErr) {
|
result := &hook.ModerationResult{}
|
||||||
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusRejected, rejectedErr.UserMessage(), "ai"); updateErr != nil {
|
s.hookManager.TriggerWithMetadata(context.Background(), hook.HookPostPreModerate, authorID, &hook.PostModerateHookData{
|
||||||
log.Printf("[WARN] Failed to reject vote post %s: %v", postID, updateErr)
|
PostID: postID,
|
||||||
}
|
Title: title,
|
||||||
s.notifyModerationRejected(userID, rejectedErr.Reason)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusRejected, result.RejectReason, result.ReviewedBy); updateErr != nil {
|
||||||
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); updateErr != nil {
|
log.Printf("[WARN] Failed to reject vote post %s: %v", postID, updateErr)
|
||||||
log.Printf("[WARN] Failed to publish vote post %s after moderation error: %v", postID, updateErr)
|
} else {
|
||||||
|
cache.InvalidatePostDetail(s.cache, postID)
|
||||||
|
cache.InvalidatePostList(s.cache)
|
||||||
}
|
}
|
||||||
|
s.notifyModerationRejected(userID, result.RejectReason)
|
||||||
return
|
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)
|
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 {
|
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{
|
response := &dto.PostResponse{
|
||||||
ID: post.ID,
|
ID: post.ID,
|
||||||
UserID: post.UserID,
|
UserID: post.UserID,
|
||||||
Title: post.Title,
|
Title: post.Title,
|
||||||
Content: post.Content,
|
Content: post.Content,
|
||||||
LikesCount: post.LikesCount,
|
LikesCount: post.LikesCount,
|
||||||
CommentsCount: post.CommentsCount,
|
CommentsCount: post.CommentsCount,
|
||||||
FavoritesCount: post.FavoritesCount,
|
FavoritesCount: post.FavoritesCount,
|
||||||
SharesCount: post.SharesCount,
|
SharesCount: post.SharesCount,
|
||||||
ViewsCount: post.ViewsCount,
|
ViewsCount: post.ViewsCount,
|
||||||
IsPinned: post.IsPinned,
|
IsPinned: post.IsPinned,
|
||||||
IsLocked: post.IsLocked,
|
IsLocked: post.IsLocked,
|
||||||
IsVote: post.IsVote,
|
IsVote: post.IsVote,
|
||||||
CreatedAt: dto.FormatTime(post.CreatedAt),
|
CreatedAt: dto.FormatTime(post.CreatedAt),
|
||||||
UpdatedAt: dto.FormatTime(post.UpdatedAt),
|
UpdatedAt: dto.FormatTime(post.UpdatedAt),
|
||||||
ContentEditedAt: dto.FormatTimePointer(post.ContentEditedAt),
|
ContentEditedAt: dto.FormatTimePointer(post.ContentEditedAt),
|
||||||
|
|||||||
@@ -179,8 +179,10 @@ func ProvideVoteService(
|
|||||||
cache cache.Cache,
|
cache cache.Cache,
|
||||||
postAIService *service.PostAIService,
|
postAIService *service.PostAIService,
|
||||||
systemMessageService service.SystemMessageService,
|
systemMessageService service.SystemMessageService,
|
||||||
|
hookManager *hook.Manager,
|
||||||
|
logService *service.LogService,
|
||||||
) *service.VoteService {
|
) *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 {
|
func ProvideChannelService(channelRepo repository.ChannelRepository, cacheBackend cache.Cache) service.ChannelService {
|
||||||
|
|||||||
Reference in New Issue
Block a user