The moderation system now uses a "pass/review" strategy instead of "pass/review/block", treating hard violations the same as review (only routing to manual review rather than auto-rejecting). Add fallback-approve behavior in the hook system so slow moderation APIs never silently block normal content when they time out or get cancelled - timed-out moderation now approves with "ReviewedBy: system" instead of leaving the zero-value result that would reject content. Update post/comment/vote services and handlers to use the new unified behavior. Simplify moderation prompts to emphasize "宁可放过,不可误伤" (prefer false positives over false negatives).
404 lines
12 KiB
Go
404 lines
12 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
|
||
"with_you/internal/cache"
|
||
"with_you/internal/dto"
|
||
apperrors "with_you/internal/errors"
|
||
"with_you/internal/model"
|
||
"with_you/internal/pkg/cursor"
|
||
"with_you/internal/pkg/hook"
|
||
"with_you/internal/repository"
|
||
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
// CommentService 评论服务接口
|
||
type CommentService interface {
|
||
Create(ctx context.Context, postID, userID, content string, segments model.MessageSegments, parentID *string, images string, imageURLs []string) (*model.Comment, error)
|
||
GetByID(ctx context.Context, id string) (*model.Comment, error)
|
||
GetByPostID(ctx context.Context, postID string, page, pageSize int) ([]*model.Comment, int64, error)
|
||
GetRepliesByRootID(ctx context.Context, rootID string, page, pageSize int) ([]*model.Comment, int64, error)
|
||
GetReplies(ctx context.Context, parentID string) ([]*model.Comment, error)
|
||
Update(ctx context.Context, userID string, comment *model.Comment) error
|
||
Delete(ctx context.Context, userID string, id string) error
|
||
Like(ctx context.Context, commentID, userID string) error
|
||
Unlike(ctx context.Context, commentID, userID string) error
|
||
IsLiked(ctx context.Context, commentID, userID string) bool
|
||
GetCommentsByCursor(ctx context.Context, postID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Comment], error)
|
||
GetRepliesByCursor(ctx context.Context, rootID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Comment], error)
|
||
}
|
||
|
||
type commentService struct {
|
||
commentRepo repository.CommentRepository
|
||
postRepo repository.PostRepository
|
||
systemMessageService SystemMessageService
|
||
cache cache.Cache
|
||
postAIService PostAIService
|
||
logService *LogService
|
||
hookManager *hook.Manager
|
||
}
|
||
|
||
func NewCommentService(commentRepo repository.CommentRepository, postRepo repository.PostRepository, systemMessageService SystemMessageService, postAIService PostAIService, cacheBackend cache.Cache, hookManager *hook.Manager, logService *LogService) CommentService {
|
||
return &commentService{
|
||
commentRepo: commentRepo,
|
||
postRepo: postRepo,
|
||
systemMessageService: systemMessageService,
|
||
cache: cacheBackend,
|
||
postAIService: postAIService,
|
||
logService: logService,
|
||
hookManager: hookManager,
|
||
}
|
||
}
|
||
|
||
// Create 创建评论
|
||
func (s *commentService) Create(ctx context.Context, postID, userID, content string, segments model.MessageSegments, parentID *string, images string, imageURLs []string) (*model.Comment, error) {
|
||
if s.postAIService != nil {
|
||
// 采用异步审核,前端先立即返回
|
||
}
|
||
|
||
// 获取帖子信息用于发送通知
|
||
post, err := s.postRepo.GetByID(postID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
comment := &model.Comment{
|
||
PostID: postID,
|
||
UserID: userID,
|
||
Content: content,
|
||
Segments: segments,
|
||
ParentID: parentID,
|
||
Images: images,
|
||
Status: model.CommentStatusPending,
|
||
}
|
||
|
||
// 如果有父评论,设置根评论ID
|
||
var parentUserID string
|
||
if parentID != nil {
|
||
parent, err := s.commentRepo.GetByID(*parentID)
|
||
if err == nil && parent != nil {
|
||
if parent.RootID != nil {
|
||
comment.RootID = parent.RootID
|
||
} else {
|
||
comment.RootID = parentID
|
||
}
|
||
parentUserID = parent.UserID
|
||
}
|
||
}
|
||
|
||
err = s.commentRepo.Create(comment)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 记录操作日志
|
||
if s.logService != nil {
|
||
s.logService.OperationLog.RecordOperation(&model.OperationLog{
|
||
UserID: userID,
|
||
Operation: string(model.OpCommentCreate),
|
||
Module: "comment",
|
||
TargetType: "comment",
|
||
TargetID: comment.ID,
|
||
Status: "success",
|
||
})
|
||
}
|
||
|
||
// 重新查询以获取关联的 User
|
||
comment, err = s.commentRepo.GetByID(comment.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 异步处理 @提及通知
|
||
if len(segments) > 0 {
|
||
bgCtx := context.WithoutCancel(ctx)
|
||
go s.processCommentMentionNotifications(bgCtx, userID, postID, post.Title, segments)
|
||
}
|
||
|
||
go s.reviewCommentAsync(comment.ID, userID, postID, content, imageURLs, parentID, parentUserID, post.UserID)
|
||
|
||
return comment, nil
|
||
}
|
||
|
||
// processCommentMentionNotifications 处理评论中 @提及的通知
|
||
func (s *commentService) processCommentMentionNotifications(ctx context.Context, authorID, postID, postTitle string, segments model.MessageSegments) {
|
||
if s.systemMessageService == nil {
|
||
return
|
||
}
|
||
|
||
mentionedUserIDs := dto.ExtractMentionedUsers(segments)
|
||
if len(mentionedUserIDs) == 0 {
|
||
return
|
||
}
|
||
|
||
for _, targetUserID := range mentionedUserIDs {
|
||
if targetUserID == authorID {
|
||
continue
|
||
}
|
||
if err := s.systemMessageService.SendMentionNotification(ctx, targetUserID, authorID, postID); err != nil {
|
||
log.Printf("[WARN] Failed to send comment mention notification to user %s: %v", targetUserID, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *commentService) reviewCommentAsync(
|
||
commentID, userID, postID, content string,
|
||
imageURLs []string,
|
||
parentID *string,
|
||
parentUserID string,
|
||
postOwnerID string,
|
||
) {
|
||
if s.hookManager == 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),
|
||
)
|
||
return
|
||
}
|
||
// 计数维护已在 UpdateModerationStatus 事务内完成,无需再单独调用
|
||
s.invalidatePostCaches(postID)
|
||
s.afterCommentPublished(userID, postID, commentID, parentID, parentUserID, postOwnerID)
|
||
return
|
||
}
|
||
|
||
var authorID uint
|
||
fmt.Sscanf(userID, "%d", &authorID)
|
||
|
||
result := &hook.ModerationResult{}
|
||
triggerErr := s.hookManager.TriggerWithMetadata(context.Background(), hook.HookCommentPreModerate, authorID, &hook.CommentModerateHookData{
|
||
CommentID: commentID,
|
||
PostID: postID,
|
||
Content: content,
|
||
Images: imageURLs,
|
||
AuthorID: authorID,
|
||
ParentID: parentID,
|
||
}, map[string]any{
|
||
"result": result,
|
||
})
|
||
|
||
// Fallback-approve when a moderation hook times out or is cancelled, so a
|
||
// slow moderation API never silently rejects an otherwise-fine comment.
|
||
if triggerErr != nil && (errors.Is(triggerErr, context.DeadlineExceeded) || errors.Is(triggerErr, context.Canceled)) {
|
||
zap.L().Warn("Comment moderation hook timed out or was cancelled, fallback approve",
|
||
zap.String("comment_id", commentID),
|
||
zap.Error(triggerErr),
|
||
)
|
||
result.Approved = true
|
||
result.ReviewedBy = "system"
|
||
result.RejectReason = ""
|
||
result.NeedsReview = false
|
||
}
|
||
|
||
s.hookManager.Trigger(context.Background(), hook.HookCommentModerated, authorID, &hook.CommentModeratedHookData{
|
||
CommentID: commentID,
|
||
PostID: postID,
|
||
AuthorID: authorID,
|
||
Approved: result.Approved,
|
||
RejectReason: result.RejectReason,
|
||
ReviewedBy: result.ReviewedBy,
|
||
})
|
||
|
||
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),
|
||
zap.Error(delErr),
|
||
)
|
||
}
|
||
s.notifyCommentModerationRejected(userID, result.RejectReason)
|
||
return
|
||
}
|
||
|
||
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),
|
||
)
|
||
return
|
||
}
|
||
// 计数维护已在 UpdateModerationStatus 事务内完成,无需再单独调用
|
||
s.invalidatePostCaches(postID)
|
||
s.afterCommentPublished(userID, postID, commentID, parentID, parentUserID, postOwnerID)
|
||
}
|
||
|
||
func (s *commentService) invalidatePostCaches(postID string) {
|
||
cache.InvalidatePostDetail(s.cache, postID)
|
||
cache.InvalidatePostList(s.cache)
|
||
}
|
||
|
||
func (s *commentService) afterCommentPublished(userID, postID, commentID string, parentID *string, parentUserID, postOwnerID string) {
|
||
// 发送系统消息通知
|
||
if s.systemMessageService != nil {
|
||
go func() {
|
||
if parentID != nil && parentUserID != "" {
|
||
// 回复评论,通知被回复的人
|
||
if parentUserID != userID {
|
||
notifyErr := s.systemMessageService.SendReplyNotification(context.Background(), parentUserID, userID, postID, *parentID, commentID)
|
||
if notifyErr != nil {
|
||
zap.L().Error("Error sending reply notification",
|
||
zap.Error(notifyErr),
|
||
)
|
||
}
|
||
}
|
||
} else {
|
||
// 评论帖子,通知帖子作者
|
||
if postOwnerID != userID {
|
||
notifyErr := s.systemMessageService.SendCommentNotification(context.Background(), postOwnerID, userID, postID, commentID)
|
||
if notifyErr != nil {
|
||
zap.L().Error("Error sending comment notification",
|
||
zap.Error(notifyErr),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
|
||
}
|
||
|
||
func (s *commentService) notifyCommentModerationRejected(userID, reason string) {
|
||
if s.systemMessageService == nil || strings.TrimSpace(userID) == "" {
|
||
return
|
||
}
|
||
|
||
content := "您发布的评论未通过AI审核,请修改后重试。"
|
||
if strings.TrimSpace(reason) != "" {
|
||
content = fmt.Sprintf("您发布的评论未通过AI审核,原因:%s。请修改后重试。", reason)
|
||
}
|
||
|
||
go func() {
|
||
if err := s.systemMessageService.SendSystemAnnouncement(
|
||
context.Background(),
|
||
[]string{userID},
|
||
"评论审核未通过",
|
||
content,
|
||
); err != nil {
|
||
zap.L().Warn("Failed to send comment moderation reject notification",
|
||
zap.Error(err),
|
||
)
|
||
}
|
||
}()
|
||
}
|
||
|
||
// GetByID 根据ID获取评论
|
||
func (s *commentService) GetByID(ctx context.Context, id string) (*model.Comment, error) {
|
||
return s.commentRepo.GetByID(id)
|
||
}
|
||
|
||
// GetByPostID 获取帖子评论
|
||
func (s *commentService) GetByPostID(ctx context.Context, postID string, page, pageSize int) ([]*model.Comment, int64, error) {
|
||
// 使用带回复的查询,默认加载前3条回复
|
||
return s.commentRepo.GetByPostIDWithReplies(postID, page, pageSize, 3)
|
||
}
|
||
|
||
// GetRepliesByRootID 根据根评论ID分页获取回复
|
||
func (s *commentService) GetRepliesByRootID(ctx context.Context, rootID string, page, pageSize int) ([]*model.Comment, int64, error) {
|
||
return s.commentRepo.GetRepliesByRootID(rootID, page, pageSize)
|
||
}
|
||
|
||
// GetReplies 获取回复
|
||
func (s *commentService) GetReplies(ctx context.Context, parentID string) ([]*model.Comment, error) {
|
||
return s.commentRepo.GetReplies(parentID)
|
||
}
|
||
|
||
// Update 更新评论
|
||
func (s *commentService) Update(ctx context.Context, userID string, comment *model.Comment) error {
|
||
if comment.UserID != userID {
|
||
return apperrors.ErrForbidden
|
||
}
|
||
return s.commentRepo.Update(comment)
|
||
}
|
||
|
||
// Delete 删除评论
|
||
func (s *commentService) Delete(ctx context.Context, userID string, id string) error {
|
||
comment, err := s.commentRepo.GetByID(id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if comment.UserID != userID {
|
||
return apperrors.ErrForbidden
|
||
}
|
||
if err := s.commentRepo.Delete(id); err != nil {
|
||
return err
|
||
}
|
||
s.invalidatePostCaches(comment.PostID)
|
||
return nil
|
||
}
|
||
|
||
// Like 点赞评论
|
||
func (s *commentService) Like(ctx context.Context, commentID, userID string) error {
|
||
// 获取评论信息用于发送通知
|
||
comment, err := s.commentRepo.GetByID(commentID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
err = s.commentRepo.Like(commentID, userID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 发送评论/回复点赞通知(只有不是给自己点赞时才发送)
|
||
if s.systemMessageService != nil && comment.UserID != userID {
|
||
go func() {
|
||
var notifyErr error
|
||
if comment.ParentID != nil {
|
||
notifyErr = s.systemMessageService.SendLikeReplyNotification(
|
||
context.Background(),
|
||
comment.UserID,
|
||
userID,
|
||
comment.PostID,
|
||
commentID,
|
||
comment.Content,
|
||
)
|
||
} else {
|
||
notifyErr = s.systemMessageService.SendLikeCommentNotification(
|
||
context.Background(),
|
||
comment.UserID,
|
||
userID,
|
||
comment.PostID,
|
||
commentID,
|
||
comment.Content,
|
||
)
|
||
}
|
||
if notifyErr != nil {
|
||
zap.L().Error("Error sending like notification",
|
||
zap.Error(notifyErr),
|
||
)
|
||
}
|
||
}()
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// Unlike 取消点赞评论
|
||
func (s *commentService) Unlike(ctx context.Context, commentID, userID string) error {
|
||
return s.commentRepo.Unlike(commentID, userID)
|
||
}
|
||
|
||
// IsLiked 检查是否已点赞
|
||
func (s *commentService) IsLiked(ctx context.Context, commentID, userID string) bool {
|
||
return s.commentRepo.IsLiked(commentID, userID)
|
||
}
|
||
|
||
// GetCommentsByCursor 游标分页获取帖子评论
|
||
func (s *commentService) GetCommentsByCursor(ctx context.Context, postID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Comment], error) {
|
||
return s.commentRepo.GetCommentsByCursor(ctx, postID, req)
|
||
}
|
||
|
||
// GetRepliesByCursor 游标分页获取根评论的回复
|
||
func (s *commentService) GetRepliesByCursor(ctx context.Context, rootID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Comment], error) {
|
||
return s.commentRepo.GetRepliesByCursor(ctx, rootID, req)
|
||
}
|