2026-03-09 21:28:58 +08:00
|
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"log"
|
2026-03-21 02:24:11 +08:00
|
|
|
|
"strconv"
|
2026-03-09 21:28:58 +08:00
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"carrot_bbs/internal/cache"
|
|
|
|
|
|
"carrot_bbs/internal/model"
|
feat(api): add cursor-based pagination for posts, comments, messages, groups, and notifications
Implement cursor-based pagination across multiple API endpoints to improve performance and enable efficient infinite scrolling. This replaces traditional offset-based pagination for high-volume data retrieval.
Changes:
- Add cursor pagination DTOs for all entity types (Post, Comment, Message, Conversation, Group, Notification, GroupMember, GroupAnnouncement)
- Implement cursor pagination methods in repositories with support for various sort orders (created_at, updated_at, join_time, seq)
- Add cursor pagination handlers that maintain backward compatibility with existing offset pagination
- Add new API routes for cursor-based endpoints (/cursor suffix)
- Add helper converter functions for pointer slice types in DTO conversions
2026-03-20 23:03:23 +08:00
|
|
|
|
"carrot_bbs/internal/pkg/cursor"
|
2026-03-20 12:23:28 +08:00
|
|
|
|
"carrot_bbs/internal/pkg/hook"
|
2026-03-09 21:28:58 +08:00
|
|
|
|
"carrot_bbs/internal/repository"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// 缓存TTL常量
|
|
|
|
|
|
const (
|
|
|
|
|
|
PostListTTL = 30 * time.Second // 帖子列表缓存30秒
|
|
|
|
|
|
PostListNullTTL = 5 * time.Second
|
|
|
|
|
|
PostListJitterRatio = 0.15
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-13 09:38:18 +08:00
|
|
|
|
// PostService 帖子服务接口
|
|
|
|
|
|
type PostService interface {
|
|
|
|
|
|
// 帖子CRUD
|
2026-03-24 22:27:53 +08:00
|
|
|
|
Create(ctx context.Context, userID, title, content string, images []string, channelID *string) (*model.Post, error)
|
2026-03-13 09:38:18 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
// 帖子列表
|
2026-03-24 22:27:53 +08:00
|
|
|
|
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)
|
2026-03-13 09:38:18 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
feat(api): add cursor-based pagination for posts, comments, messages, groups, and notifications
Implement cursor-based pagination across multiple API endpoints to improve performance and enable efficient infinite scrolling. This replaces traditional offset-based pagination for high-volume data retrieval.
Changes:
- Add cursor pagination DTOs for all entity types (Post, Comment, Message, Conversation, Group, Notification, GroupMember, GroupAnnouncement)
- Implement cursor pagination methods in repositories with support for various sort orders (created_at, updated_at, join_time, seq)
- Add cursor pagination handlers that maintain backward compatibility with existing offset pagination
- Add new API routes for cursor-based endpoints (/cursor suffix)
- Add helper converter functions for pointer slice types in DTO conversions
2026-03-20 23:03:23 +08:00
|
|
|
|
// 游标分页方法
|
2026-03-24 22:27:53 +08:00
|
|
|
|
ListByCursor(ctx context.Context, userID string, includePending bool, channelID *string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error)
|
feat(api): add cursor-based pagination for posts, comments, messages, groups, and notifications
Implement cursor-based pagination across multiple API endpoints to improve performance and enable efficient infinite scrolling. This replaces traditional offset-based pagination for high-volume data retrieval.
Changes:
- Add cursor pagination DTOs for all entity types (Post, Comment, Message, Conversation, Group, Notification, GroupMember, GroupAnnouncement)
- Implement cursor pagination methods in repositories with support for various sort orders (created_at, updated_at, join_time, seq)
- Add cursor pagination handlers that maintain backward compatibility with existing offset pagination
- Add new API routes for cursor-based endpoints (/cursor suffix)
- Add helper converter functions for pointer slice types in DTO conversions
2026-03-20 23:03:23 +08:00
|
|
|
|
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)
|
2026-03-21 02:24:11 +08:00
|
|
|
|
GetFollowingPostsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error)
|
|
|
|
|
|
GetHotPostsByCursor(ctx context.Context, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error)
|
feat(api): add cursor-based pagination for posts, comments, messages, groups, and notifications
Implement cursor-based pagination across multiple API endpoints to improve performance and enable efficient infinite scrolling. This replaces traditional offset-based pagination for high-volume data retrieval.
Changes:
- Add cursor pagination DTOs for all entity types (Post, Comment, Message, Conversation, Group, Notification, GroupMember, GroupAnnouncement)
- Implement cursor pagination methods in repositories with support for various sort orders (created_at, updated_at, join_time, seq)
- Add cursor pagination handlers that maintain backward compatibility with existing offset pagination
- Add new API routes for cursor-based endpoints (/cursor suffix)
- Add helper converter functions for pointer slice types in DTO conversions
2026-03-20 23:03:23 +08:00
|
|
|
|
|
2026-03-13 09:38:18 +08:00
|
|
|
|
// 关注和推荐
|
|
|
|
|
|
GetFollowingPosts(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error)
|
|
|
|
|
|
GetHotPosts(ctx context.Context, 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
|
2026-03-24 05:21:36 +08:00
|
|
|
|
// RecordShare 记录分享(仅已发布帖子计数 +1),返回最新 shares_count
|
|
|
|
|
|
RecordShare(ctx context.Context, postID, userID string) (sharesCount int, err error)
|
2026-03-15 02:25:10 +08:00
|
|
|
|
|
|
|
|
|
|
// 日志服务设置
|
|
|
|
|
|
SetLogService(logService *LogService)
|
2026-03-13 09:38:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// postServiceImpl 帖子服务实现
|
|
|
|
|
|
type postServiceImpl struct {
|
2026-03-26 18:14:16 +08:00
|
|
|
|
postRepo repository.PostRepository
|
2026-03-09 21:28:58 +08:00
|
|
|
|
systemMessageService SystemMessageService
|
|
|
|
|
|
cache cache.Cache
|
|
|
|
|
|
postAIService *PostAIService
|
2026-03-15 02:25:10 +08:00
|
|
|
|
txManager repository.TransactionManager
|
|
|
|
|
|
logService *LogService
|
2026-03-20 12:23:28 +08:00
|
|
|
|
hookManager *hook.Manager
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-26 18:14:16 +08:00
|
|
|
|
func NewPostService(postRepo repository.PostRepository, systemMessageService SystemMessageService, postAIService *PostAIService, cacheBackend cache.Cache, txManager repository.TransactionManager, hookManager *hook.Manager) PostService {
|
2026-03-13 09:38:18 +08:00
|
|
|
|
return &postServiceImpl{
|
2026-03-09 21:28:58 +08:00
|
|
|
|
postRepo: postRepo,
|
|
|
|
|
|
systemMessageService: systemMessageService,
|
2026-03-13 09:38:18 +08:00
|
|
|
|
cache: cacheBackend,
|
2026-03-09 21:28:58 +08:00
|
|
|
|
postAIService: postAIService,
|
2026-03-13 09:38:18 +08:00
|
|
|
|
txManager: txManager,
|
2026-03-15 02:25:10 +08:00
|
|
|
|
logService: nil,
|
2026-03-20 12:23:28 +08:00
|
|
|
|
hookManager: hookManager,
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 02:25:10 +08:00
|
|
|
|
// SetLogService 设置日志服务
|
|
|
|
|
|
func (s *postServiceImpl) SetLogService(logService *LogService) {
|
|
|
|
|
|
s.logService = logService
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
// PostListResult 帖子列表缓存结果
|
|
|
|
|
|
type PostListResult struct {
|
|
|
|
|
|
Posts []*model.Post
|
|
|
|
|
|
Total int64
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Create 创建帖子
|
2026-03-24 22:27:53 +08:00
|
|
|
|
func (s *postServiceImpl) Create(ctx context.Context, userID, title, content string, images []string, channelID *string) (*model.Post, error) {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
post := &model.Post{
|
2026-03-24 22:27:53 +08:00
|
|
|
|
UserID: userID,
|
|
|
|
|
|
ChannelID: channelID,
|
|
|
|
|
|
Title: title,
|
|
|
|
|
|
Content: content,
|
|
|
|
|
|
Status: model.PostStatusPending,
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
err := s.postRepo.Create(post, images)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 02:25:10 +08:00
|
|
|
|
// 记录操作日志
|
|
|
|
|
|
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",
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
// 失效帖子列表缓存
|
|
|
|
|
|
cache.InvalidatePostList(s.cache)
|
|
|
|
|
|
|
2026-03-24 05:18:30 +08:00
|
|
|
|
// 异步执行审核流
|
2026-03-09 21:28:58 +08:00
|
|
|
|
go s.reviewPostAsync(post.ID, userID, title, content, images)
|
|
|
|
|
|
|
|
|
|
|
|
// 重新查询以获取关联的 User 和 Images
|
|
|
|
|
|
return s.postRepo.GetByID(post.ID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) reviewPostAsync(postID, userID, title, content string, images []string) {
|
2026-03-12 08:38:14 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
2026-03-20 12:23:28 +08:00
|
|
|
|
if s.hookManager == nil {
|
2026-03-12 08:38:14 +08:00
|
|
|
|
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); err != nil {
|
2026-03-20 12:23:28 +08:00
|
|
|
|
log.Printf("[WARN] Failed to publish post without hook manager: %v", err)
|
2026-03-10 12:58:23 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
s.invalidatePostCaches(postID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 12:23:28 +08:00
|
|
|
|
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]interface{}{
|
|
|
|
|
|
"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 updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusRejected, result.RejectReason, result.ReviewedBy); updateErr != nil {
|
|
|
|
|
|
log.Printf("[WARN] Failed to reject post %s: %v", postID, updateErr)
|
2026-03-10 12:58:23 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
s.invalidatePostCaches(postID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
2026-03-20 12:23:28 +08:00
|
|
|
|
s.notifyModerationRejected(userID, result.RejectReason)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 12:23:28 +08:00
|
|
|
|
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", result.ReviewedBy); err != nil {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
log.Printf("[WARN] Failed to publish post %s: %v", postID, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-10 12:58:23 +08:00
|
|
|
|
s.invalidatePostCaches(postID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) updateModerationStatusWithRetry(postID string, status model.PostStatus, rejectReason string, reviewedBy string) error {
|
2026-03-12 08:38:14 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) invalidatePostCaches(postID string) {
|
2026-03-10 12:58:23 +08:00
|
|
|
|
cache.InvalidatePostDetail(s.cache, postID)
|
|
|
|
|
|
cache.InvalidatePostList(s.cache)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) notifyModerationRejected(userID, reason string) {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
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获取帖子
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) GetByID(ctx context.Context, id string) (*model.Post, error) {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return s.postRepo.GetByID(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update 更新帖子
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) Update(ctx context.Context, post *model.Post) error {
|
2026-03-10 12:58:23 +08:00
|
|
|
|
return s.UpdateWithImages(ctx, post, nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// UpdateWithImages 更新帖子并可选更新图片(images=nil 表示不更新图片)
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) UpdateWithImages(ctx context.Context, post *model.Post, images *[]string) error {
|
2026-03-10 12:58:23 +08:00
|
|
|
|
err := s.postRepo.UpdateWithImages(post, images)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 失效帖子详情缓存和列表缓存
|
|
|
|
|
|
cache.InvalidatePostDetail(s.cache, post.ID)
|
|
|
|
|
|
cache.InvalidatePostList(s.cache)
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Delete 删除帖子
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) Delete(ctx context.Context, id string) error {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
err := s.postRepo.Delete(id)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 失效帖子详情缓存和列表缓存
|
|
|
|
|
|
cache.InvalidatePostDetail(s.cache, id)
|
|
|
|
|
|
cache.InvalidatePostList(s.cache)
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// List 获取帖子列表(带缓存)
|
2026-03-24 22:27:53 +08:00
|
|
|
|
func (s *postServiceImpl) List(ctx context.Context, page, pageSize int, userID string, includePending bool, channelID *string) ([]*model.Post, int64, error) {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 12:58:23 +08:00
|
|
|
|
// 生成缓存键(包含 userID 维度与可见性维度,避免作者视角污染公开视角)
|
|
|
|
|
|
visibilityUserKey := userID
|
|
|
|
|
|
if includePending && userID != "" {
|
|
|
|
|
|
visibilityUserKey = "owner:" + userID
|
|
|
|
|
|
}
|
2026-03-24 22:27:53 +08:00
|
|
|
|
if channelID != nil && *channelID != "" {
|
|
|
|
|
|
visibilityUserKey += ":channel:" + *channelID
|
|
|
|
|
|
}
|
2026-03-10 12:58:23 +08:00
|
|
|
|
cacheKey := cache.PostListKey("latest", visibilityUserKey, page, pageSize)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
|
2026-03-12 23:40:21 +08:00
|
|
|
|
result, err := cache.GetOrLoadTyped(
|
2026-03-09 21:28:58 +08:00
|
|
|
|
s.cache,
|
|
|
|
|
|
cacheKey,
|
|
|
|
|
|
postListTTL,
|
|
|
|
|
|
jitter,
|
|
|
|
|
|
nullTTL,
|
|
|
|
|
|
func() (*PostListResult, error) {
|
2026-03-24 22:27:53 +08:00
|
|
|
|
posts, total, err := s.postRepo.List(page, pageSize, userID, includePending, channelID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
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 {
|
2026-03-24 22:27:53 +08:00
|
|
|
|
posts, total, loadErr := s.postRepo.List(page, pageSize, userID, includePending, channelID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
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 获取最新帖子(语义化别名)
|
2026-03-24 22:27:53 +08:00
|
|
|
|
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)
|
2026-03-10 12:58:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetLatestPostsForOwner 获取作者视角帖子列表(包含待审核)
|
2026-03-24 22:27:53 +08:00
|
|
|
|
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)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetUserPosts 获取用户帖子
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) GetUserPosts(ctx context.Context, userID string, page, pageSize int, includePending bool) ([]*model.Post, int64, error) {
|
2026-03-10 12:58:23 +08:00
|
|
|
|
return s.postRepo.GetUserPosts(userID, page, pageSize, includePending)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Like 点赞
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) Like(ctx context.Context, postID, userID string) error {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
// 获取帖子信息用于发送通知
|
|
|
|
|
|
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 {
|
2026-03-09 22:18:53 +08:00
|
|
|
|
log.Printf("[ERROR] Error sending like notification: %v", notifyErr)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Unlike 取消点赞
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) Unlike(ctx context.Context, postID, userID string) error {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
err := s.postRepo.Unlike(postID, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 失效帖子详情缓存
|
|
|
|
|
|
cache.InvalidatePostDetail(s.cache, postID)
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// IsLiked 检查是否点赞
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) IsLiked(ctx context.Context, postID, userID string) bool {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return s.postRepo.IsLiked(postID, userID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Favorite 收藏
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) Favorite(ctx context.Context, postID, userID string) error {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
// 获取帖子信息用于发送通知
|
|
|
|
|
|
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 {
|
2026-03-09 22:18:53 +08:00
|
|
|
|
log.Printf("[ERROR] Error sending favorite notification: %v", notifyErr)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Unfavorite 取消收藏
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) Unfavorite(ctx context.Context, postID, userID string) error {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
err := s.postRepo.Unfavorite(postID, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 失效帖子详情缓存
|
|
|
|
|
|
cache.InvalidatePostDetail(s.cache, postID)
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// IsFavorited 检查是否收藏
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) IsFavorited(ctx context.Context, postID, userID string) bool {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return s.postRepo.IsFavorited(postID, userID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 20:52:50 +08:00
|
|
|
|
// GetPostInteractionStatus 批量获取帖子的交互状态(点赞、收藏)
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) GetPostInteractionStatus(ctx context.Context, postIDs []string, userID string) (map[string]bool, map[string]bool, error) {
|
2026-03-10 20:52:50 +08:00
|
|
|
|
isLikedMap := make(map[string]bool)
|
|
|
|
|
|
isFavoritedMap := make(map[string]bool)
|
|
|
|
|
|
|
|
|
|
|
|
if userID == "" || len(postIDs) == 0 {
|
|
|
|
|
|
return isLikedMap, isFavoritedMap, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 00:47:17 +08:00
|
|
|
|
// 使用批量查询替代循环中的单个查询,解决 N+1 问题
|
|
|
|
|
|
isLikedMap = s.postRepo.IsLikedBatch(postIDs, userID)
|
|
|
|
|
|
isFavoritedMap = s.postRepo.IsFavoritedBatch(postIDs, userID)
|
2026-03-10 20:52:50 +08:00
|
|
|
|
|
|
|
|
|
|
return isLikedMap, isFavoritedMap, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetPostInteractionStatusSingle 获取单个帖子的交互状态
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) GetPostInteractionStatusSingle(ctx context.Context, postID, userID string) (isLiked, isFavorited bool) {
|
2026-03-10 20:52:50 +08:00
|
|
|
|
if userID == "" {
|
|
|
|
|
|
return false, false
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.postRepo.IsLiked(postID, userID), s.postRepo.IsFavorited(postID, userID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 05:18:30 +08:00
|
|
|
|
// IncrementViews 增加帖子观看量
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) IncrementViews(ctx context.Context, postID, userID string) error {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if err := s.postRepo.IncrementViews(postID); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 05:21:36 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
// GetFavorites 获取收藏列表
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) GetFavorites(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return s.postRepo.GetFavorites(userID, page, pageSize)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Search 搜索帖子
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) Search(ctx context.Context, keyword string, page, pageSize int) ([]*model.Post, int64, error) {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return s.postRepo.Search(keyword, page, pageSize)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetFollowingPosts 获取关注用户的帖子(带缓存)
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) GetFollowingPosts(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 05:18:30 +08:00
|
|
|
|
// GetHotPosts 获取热门帖子(优先分层缓存 top_ids → Redis ZSET,未就绪时回源 DB 按最新排序)
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func (s *postServiceImpl) GetHotPosts(ctx context.Context, page, pageSize int) ([]*model.Post, int64, error) {
|
2026-03-24 05:18:30 +08:00
|
|
|
|
offset := (page - 1) * pageSize
|
|
|
|
|
|
if posts, total, ok, err := s.tryHotPostsFromTopIDsCache(offset, pageSize); ok {
|
|
|
|
|
|
return posts, total, err
|
|
|
|
|
|
} else if err != nil {
|
|
|
|
|
|
return nil, 0, err
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
2026-03-24 05:18:30 +08:00
|
|
|
|
if posts, total, ok, err := s.tryHotPostsFromZSet(ctx, offset, pageSize); ok {
|
|
|
|
|
|
return posts, total, err
|
|
|
|
|
|
} else if err != nil {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return nil, 0, err
|
|
|
|
|
|
}
|
2026-03-24 05:18:30 +08:00
|
|
|
|
return s.getHotPostsFromDB(ctx, page, pageSize)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 05:18:30 +08:00
|
|
|
|
// 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
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
2026-03-24 05:18:30 +08:00
|
|
|
|
ids, hit := cache.GetTyped[[]string](s.cache, cache.HotRankTopIDsKey())
|
|
|
|
|
|
if !hit || len(ids) == 0 {
|
|
|
|
|
|
return nil, 0, false, nil
|
2026-03-12 23:40:21 +08:00
|
|
|
|
}
|
2026-03-24 05:18:30 +08:00
|
|
|
|
if offset >= len(ids) {
|
|
|
|
|
|
return []*model.Post{}, int64(len(ids)), true, nil
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
2026-03-24 05:18:30 +08:00
|
|
|
|
end := offset + pageSize
|
|
|
|
|
|
if end > len(ids) {
|
|
|
|
|
|
end = len(ids)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
2026-03-24 05:18:30 +08:00
|
|
|
|
pageIDs := ids[offset:end]
|
|
|
|
|
|
posts, err = s.postRepo.GetByIDs(pageIDs)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if err != nil {
|
2026-03-24 05:18:30 +08:00
|
|
|
|
return nil, 0, false, err
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
2026-03-24 05:18:30 +08:00
|
|
|
|
return posts, int64(len(ids)), true, nil
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 05:18:30 +08:00
|
|
|
|
// 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
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
2026-03-24 05:18:30 +08:00
|
|
|
|
key := cache.HotRankZSetKey()
|
|
|
|
|
|
card, zerr := s.cache.ZCard(ctx, key)
|
|
|
|
|
|
if zerr != nil || card == 0 {
|
|
|
|
|
|
return nil, 0, false, nil
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
2026-03-24 05:18:30 +08:00
|
|
|
|
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
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
2026-03-24 05:18:30 +08:00
|
|
|
|
posts, err = s.postRepo.GetByIDs(ids)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, 0, false, err
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
2026-03-24 05:18:30 +08:00
|
|
|
|
return posts, card, true, nil
|
|
|
|
|
|
}
|
2026-03-09 21:28:58 +08:00
|
|
|
|
|
2026-03-24 05:18:30 +08:00
|
|
|
|
// getHotPostsFromDB 热门未就绪时按最新发布降级
|
|
|
|
|
|
func (s *postServiceImpl) getHotPostsFromDB(ctx context.Context, page, pageSize int) ([]*model.Post, int64, error) {
|
|
|
|
|
|
posts, total, err := s.postRepo.GetHotPosts(page, pageSize)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, 0, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return posts, total, nil
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
2026-03-13 09:38:18 +08:00
|
|
|
|
|
|
|
|
|
|
// ========== 事务管理器示例方法 ==========
|
|
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
feat(api): add cursor-based pagination for posts, comments, messages, groups, and notifications
Implement cursor-based pagination across multiple API endpoints to improve performance and enable efficient infinite scrolling. This replaces traditional offset-based pagination for high-volume data retrieval.
Changes:
- Add cursor pagination DTOs for all entity types (Post, Comment, Message, Conversation, Group, Notification, GroupMember, GroupAnnouncement)
- Implement cursor pagination methods in repositories with support for various sort orders (created_at, updated_at, join_time, seq)
- Add cursor pagination handlers that maintain backward compatibility with existing offset pagination
- Add new API routes for cursor-based endpoints (/cursor suffix)
- Add helper converter functions for pointer slice types in DTO conversions
2026-03-20 23:03:23 +08:00
|
|
|
|
|
|
|
|
|
|
// ========== 游标分页方法 ==========
|
|
|
|
|
|
|
|
|
|
|
|
// ListByCursor 游标分页获取帖子列表
|
2026-03-24 22:27:53 +08:00
|
|
|
|
func (s *postServiceImpl) ListByCursor(ctx context.Context, userID string, includePending bool, channelID *string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
|
feat(api): add cursor-based pagination for posts, comments, messages, groups, and notifications
Implement cursor-based pagination across multiple API endpoints to improve performance and enable efficient infinite scrolling. This replaces traditional offset-based pagination for high-volume data retrieval.
Changes:
- Add cursor pagination DTOs for all entity types (Post, Comment, Message, Conversation, Group, Notification, GroupMember, GroupAnnouncement)
- Implement cursor pagination methods in repositories with support for various sort orders (created_at, updated_at, join_time, seq)
- Add cursor pagination handlers that maintain backward compatibility with existing offset pagination
- Add new API routes for cursor-based endpoints (/cursor suffix)
- Add helper converter functions for pointer slice types in DTO conversions
2026-03-20 23:03:23 +08:00
|
|
|
|
// 规范化请求参数
|
|
|
|
|
|
req.Normalize()
|
|
|
|
|
|
|
2026-03-24 22:27:53 +08:00
|
|
|
|
return s.postRepo.GetPostsByCursor(ctx, userID, includePending, channelID, req)
|
feat(api): add cursor-based pagination for posts, comments, messages, groups, and notifications
Implement cursor-based pagination across multiple API endpoints to improve performance and enable efficient infinite scrolling. This replaces traditional offset-based pagination for high-volume data retrieval.
Changes:
- Add cursor pagination DTOs for all entity types (Post, Comment, Message, Conversation, Group, Notification, GroupMember, GroupAnnouncement)
- Implement cursor pagination methods in repositories with support for various sort orders (created_at, updated_at, join_time, seq)
- Add cursor pagination handlers that maintain backward compatibility with existing offset pagination
- Add new API routes for cursor-based endpoints (/cursor suffix)
- Add helper converter functions for pointer slice types in DTO conversions
2026-03-20 23:03:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
2026-03-21 02:24:11 +08:00
|
|
|
|
|
|
|
|
|
|
// GetFollowingPostsByCursor 游标分页获取关注用户的帖子
|
|
|
|
|
|
func (s *postServiceImpl) GetFollowingPostsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
|
|
|
|
|
|
// 规范化请求参数
|
|
|
|
|
|
req.Normalize()
|
|
|
|
|
|
|
|
|
|
|
|
return s.postRepo.GetFollowingPostsByCursor(ctx, userID, req)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetHotPostsByCursor 游标分页获取热门帖子
|
|
|
|
|
|
func (s *postServiceImpl) GetHotPostsByCursor(ctx context.Context, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
|
|
|
|
|
|
// 规范化请求参数
|
|
|
|
|
|
req.Normalize()
|
|
|
|
|
|
|
2026-03-24 05:18:30 +08:00
|
|
|
|
return s.getHotPostsByCursorFromDB(ctx, req)
|
|
|
|
|
|
}
|
2026-03-21 02:24:11 +08:00
|
|
|
|
|
2026-03-24 05:18:30 +08:00
|
|
|
|
// getHotPostsByCursorFromDB 热门游标:优先 ZSET(一次多取 1 条判断 hasMore),否则回源 DB
|
|
|
|
|
|
func (s *postServiceImpl) getHotPostsByCursorFromDB(ctx context.Context, 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
|
2026-03-21 02:24:11 +08:00
|
|
|
|
}
|
2026-03-24 05:18:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if s.cache != nil {
|
|
|
|
|
|
if ids, hit := cache.GetTyped[[]string](s.cache, cache.HotRankTopIDsKey()); 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
|
2026-03-21 02:24:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
return &cursor.CursorPageResult[*model.Post]{
|
2026-03-24 05:18:30 +08:00
|
|
|
|
Items: []*model.Post{},
|
|
|
|
|
|
NextCursor: "",
|
2026-03-21 02:24:11 +08:00
|
|
|
|
PrevCursor: "",
|
2026-03-24 05:18:30 +08:00
|
|
|
|
HasMore: false,
|
2026-03-21 02:24:11 +08:00
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 05:18:30 +08:00
|
|
|
|
key := cache.HotRankZSetKey()
|
|
|
|
|
|
card, zerr := s.cache.ZCard(ctx, key)
|
|
|
|
|
|
if zerr == nil && card > 0 {
|
|
|
|
|
|
stop := int64(offset + req.PageSize)
|
|
|
|
|
|
ids, rerr := s.cache.ZRevRange(ctx, key, 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
|
|
|
|
|
|
}
|
2026-03-21 02:24:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 05:18:30 +08:00
|
|
|
|
page := offset/req.PageSize + 1
|
|
|
|
|
|
posts, _, err := s.postRepo.GetHotPosts(page, req.PageSize+1)
|
2026-03-21 02:24:11 +08:00
|
|
|
|
if err != nil {
|
2026-03-24 05:18:30 +08:00
|
|
|
|
return nil, err
|
2026-03-21 02:24:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 05:18:30 +08:00
|
|
|
|
hasMore := len(posts) > req.PageSize
|
2026-03-21 02:24:11 +08:00
|
|
|
|
if hasMore {
|
2026-03-24 05:18:30 +08:00
|
|
|
|
posts = posts[:req.PageSize]
|
2026-03-21 02:24:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
nextCursor := ""
|
|
|
|
|
|
if hasMore {
|
|
|
|
|
|
nextCursor = strconv.Itoa(offset + req.PageSize)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return &cursor.CursorPageResult[*model.Post]{
|
|
|
|
|
|
Items: posts,
|
|
|
|
|
|
NextCursor: nextCursor,
|
|
|
|
|
|
PrevCursor: "",
|
|
|
|
|
|
HasMore: hasMore,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|