package service import ( "context" "errors" "fmt" "log" "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 struct { postRepo *repository.PostRepository systemMessageService SystemMessageService cache cache.Cache gorseClient gorse.Client postAIService *PostAIService } // NewPostService 创建帖子服务 func NewPostService(postRepo *repository.PostRepository, systemMessageService SystemMessageService, gorseClient gorse.Client, postAIService *PostAIService) *PostService { return &PostService{ postRepo: postRepo, systemMessageService: systemMessageService, cache: cache.GetCache(), gorseClient: gorseClient, postAIService: postAIService, } } // PostListResult 帖子列表缓存结果 type PostListResult struct { Posts []*model.Post Total int64 } // Create 创建帖子 func (s *PostService) 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 } // 失效帖子列表缓存 cache.InvalidatePostList(s.cache) // 同步到Gorse推荐系统(异步) go s.reviewPostAsync(post.ID, userID, title, content, images) // 重新查询以获取关联的 User 和 Images return s.postRepo.GetByID(post.ID) } func (s *PostService) reviewPostAsync(postID, userID, title, content string, images []string) { // 未启用AI时,直接发布 if s.postAIService == nil || !s.postAIService.IsEnabled() { if err := s.postRepo.UpdateModerationStatus(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.postRepo.UpdateModerationStatus(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.postRepo.UpdateModerationStatus(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.postRepo.UpdateModerationStatus(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 *PostService) invalidatePostCaches(postID string) { cache.InvalidatePostDetail(s.cache, postID) cache.InvalidatePostList(s.cache) } func (s *PostService) 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 *PostService) GetByID(ctx context.Context, id string) (*model.Post, error) { return s.postRepo.GetByID(id) } // Update 更新帖子 func (s *PostService) Update(ctx context.Context, post *model.Post) error { return s.UpdateWithImages(ctx, post, nil) } // UpdateWithImages 更新帖子并可选更新图片(images=nil 表示不更新图片) func (s *PostService) 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 *PostService) 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 *PostService) 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[*PostListResult]( 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 *PostService) GetLatestPosts(ctx context.Context, page, pageSize int, userID string) ([]*model.Post, int64, error) { return s.List(ctx, page, pageSize, userID, false) } // GetLatestPostsForOwner 获取作者视角帖子列表(包含待审核) func (s *PostService) GetLatestPostsForOwner(ctx context.Context, page, pageSize int, userID string) ([]*model.Post, int64, error) { return s.List(ctx, page, pageSize, userID, true) } // GetUserPosts 获取用户帖子 func (s *PostService) 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 *PostService) 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 *PostService) 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 *PostService) IsLiked(ctx context.Context, postID, userID string) bool { return s.postRepo.IsLiked(postID, userID) } // Favorite 收藏 func (s *PostService) 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 *PostService) 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 *PostService) IsFavorited(ctx context.Context, postID, userID string) bool { return s.postRepo.IsFavorited(postID, userID) } // IncrementViews 增加帖子观看量并同步到Gorse func (s *PostService) 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 *PostService) GetFavorites(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) { return s.postRepo.GetFavorites(userID, page, pageSize) } // Search 搜索帖子 func (s *PostService) Search(ctx context.Context, keyword string, page, pageSize int) ([]*model.Post, int64, error) { return s.postRepo.Search(keyword, page, pageSize) } // GetFollowingPosts 获取关注用户的帖子(带缓存) func (s *PostService) 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 *PostService) 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 *PostService) 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 *PostService) 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) } // 计算偏移量 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 *PostService) 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 }