Files
backend/internal/service/post_service.go
lan 4d8f2ec997 Initial backend repository commit.
Set up project files and add .gitignore to exclude local build/runtime artifacts.

Made-with: Cursor
2026-03-09 21:28:58 +08:00

594 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
fmt.Printf("[DEBUG] Error sending like notification: %v\n", notifyErr)
} else {
fmt.Printf("[DEBUG] Like notification sent successfully\n")
}
}()
}
// 推送点赞行为到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 {
fmt.Printf("[DEBUG] Error sending favorite notification: %v\n", notifyErr)
} else {
fmt.Printf("[DEBUG] Favorite notification sent successfully\n")
}
}()
}
// 推送收藏行为到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
}