feat(api): add tab-based post feed endpoints with cursor pagination
Some checks failed
Build Backend / build (push) Successful in 3m58s
Build Backend / build-docker (push) Failing after 4m49s

Add support for different post feed types via tab parameter:
- follow: posts from followed users (requires authentication)
- hot: trending posts using Gorse most_liked_weekly or database fallback
- recommend: personalized recommendations with random offset for diversity
- latest: default chronological feed

The cursor pagination detection is also improved to check for parameter
existence rather than non-empty string value.
This commit is contained in:
lafay
2026-03-21 02:24:11 +08:00
parent 57058b04da
commit 687ac92aea
3 changed files with 267 additions and 4 deletions

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"math/rand"
"strconv"
"strings"
"time"
@@ -45,6 +46,9 @@ type PostService interface {
ListByCursor(ctx context.Context, userID string, includePending bool, 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, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error)
GetHotPostsByCursor(ctx context.Context, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error)
GetRecommendedPostsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error)
// 关注和推荐
GetFollowingPosts(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error)
@@ -792,3 +796,126 @@ func (s *postServiceImpl) GetUserPostsByCursor(ctx context.Context, userID strin
return s.postRepo.GetUserPostsByCursor(ctx, userID, includePending, req)
}
// 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()
// 如果Gorse启用使用自定义的非个性化推荐器
if s.gorseClient.IsEnabled() {
// 计算偏移量
offset := 0
if req.Cursor != "" {
// 尝试解析游标作为偏移量
if parsed, err := strconv.Atoi(req.Cursor); err == nil {
offset = parsed
}
}
// 使用 most_liked_weekly 推荐器获取周热门
// 多取1条用于判断是否还有下一页
itemIDs, err := s.gorseClient.GetNonPersonalized(ctx, "most_liked_weekly", req.PageSize+1, offset, "")
if err != nil {
log.Printf("[WARN] Gorse GetNonPersonalized failed: %v, fallback to database", err)
return s.getHotPostsByCursorFromDB(ctx, req)
}
if len(itemIDs) > 0 {
hasMore := len(itemIDs) > req.PageSize
if hasMore {
itemIDs = itemIDs[:req.PageSize]
}
posts, err := s.postRepo.GetByIDs(itemIDs)
if err != nil {
return nil, err
}
// 下一页的游标是当前偏移量 + 已获取数量
nextCursor := ""
if hasMore {
nextCursor = strconv.Itoa(offset + req.PageSize)
}
return &cursor.CursorPageResult[*model.Post]{
Items: posts,
NextCursor: nextCursor,
PrevCursor: "",
HasMore: hasMore,
}, nil
}
}
// 降级:从数据库获取
return s.getHotPostsByCursorFromDB(ctx, req)
}
// getHotPostsByCursorFromDB 从数据库游标分页获取热门帖子(降级路径)
func (s *postServiceImpl) getHotPostsByCursorFromDB(ctx context.Context, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
return s.postRepo.GetHotPostsByCursor(ctx, req)
}
// GetRecommendedPostsByCursor 游标分页获取推荐帖子
func (s *postServiceImpl) GetRecommendedPostsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
// 规范化请求参数
req.Normalize()
// 如果Gorse未启用或用户未登录降级为热门帖子
if !s.gorseClient.IsEnabled() || userID == "" {
return s.GetHotPostsByCursor(ctx, req)
}
// 计算偏移量:第一页使用随机偏移量,增加推荐多样性
var offset int
if req.Cursor == "" {
// 第一页随机偏移 0-50让每次刷新看到不同的推荐内容
offset = rand.Intn(51)
} else {
// 尝试解析游标作为偏移量
if parsed, err := strconv.Atoi(req.Cursor); err == nil {
offset = parsed
}
}
// 从Gorse获取推荐列表
// 多取1条用于判断是否还有下一页
itemIDs, err := s.gorseClient.GetRecommend(ctx, userID, req.PageSize+1, offset)
if err != nil {
log.Printf("[WARN] Gorse recommendation failed: %v, fallback to hot posts", err)
return s.GetHotPostsByCursor(ctx, req)
}
// 如果没有推荐结果,降级为热门帖子
if len(itemIDs) == 0 {
return s.GetHotPostsByCursor(ctx, req)
}
hasMore := len(itemIDs) > req.PageSize
if hasMore {
itemIDs = itemIDs[:req.PageSize]
}
// 根据ID列表查询帖子详情
posts, err := s.postRepo.GetByIDs(itemIDs)
if err != nil {
return nil, err
}
// 下一页的游标是当前偏移量 + 已获取数量
nextCursor := ""
if hasMore {
nextCursor = strconv.Itoa(offset + req.PageSize)
}
return &cursor.CursorPageResult[*model.Post]{
Items: posts,
NextCursor: nextCursor,
PrevCursor: "",
HasMore: hasMore,
}, nil
}