package service import ( "context" "errors" "fmt" "log" "math/rand" "strings" "time" "carrot_bbs/internal/cache" "carrot_bbs/internal/model" "carrot_bbs/internal/pkg/gorse" "carrot_bbs/internal/repository" ) // 缓存TTL常量 const ( PostListTTL = 30 * time.Second // 帖子列表缓存30秒 PostListNullTTL = 5 * time.Second PostListJitterRatio = 0.15 anonymousViewUserID = "_anon_view" ) // PostService 帖子服务接口 type PostService interface { // 帖子CRUD Create(ctx context.Context, userID, title, content string, images []string) (*model.Post, error) GetByID(ctx context.Context, id string) (*model.Post, error) Update(ctx context.Context, post *model.Post) error UpdateWithImages(ctx context.Context, post *model.Post, images *[]string) error Delete(ctx context.Context, id string) error // 帖子列表 List(ctx context.Context, page, pageSize int, userID string, includePending bool) ([]*model.Post, int64, error) GetLatestPosts(ctx context.Context, page, pageSize int, userID string) ([]*model.Post, int64, error) GetLatestPostsForOwner(ctx context.Context, page, pageSize int, userID string) ([]*model.Post, int64, error) GetUserPosts(ctx context.Context, userID string, page, pageSize int, includePending bool) ([]*model.Post, int64, error) GetFavorites(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) Search(ctx context.Context, keyword string, page, pageSize int) ([]*model.Post, int64, error) // 关注和推荐 GetFollowingPosts(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) GetHotPosts(ctx context.Context, page, pageSize int) ([]*model.Post, int64, error) GetRecommendedPosts(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) // 交互功能 Like(ctx context.Context, postID, userID string) error Unlike(ctx context.Context, postID, userID string) error IsLiked(ctx context.Context, postID, userID string) bool Favorite(ctx context.Context, postID, userID string) error Unfavorite(ctx context.Context, postID, userID string) error IsFavorited(ctx context.Context, postID, userID string) bool // 交互状态查询 GetPostInteractionStatus(ctx context.Context, postIDs []string, userID string) (map[string]bool, map[string]bool, error) GetPostInteractionStatusSingle(ctx context.Context, postID, userID string) (isLiked, isFavorited bool) // 其他 IncrementViews(ctx context.Context, postID, userID string) error // 日志服务设置 SetLogService(logService *LogService) } // postServiceImpl 帖子服务实现 type postServiceImpl struct { postRepo *repository.PostRepository systemMessageService SystemMessageService cache cache.Cache gorseClient gorse.Client postAIService *PostAIService txManager repository.TransactionManager logService *LogService } // NewPostService 创建帖子服务 func NewPostService(postRepo *repository.PostRepository, systemMessageService SystemMessageService, gorseClient gorse.Client, postAIService *PostAIService, cacheBackend cache.Cache, txManager repository.TransactionManager) PostService { return &postServiceImpl{ postRepo: postRepo, systemMessageService: systemMessageService, cache: cacheBackend, gorseClient: gorseClient, postAIService: postAIService, txManager: txManager, logService: nil, } } // SetLogService 设置日志服务 func (s *postServiceImpl) SetLogService(logService *LogService) { s.logService = logService } // PostListResult 帖子列表缓存结果 type PostListResult struct { Posts []*model.Post Total int64 } // Create 创建帖子 func (s *postServiceImpl) Create(ctx context.Context, userID, title, content string, images []string) (*model.Post, error) { post := &model.Post{ UserID: userID, Title: title, Content: content, Status: model.PostStatusPending, } err := s.postRepo.Create(post, images) if err != nil { return nil, err } // 记录操作日志 if s.logService != nil { s.logService.OperationLog.RecordOperation(&model.OperationLog{ UserID: userID, Operation: string(model.OpPostCreate), Module: "post", TargetType: "post", TargetID: post.ID, Status: "success", }) } // 失效帖子列表缓存 cache.InvalidatePostList(s.cache) // 同步到Gorse推荐系统(异步) go s.reviewPostAsync(post.ID, userID, title, content, images) // 重新查询以获取关联的 User 和 Images return s.postRepo.GetByID(post.ID) } func (s *postServiceImpl) reviewPostAsync(postID, userID, title, content string, images []string) { defer func() { if r := recover(); r != nil { log.Printf("[ERROR] Panic in post moderation async flow, fallback publish post=%s panic=%v", postID, r) if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); err != nil { log.Printf("[WARN] Failed to publish post %s after panic recovery: %v", postID, err) return } s.invalidatePostCaches(postID) } }() // 未启用AI时,直接发布 if s.postAIService == nil || !s.postAIService.IsEnabled() { if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); err != nil { log.Printf("[WARN] Failed to publish post without AI moderation: %v", err) } else { s.invalidatePostCaches(postID) } return } err := s.postAIService.ModeratePost(context.Background(), title, content, images) if err != nil { var rejectedErr *PostModerationRejectedError if errors.As(err, &rejectedErr) { if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusRejected, rejectedErr.UserMessage(), "ai"); updateErr != nil { log.Printf("[WARN] Failed to reject post %s: %v", postID, updateErr) } else { s.invalidatePostCaches(postID) } s.notifyModerationRejected(userID, rejectedErr.Reason) return } // 规则审核不可用时,降级为发布,避免长时间pending if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); updateErr != nil { log.Printf("[WARN] Failed to publish post %s after moderation error: %v", postID, updateErr) } else { s.invalidatePostCaches(postID) } log.Printf("[WARN] Post moderation failed, fallback publish post=%s err=%v", postID, err) return } if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "ai"); err != nil { log.Printf("[WARN] Failed to publish post %s: %v", postID, err) return } s.invalidatePostCaches(postID) if s.gorseClient.IsEnabled() { post, getErr := s.postRepo.GetByID(postID) if getErr != nil { log.Printf("[WARN] Failed to load published post for gorse sync: %v", getErr) return } categories := s.buildPostCategories(post) comment := post.Title textToEmbed := post.Title + " " + post.Content if upsertErr := s.gorseClient.UpsertItemWithEmbedding(context.Background(), post.ID, categories, comment, textToEmbed); upsertErr != nil { log.Printf("[WARN] Failed to upsert item to Gorse: %v", upsertErr) } } } func (s *postServiceImpl) updateModerationStatusWithRetry(postID string, status model.PostStatus, rejectReason string, reviewedBy string) error { const maxAttempts = 3 const retryDelay = 200 * time.Millisecond var lastErr error for attempt := 1; attempt <= maxAttempts; attempt++ { if err := s.postRepo.UpdateModerationStatus(postID, status, rejectReason, reviewedBy); err != nil { lastErr = err if attempt < maxAttempts { log.Printf("[WARN] UpdateModerationStatus failed post=%s attempt=%d/%d err=%v", postID, attempt, maxAttempts, err) time.Sleep(time.Duration(attempt) * retryDelay) continue } } else { return nil } } return lastErr } func (s *postServiceImpl) invalidatePostCaches(postID string) { cache.InvalidatePostDetail(s.cache, postID) cache.InvalidatePostList(s.cache) } func (s *postServiceImpl) notifyModerationRejected(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 { log.Printf("[WARN] Failed to send moderation reject notification: %v", err) } }() } // GetByID 根据ID获取帖子 func (s *postServiceImpl) GetByID(ctx context.Context, id string) (*model.Post, error) { return s.postRepo.GetByID(id) } // Update 更新帖子 func (s *postServiceImpl) Update(ctx context.Context, post *model.Post) error { return s.UpdateWithImages(ctx, post, nil) } // UpdateWithImages 更新帖子并可选更新图片(images=nil 表示不更新图片) func (s *postServiceImpl) UpdateWithImages(ctx context.Context, post *model.Post, images *[]string) error { err := s.postRepo.UpdateWithImages(post, images) if err != nil { return err } // 失效帖子详情缓存和列表缓存 cache.InvalidatePostDetail(s.cache, post.ID) cache.InvalidatePostList(s.cache) return nil } // Delete 删除帖子 func (s *postServiceImpl) Delete(ctx context.Context, id string) error { err := s.postRepo.Delete(id) if err != nil { return err } // 失效帖子详情缓存和列表缓存 cache.InvalidatePostDetail(s.cache, id) cache.InvalidatePostList(s.cache) // 从Gorse中删除帖子(异步) go func() { if s.gorseClient.IsEnabled() { if err := s.gorseClient.DeleteItem(context.Background(), id); err != nil { log.Printf("[WARN] Failed to delete item from Gorse: %v", err) } } }() return nil } // List 获取帖子列表(带缓存) func (s *postServiceImpl) List(ctx context.Context, page, pageSize int, userID string, includePending bool) ([]*model.Post, int64, error) { cacheSettings := cache.GetSettings() postListTTL := cacheSettings.PostListTTL if postListTTL <= 0 { postListTTL = PostListTTL } nullTTL := cacheSettings.NullTTL if nullTTL <= 0 { nullTTL = PostListNullTTL } jitter := cacheSettings.JitterRatio if jitter <= 0 { jitter = PostListJitterRatio } // 生成缓存键(包含 userID 维度与可见性维度,避免作者视角污染公开视角) visibilityUserKey := userID if includePending && userID != "" { visibilityUserKey = "owner:" + userID } cacheKey := cache.PostListKey("latest", visibilityUserKey, page, pageSize) result, err := cache.GetOrLoadTyped( s.cache, cacheKey, postListTTL, jitter, nullTTL, func() (*PostListResult, error) { posts, total, err := s.postRepo.List(page, pageSize, userID, includePending) if err != nil { return nil, err } return &PostListResult{Posts: posts, Total: total}, nil }, ) if err != nil { return nil, 0, err } if result == nil { return []*model.Post{}, 0, nil } // 兼容历史脏缓存:旧缓存序列化会丢失 Post.User,导致前端显示“匿名用户” // 这里检测并回源重建一次缓存,避免在 TTL 内持续返回缺失作者的数据。 missingAuthor := false for _, post := range result.Posts { if post != nil && post.UserID != "" && post.User == nil { missingAuthor = true break } } if missingAuthor { posts, total, loadErr := s.postRepo.List(page, pageSize, userID, includePending) if loadErr != nil { return nil, 0, loadErr } result = &PostListResult{Posts: posts, Total: total} cache.SetWithJitter(s.cache, cacheKey, result, postListTTL, jitter) } return result.Posts, result.Total, nil } // GetLatestPosts 获取最新帖子(语义化别名) func (s *postServiceImpl) GetLatestPosts(ctx context.Context, page, pageSize int, userID string) ([]*model.Post, int64, error) { return s.List(ctx, page, pageSize, userID, false) } // GetLatestPostsForOwner 获取作者视角帖子列表(包含待审核) func (s *postServiceImpl) GetLatestPostsForOwner(ctx context.Context, page, pageSize int, userID string) ([]*model.Post, int64, error) { return s.List(ctx, page, pageSize, userID, true) } // GetUserPosts 获取用户帖子 func (s *postServiceImpl) GetUserPosts(ctx context.Context, userID string, page, pageSize int, includePending bool) ([]*model.Post, int64, error) { return s.postRepo.GetUserPosts(userID, page, pageSize, includePending) } // Like 点赞 func (s *postServiceImpl) Like(ctx context.Context, postID, userID string) error { // 获取帖子信息用于发送通知 post, err := s.postRepo.GetByID(postID) if err != nil { return err } err = s.postRepo.Like(postID, userID) if err != nil { return err } // 失效帖子详情缓存 cache.InvalidatePostDetail(s.cache, postID) // 发送点赞通知(不给自己发通知) if s.systemMessageService != nil && post.UserID != userID { go func() { notifyErr := s.systemMessageService.SendLikeNotification(context.Background(), post.UserID, userID, postID) if notifyErr != nil { log.Printf("[ERROR] Error sending like notification: %v", notifyErr) } }() } // 推送点赞行为到Gorse(异步) go func() { if s.gorseClient.IsEnabled() { if err := s.gorseClient.InsertFeedback(context.Background(), gorse.FeedbackTypeLike, userID, postID); err != nil { log.Printf("[WARN] Failed to insert like feedback to Gorse: %v", err) } } }() return nil } // Unlike 取消点赞 func (s *postServiceImpl) Unlike(ctx context.Context, postID, userID string) error { err := s.postRepo.Unlike(postID, userID) if err != nil { return err } // 失效帖子详情缓存 cache.InvalidatePostDetail(s.cache, postID) // 删除Gorse中的点赞反馈(异步) go func() { if s.gorseClient.IsEnabled() { if err := s.gorseClient.DeleteFeedback(context.Background(), gorse.FeedbackTypeLike, userID, postID); err != nil { log.Printf("[WARN] Failed to delete like feedback from Gorse: %v", err) } } }() return nil } // IsLiked 检查是否点赞 func (s *postServiceImpl) IsLiked(ctx context.Context, postID, userID string) bool { return s.postRepo.IsLiked(postID, userID) } // Favorite 收藏 func (s *postServiceImpl) Favorite(ctx context.Context, postID, userID string) error { // 获取帖子信息用于发送通知 post, err := s.postRepo.GetByID(postID) if err != nil { return err } err = s.postRepo.Favorite(postID, userID) if err != nil { return err } // 失效帖子详情缓存 cache.InvalidatePostDetail(s.cache, postID) // 发送收藏通知(不给自己发通知) if s.systemMessageService != nil && post.UserID != userID { go func() { notifyErr := s.systemMessageService.SendFavoriteNotification(context.Background(), post.UserID, userID, postID) if notifyErr != nil { log.Printf("[ERROR] Error sending favorite notification: %v", notifyErr) } }() } // 推送收藏行为到Gorse(异步) go func() { if s.gorseClient.IsEnabled() { if err := s.gorseClient.InsertFeedback(context.Background(), gorse.FeedbackTypeStar, userID, postID); err != nil { log.Printf("[WARN] Failed to insert favorite feedback to Gorse: %v", err) } } }() return nil } // Unfavorite 取消收藏 func (s *postServiceImpl) Unfavorite(ctx context.Context, postID, userID string) error { err := s.postRepo.Unfavorite(postID, userID) if err != nil { return err } // 失效帖子详情缓存 cache.InvalidatePostDetail(s.cache, postID) // 删除Gorse中的收藏反馈(异步) go func() { if s.gorseClient.IsEnabled() { if err := s.gorseClient.DeleteFeedback(context.Background(), gorse.FeedbackTypeStar, userID, postID); err != nil { log.Printf("[WARN] Failed to delete favorite feedback from Gorse: %v", err) } } }() return nil } // IsFavorited 检查是否收藏 func (s *postServiceImpl) IsFavorited(ctx context.Context, postID, userID string) bool { return s.postRepo.IsFavorited(postID, userID) } // GetPostInteractionStatus 批量获取帖子的交互状态(点赞、收藏) func (s *postServiceImpl) GetPostInteractionStatus(ctx context.Context, postIDs []string, userID string) (map[string]bool, map[string]bool, error) { isLikedMap := make(map[string]bool) isFavoritedMap := make(map[string]bool) if userID == "" || len(postIDs) == 0 { return isLikedMap, isFavoritedMap, nil } // 使用批量查询替代循环中的单个查询,解决 N+1 问题 isLikedMap = s.postRepo.IsLikedBatch(postIDs, userID) isFavoritedMap = s.postRepo.IsFavoritedBatch(postIDs, userID) return isLikedMap, isFavoritedMap, nil } // GetPostInteractionStatusSingle 获取单个帖子的交互状态 func (s *postServiceImpl) GetPostInteractionStatusSingle(ctx context.Context, postID, userID string) (isLiked, isFavorited bool) { if userID == "" { return false, false } return s.postRepo.IsLiked(postID, userID), s.postRepo.IsFavorited(postID, userID) } // IncrementViews 增加帖子观看量并同步到Gorse func (s *postServiceImpl) IncrementViews(ctx context.Context, postID, userID string) error { if err := s.postRepo.IncrementViews(postID); err != nil { return err } // 同步浏览行为到Gorse(异步) go func() { if !s.gorseClient.IsEnabled() { return } feedbackUserID := userID if feedbackUserID == "" { feedbackUserID = anonymousViewUserID } if err := s.gorseClient.InsertFeedback(context.Background(), gorse.FeedbackTypeRead, feedbackUserID, postID); err != nil { log.Printf("[WARN] Failed to insert read feedback to Gorse: %v", err) } }() return nil } // GetFavorites 获取收藏列表 func (s *postServiceImpl) GetFavorites(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) { return s.postRepo.GetFavorites(userID, page, pageSize) } // Search 搜索帖子 func (s *postServiceImpl) Search(ctx context.Context, keyword string, page, pageSize int) ([]*model.Post, int64, error) { return s.postRepo.Search(keyword, page, pageSize) } // GetFollowingPosts 获取关注用户的帖子(带缓存) func (s *postServiceImpl) GetFollowingPosts(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) { cacheSettings := cache.GetSettings() postListTTL := cacheSettings.PostListTTL if postListTTL <= 0 { postListTTL = PostListTTL } nullTTL := cacheSettings.NullTTL if nullTTL <= 0 { nullTTL = PostListNullTTL } jitter := cacheSettings.JitterRatio if jitter <= 0 { jitter = PostListJitterRatio } // 生成缓存键 cacheKey := cache.PostListKey("follow", userID, page, pageSize) result, err := cache.GetOrLoadTyped[*PostListResult]( s.cache, cacheKey, postListTTL, jitter, nullTTL, func() (*PostListResult, error) { posts, total, err := s.postRepo.GetFollowingPosts(userID, page, pageSize) if err != nil { return nil, err } return &PostListResult{Posts: posts, Total: total}, nil }, ) if err != nil { return nil, 0, err } if result == nil { return []*model.Post{}, 0, nil } return result.Posts, result.Total, nil } // GetHotPosts 获取热门帖子(使用Gorse非个性化推荐) func (s *postServiceImpl) GetHotPosts(ctx context.Context, page, pageSize int) ([]*model.Post, int64, error) { // 如果Gorse启用,使用自定义的非个性化推荐器 if s.gorseClient.IsEnabled() { offset := (page - 1) * pageSize // 使用 most_liked_weekly 推荐器获取周热门 // 多取1条用于判断是否还有下一页 itemIDs, err := s.gorseClient.GetNonPersonalized(ctx, "most_liked_weekly", pageSize+1, offset, "") if err != nil { log.Printf("[WARN] Gorse GetNonPersonalized failed: %v, fallback to database", err) return s.getHotPostsFromDB(ctx, page, pageSize) } if len(itemIDs) > 0 { hasNext := len(itemIDs) > pageSize if hasNext { itemIDs = itemIDs[:pageSize] } posts, err := s.postRepo.GetByIDs(itemIDs) if err != nil { return nil, 0, err } // 近似 total:当 hasNext 为 true 时,按分页窗口估算,避免因脏数据/缺失数据导致总页数被低估 estimatedTotal := int64(offset + len(posts)) if hasNext { estimatedTotal = int64(offset + pageSize + 1) } return posts, estimatedTotal, nil } } // 降级:从数据库获取 return s.getHotPostsFromDB(ctx, page, pageSize) } // getHotPostsFromDB 从数据库获取热门帖子(降级路径) func (s *postServiceImpl) getHotPostsFromDB(ctx context.Context, page, pageSize int) ([]*model.Post, int64, error) { // 直接查询数据库,不再使用本地缓存(Gorse失败降级时使用) posts, total, err := s.postRepo.GetHotPosts(page, pageSize) if err != nil { return nil, 0, err } return posts, total, nil } // GetRecommendedPosts 获取推荐帖子 func (s *postServiceImpl) GetRecommendedPosts(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) { // 如果Gorse未启用或用户未登录,降级为热门帖子 if !s.gorseClient.IsEnabled() || userID == "" { return s.GetHotPosts(ctx, page, pageSize) } // 计算偏移量:第一页使用随机偏移量,增加推荐多样性 var offset int if page == 1 { // 第一页随机偏移 0-50,让每次刷新看到不同的推荐内容 offset = rand.Intn(51) } else { offset = (page - 1) * pageSize } // 从Gorse获取推荐列表 // 多取1条用于判断是否还有下一页 itemIDs, err := s.gorseClient.GetRecommend(ctx, userID, pageSize+1, offset) if err != nil { log.Printf("[WARN] Gorse recommendation failed: %v, fallback to hot posts", err) return s.GetHotPosts(ctx, page, pageSize) } // 如果没有推荐结果,降级为热门帖子 if len(itemIDs) == 0 { return s.GetHotPosts(ctx, page, pageSize) } hasNext := len(itemIDs) > pageSize if hasNext { itemIDs = itemIDs[:pageSize] } // 根据ID列表查询帖子详情 posts, err := s.postRepo.GetByIDs(itemIDs) if err != nil { return nil, 0, err } // 近似 total:当 hasNext 为 true 时,按分页窗口估算,避免因脏数据/缺失数据导致总页数被低估 estimatedTotal := int64(offset + len(posts)) if hasNext { estimatedTotal = int64(offset + pageSize + 1) } return posts, estimatedTotal, nil } // buildPostCategories 构建帖子的类别标签 func (s *postServiceImpl) buildPostCategories(post *model.Post) []string { var categories []string // 热度标签 if post.ViewsCount > 1000 { categories = append(categories, "hot_high") } else if post.ViewsCount > 100 { categories = append(categories, "hot_medium") } // 点赞标签 if post.LikesCount > 100 { categories = append(categories, "likes_100+") } else if post.LikesCount > 50 { categories = append(categories, "likes_50+") } else if post.LikesCount > 10 { categories = append(categories, "likes_10+") } // 评论标签 if post.CommentsCount > 50 { categories = append(categories, "comments_50+") } else if post.CommentsCount > 10 { categories = append(categories, "comments_10+") } // 时间标签 age := time.Since(post.CreatedAt) if age < 24*time.Hour { categories = append(categories, "today") } else if age < 7*24*time.Hour { categories = append(categories, "this_week") } else if age < 30*24*time.Hour { categories = append(categories, "this_month") } return categories } // ========== 事务管理器示例方法 ========== // DeletePostWithTransaction 使用事务管理器删除帖子(示例) // 此方法展示如何在 Service 层使用事务管理器控制跨多个 Repository 的事务 // 当需要在一个事务中执行多个 Repository 操作时,可以使用此模式 func (s *postServiceImpl) DeletePostWithTransaction(ctx context.Context, postID string) error { // 使用事务管理器执行事务 return s.txManager.RunInTransaction(ctx, func(ctx context.Context) error { // 在同一个事务中执行删除操作 // Repository 方法会通过 context 获取事务 TX return s.postRepo.DeleteWithContext(ctx, postID) }) }