Files
backend/internal/service/post_service.go

590 lines
17 KiB
Go
Raw Permalink Normal View History

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)
}
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)
}
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)
}
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
}
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) 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 {
err := s.postRepo.Update(post)
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) ([]*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 维度,避免过滤查询与全量查询互相污染)
cacheKey := cache.PostListKey("latest", userID, 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)
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)
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)
}
// GetUserPosts 获取用户帖子
func (s *PostService) GetUserPosts(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) {
return s.postRepo.GetUserPosts(userID, page, pageSize)
}
// 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
}