Files
backend/internal/service/post_service.go
lafay b2b55ea52d
All checks were successful
Build Backend / build (push) Successful in 1m56s
Build Backend / build-docker (push) Successful in 1m15s
feat: enhance security with IP banning, ownership checks, and SSRF protection
Add comprehensive security improvements across the application:

- **IP-based login protection**: Implement IP ban system tracking login failures, auto-banning after threshold exceeded
- **Ownership verification**: Add userID parameter to Delete/Update operations for posts and comments to prevent unauthorized modifications
- **SSRF protection**: Add URL and resolved host validation for image URLs in chat and OpenAI client
- **SQL injection prevention**: Add EscapeLikeWildcard utility to escape special characters in LIKE queries
- **HTTP security**: Configure server timeouts and add security headers middleware
- **Rate limiting**: Refactor to support configurable duration and per-endpoint rate limits for auth routes
- **Error handling**: Standardize error responses using HandleError and proper error types
2026-04-30 12:26:25 +08:00

884 lines
28 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"
"fmt"
"log"
"strconv"
"strings"
"time"
apperrors "with_you/internal/errors"
"with_you/internal/cache"
"with_you/internal/dto"
"with_you/internal/model"
"with_you/internal/pkg/cursor"
"with_you/internal/pkg/hook"
"with_you/internal/repository"
)
// 缓存TTL常量
const (
PostListTTL = 30 * time.Second // 帖子列表缓存30秒
PostListNullTTL = 5 * time.Second
PostListJitterRatio = 0.15
)
// PostService 帖子服务接口
type PostService interface {
// 帖子CRUD
Create(ctx context.Context, userID, title, content string, segments model.MessageSegments, images []string, channelID *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, userID string, id string) error
// 帖子列表
List(ctx context.Context, page, pageSize int, userID string, includePending bool, channelID *string) ([]*model.Post, int64, error)
GetLatestPosts(ctx context.Context, page, pageSize int, userID string, channelID *string) ([]*model.Post, int64, error)
GetLatestPostsForOwner(ctx context.Context, page, pageSize int, userID string, channelID *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)
// 游标分页方法
ListByCursor(ctx context.Context, userID string, includePending bool, channelID *string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error)
SearchByCursor(ctx context.Context, keyword string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error)
GetUserPostsByCursor(ctx context.Context, userID string, includePending bool, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error)
GetFollowingPostsByCursor(ctx context.Context, userID string, channelID *string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error)
GetHotPostsByCursor(ctx context.Context, channelID *string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error)
// 关注和推荐
GetFollowingPosts(ctx context.Context, userID string, page, pageSize int, channelID *string) ([]*model.Post, int64, error)
GetHotPosts(ctx context.Context, page, pageSize int, channelID *string) ([]*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
// RecordShare 记录分享(仅已发布帖子计数 +1返回最新 shares_count
RecordShare(ctx context.Context, postID, userID string) (sharesCount int, err error)
}
// postServiceImpl 帖子服务实现
type postServiceImpl struct {
postRepo repository.PostRepository
systemMessageService SystemMessageService
cache cache.Cache
postAIService *PostAIService
txManager repository.TransactionManager
logService *LogService
hookManager *hook.Manager
}
func NewPostService(postRepo repository.PostRepository, systemMessageService SystemMessageService, postAIService *PostAIService, cacheBackend cache.Cache, txManager repository.TransactionManager, hookManager *hook.Manager, logService *LogService) PostService {
return &postServiceImpl{
postRepo: postRepo,
systemMessageService: systemMessageService,
cache: cacheBackend,
postAIService: postAIService,
txManager: txManager,
logService: logService,
hookManager: hookManager,
}
}
// PostListResult 帖子列表缓存结果
type PostListResult struct {
Posts []*model.Post
Total int64
}
// Create 创建帖子
func (s *postServiceImpl) Create(ctx context.Context, userID, title, content string, segments model.MessageSegments, images []string, channelID *string) (*model.Post, error) {
// 如果没有提供 content 但有 segments从 segments 提取文本摘要
if content == "" && len(segments) > 0 {
content = dto.ExtractTextContent(segments)
}
// 如果有 segments 但 content 为空,将 content 设为空字符串NOT NULL 约束)
if content == "" {
content = " "
}
// 检查 segments 中是否包含投票
isVote := dto.HasVoteSegment(segments)
post := &model.Post{
UserID: userID,
ChannelID: channelID,
Title: title,
Content: content,
Segments: segments,
Status: model.PostStatusPending,
IsVote: isVote,
}
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)
// 异步处理 @提及通知
if len(segments) > 0 {
bgCtx := context.WithoutCancel(ctx)
go s.processMentionNotifications(bgCtx, userID, post.ID, post.Title, segments)
}
// 异步执行审核流
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)
}
}()
if s.hookManager == nil {
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); err != nil {
log.Printf("[WARN] Failed to publish post without hook manager: %v", err)
} else {
s.invalidatePostCaches(postID)
}
return
}
var authorID uint
fmt.Sscanf(userID, "%d", &authorID)
result := &hook.ModerationResult{}
s.hookManager.TriggerWithMetadata(context.Background(), hook.HookPostPreModerate, authorID, &hook.PostModerateHookData{
PostID: postID,
Title: title,
Content: content,
Images: images,
AuthorID: authorID,
}, map[string]any{
"result": result,
})
s.hookManager.Trigger(context.Background(), hook.HookPostModerated, authorID, &hook.PostModeratedHookData{
PostID: postID,
AuthorID: authorID,
Approved: result.Approved,
RejectReason: result.RejectReason,
ReviewedBy: result.ReviewedBy,
})
if !result.Approved {
if result.NeedsReview {
s.invalidatePostCaches(postID)
return
}
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusRejected, result.RejectReason, result.ReviewedBy); updateErr != nil {
log.Printf("[WARN] Failed to reject post %s: %v", postID, updateErr)
} else {
s.invalidatePostCaches(postID)
}
s.notifyModerationRejected(userID, result.RejectReason)
return
}
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", result.ReviewedBy); err != nil {
log.Printf("[WARN] Failed to publish post %s: %v", postID, err)
return
}
s.invalidatePostCaches(postID)
}
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)
}
}()
}
// processMentionNotifications 处理帖子中 @提及的通知
func (s *postServiceImpl) processMentionNotifications(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 mention notification to user %s: %v", targetUserID, 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, userID string, id string) error {
post, err := s.postRepo.GetByID(id)
if err != nil {
return err
}
if post.UserID != userID {
return apperrors.ErrForbidden
}
err = s.postRepo.Delete(id)
if err != nil {
return err
}
cache.InvalidatePostDetail(s.cache, id)
cache.InvalidatePostList(s.cache)
return nil
}
// List 获取帖子列表(带缓存)
func (s *postServiceImpl) List(ctx context.Context, page, pageSize int, userID string, includePending bool, channelID *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 维度与可见性维度,避免作者视角污染公开视角)
visibilityUserKey := userID
if includePending && userID != "" {
visibilityUserKey = "owner:" + userID
}
if channelID != nil && *channelID != "" {
visibilityUserKey += ":channel:" + *channelID
}
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, channelID)
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, channelID)
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, channelID *string) ([]*model.Post, int64, error) {
return s.List(ctx, page, pageSize, userID, false, channelID)
}
// GetLatestPostsForOwner 获取作者视角帖子列表(包含待审核)
func (s *postServiceImpl) GetLatestPostsForOwner(ctx context.Context, page, pageSize int, userID string, channelID *string) ([]*model.Post, int64, error) {
return s.List(ctx, page, pageSize, userID, true, channelID)
}
// 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)
}
}()
}
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)
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)
}
}()
}
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)
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 增加帖子观看量
func (s *postServiceImpl) IncrementViews(ctx context.Context, postID, userID string) error {
if err := s.postRepo.IncrementViews(postID); err != nil {
return err
}
return nil
}
// RecordShare 记录分享:仅已发布帖子增加 shares_count并失效帖子详情与列表缓存
func (s *postServiceImpl) RecordShare(ctx context.Context, postID, userID string) (int, error) {
n, err := s.postRepo.IncrementShares(postID)
if err != nil {
return 0, err
}
if s.cache != nil {
cache.InvalidatePostDetail(s.cache, postID)
cache.InvalidatePostList(s.cache)
}
return n, 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, channelID *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
}
cacheSuffix := ""
if channelID != nil && *channelID != "" {
cacheSuffix = ":ch:" + *channelID
}
cacheKey := cache.PostListKey("follow", userID+cacheSuffix, 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, channelID)
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 获取热门帖子(优先分层缓存 top_ids → Redis ZSET未就绪时回源 DB 按最新排序)
func (s *postServiceImpl) GetHotPosts(ctx context.Context, page, pageSize int, channelID *string) ([]*model.Post, int64, error) {
offset := (page - 1) * pageSize
if channelID != nil && *channelID != "" {
if posts, total, ok, err := s.tryHotPostsFromChannelCache(ctx, *channelID, offset, pageSize); ok {
return posts, total, err
} else if err != nil {
return nil, 0, err
}
return s.getHotPostsFromDB(ctx, page, pageSize, channelID)
}
if posts, total, ok, err := s.tryHotPostsFromTopIDsCache(offset, pageSize); ok {
return posts, total, err
} else if err != nil {
return nil, 0, err
}
if posts, total, ok, err := s.tryHotPostsFromZSet(ctx, offset, pageSize); ok {
return posts, total, err
} else if err != nil {
return nil, 0, err
}
return s.getHotPostsFromDB(ctx, page, pageSize, nil)
}
func (s *postServiceImpl) tryHotPostsFromChannelCache(ctx context.Context, channelID string, offset, pageSize int) (posts []*model.Post, total int64, ok bool, err error) {
if s.cache == nil || pageSize <= 0 {
return nil, 0, false, nil
}
topIDsKey := cache.HotRankTopIDsChannelKey(channelID)
ids, hit := cache.GetTyped[[]string](s.cache, topIDsKey)
if hit && len(ids) > 0 {
if offset >= len(ids) {
return []*model.Post{}, int64(len(ids)), true, nil
}
end := offset + pageSize
if end > len(ids) {
end = len(ids)
}
posts, err = s.postRepo.GetByIDs(ids[offset:end])
if err != nil {
return nil, 0, false, err
}
return posts, int64(len(ids)), true, nil
}
zsetKey := cache.HotRankZSetChannelKey(channelID)
card, zerr := s.cache.ZCard(ctx, zsetKey)
if zerr == nil && card > 0 {
ids, zerr = s.cache.ZRevRange(ctx, zsetKey, int64(offset), int64(offset+pageSize-1))
if zerr == nil && len(ids) > 0 {
posts, err = s.postRepo.GetByIDs(ids)
if err != nil {
return nil, 0, false, err
}
return posts, card, true, nil
}
}
return nil, 0, false, nil
}
// tryHotPostsFromTopIDsCache 从热门榜有序 ID 列表取帖(命中 LayeredCache 本地层时可不访问 Redis
func (s *postServiceImpl) tryHotPostsFromTopIDsCache(offset, pageSize int) (posts []*model.Post, total int64, ok bool, err error) {
if s.cache == nil || pageSize <= 0 {
return nil, 0, false, nil
}
ids, hit := cache.GetTyped[[]string](s.cache, cache.HotRankTopIDsKey())
if !hit || len(ids) == 0 {
return nil, 0, false, nil
}
if offset >= len(ids) {
return []*model.Post{}, int64(len(ids)), true, nil
}
end := offset + pageSize
if end > len(ids) {
end = len(ids)
}
pageIDs := ids[offset:end]
posts, err = s.postRepo.GetByIDs(pageIDs)
if err != nil {
return nil, 0, false, err
}
return posts, int64(len(ids)), true, nil
}
// tryHotPostsFromZSet 从定时任务写入的 ZSET 按排名取帖ok=false 表示应回源
func (s *postServiceImpl) tryHotPostsFromZSet(ctx context.Context, offset, pageSize int) (posts []*model.Post, total int64, ok bool, err error) {
if s.cache == nil || pageSize <= 0 {
return nil, 0, false, nil
}
key := cache.HotRankZSetKey()
card, zerr := s.cache.ZCard(ctx, key)
if zerr != nil || card == 0 {
return nil, 0, false, nil
}
stop := int64(offset + pageSize - 1)
ids, zerr := s.cache.ZRevRange(ctx, key, int64(offset), stop)
if zerr != nil || len(ids) == 0 {
return nil, 0, false, nil
}
posts, err = s.postRepo.GetByIDs(ids)
if err != nil {
return nil, 0, false, err
}
return posts, card, true, nil
}
// getHotPostsFromDB 热门未就绪时按最新发布降级
func (s *postServiceImpl) getHotPostsFromDB(ctx context.Context, page, pageSize int, channelID *string) ([]*model.Post, int64, error) {
posts, total, err := s.postRepo.GetHotPosts(page, pageSize, channelID)
if err != nil {
return nil, 0, err
}
return posts, total, nil
}
// ========== 事务管理器示例方法 ==========
// 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)
})
}
// ========== 游标分页方法 ==========
// ListByCursor 游标分页获取帖子列表
func (s *postServiceImpl) ListByCursor(ctx context.Context, userID string, includePending bool, channelID *string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
// 规范化请求参数
req.Normalize()
return s.postRepo.GetPostsByCursor(ctx, userID, includePending, channelID, req)
}
// SearchByCursor 游标分页搜索帖子
func (s *postServiceImpl) SearchByCursor(ctx context.Context, keyword string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
// 规范化请求参数
req.Normalize()
return s.postRepo.SearchPostsByCursor(ctx, keyword, req)
}
// GetUserPostsByCursor 游标分页获取用户帖子
func (s *postServiceImpl) GetUserPostsByCursor(ctx context.Context, userID string, includePending bool, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
// 规范化请求参数
req.Normalize()
return s.postRepo.GetUserPostsByCursor(ctx, userID, includePending, req)
}
// GetFollowingPostsByCursor 游标分页获取关注用户的帖子
func (s *postServiceImpl) GetFollowingPostsByCursor(ctx context.Context, userID string, channelID *string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
req.Normalize()
return s.postRepo.GetFollowingPostsByCursor(ctx, userID, channelID, req)
}
// GetHotPostsByCursor 游标分页获取热门帖子
func (s *postServiceImpl) GetHotPostsByCursor(ctx context.Context, channelID *string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
req.Normalize()
return s.getHotPostsByCursorFromDB(ctx, channelID, req)
}
// getHotPostsByCursorFromDB 热门游标:优先 ZSET一次多取 1 条判断 hasMore否则回源 DB
func (s *postServiceImpl) getHotPostsByCursorFromDB(ctx context.Context, channelID *string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
offset := 0
if req.Cursor != "" {
if parsed, err := strconv.Atoi(req.Cursor); err == nil && parsed >= 0 {
offset = parsed
}
}
if s.cache != nil {
topIDsKey := cache.HotRankTopIDsKey()
zsetKey := cache.HotRankZSetKey()
if channelID != nil && *channelID != "" {
topIDsKey = cache.HotRankTopIDsChannelKey(*channelID)
zsetKey = cache.HotRankZSetChannelKey(*channelID)
}
if ids, hit := cache.GetTyped[[]string](s.cache, topIDsKey); hit && len(ids) > 0 {
if offset < len(ids) {
stop := offset + req.PageSize + 1
if stop > len(ids) {
stop = len(ids)
}
slice := ids[offset:stop]
hasMore := len(slice) > req.PageSize
if hasMore {
slice = slice[:req.PageSize]
}
posts, gerr := s.postRepo.GetByIDs(slice)
if gerr != nil {
return nil, gerr
}
nextCursor := ""
if hasMore {
nextCursor = strconv.Itoa(offset + req.PageSize)
}
return &cursor.CursorPageResult[*model.Post]{
Items: posts,
NextCursor: nextCursor,
PrevCursor: "",
HasMore: hasMore,
}, nil
}
return &cursor.CursorPageResult[*model.Post]{
Items: []*model.Post{},
NextCursor: "",
PrevCursor: "",
HasMore: false,
}, nil
}
card, zerr := s.cache.ZCard(ctx, zsetKey)
if zerr == nil && card > 0 {
stop := int64(offset + req.PageSize)
ids, rerr := s.cache.ZRevRange(ctx, zsetKey, int64(offset), stop)
if rerr == nil && len(ids) > 0 {
hasMore := len(ids) > req.PageSize
if hasMore {
ids = ids[:req.PageSize]
}
posts, gerr := s.postRepo.GetByIDs(ids)
if gerr != nil {
return nil, gerr
}
nextCursor := ""
if hasMore {
nextCursor = strconv.Itoa(offset + req.PageSize)
}
return &cursor.CursorPageResult[*model.Post]{
Items: posts,
NextCursor: nextCursor,
PrevCursor: "",
HasMore: hasMore,
}, nil
}
}
}
page := offset/req.PageSize + 1
posts, _, err := s.postRepo.GetHotPosts(page, req.PageSize+1, channelID)
if err != nil {
return nil, err
}
hasMore := len(posts) > req.PageSize
if hasMore {
posts = posts[:req.PageSize]
}
nextCursor := ""
if hasMore {
nextCursor = strconv.Itoa(offset + req.PageSize)
}
return &cursor.CursorPageResult[*model.Post]{
Items: posts,
NextCursor: nextCursor,
PrevCursor: "",
HasMore: hasMore,
}, nil
}