feat(api): add tab-based post feed endpoints with cursor pagination
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:
@@ -147,8 +147,9 @@ func (h *PostHandler) RecordView(c *gin.Context) {
|
|||||||
// List 获取帖子列表(支持游标分页和偏移分页)
|
// List 获取帖子列表(支持游标分页和偏移分页)
|
||||||
func (h *PostHandler) List(c *gin.Context) {
|
func (h *PostHandler) List(c *gin.Context) {
|
||||||
// 检查是否使用游标分页
|
// 检查是否使用游标分页
|
||||||
cursorStr := c.Query("cursor")
|
// 当 cursor 参数存在时(包括空字符串),使用游标分页
|
||||||
if cursorStr != "" {
|
_, cursorExists := c.GetQuery("cursor")
|
||||||
|
if cursorExists {
|
||||||
h.ListByCursor(c)
|
h.ListByCursor(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -220,6 +221,7 @@ func (h *PostHandler) List(c *gin.Context) {
|
|||||||
func (h *PostHandler) ListByCursor(c *gin.Context) {
|
func (h *PostHandler) ListByCursor(c *gin.Context) {
|
||||||
userID := c.Query("user_id")
|
userID := c.Query("user_id")
|
||||||
currentUserID := c.GetString("user_id")
|
currentUserID := c.GetString("user_id")
|
||||||
|
tab := c.Query("tab")
|
||||||
|
|
||||||
// 解析游标分页请求
|
// 解析游标分页请求
|
||||||
req := h.parseCursorRequest(c)
|
req := h.parseCursorRequest(c)
|
||||||
@@ -227,8 +229,32 @@ func (h *PostHandler) ListByCursor(c *gin.Context) {
|
|||||||
// 判断是否包含待审核帖子
|
// 判断是否包含待审核帖子
|
||||||
includePending := userID != "" && userID == currentUserID
|
includePending := userID != "" && userID == currentUserID
|
||||||
|
|
||||||
// 调用游标分页服务
|
var result *cursor.CursorPageResult[*model.Post]
|
||||||
result, err := h.postService.ListByCursor(c.Request.Context(), userID, includePending, req)
|
var err error
|
||||||
|
|
||||||
|
// 根据 tab 参数选择不同的获取方式
|
||||||
|
switch tab {
|
||||||
|
case "follow":
|
||||||
|
// 获取关注用户的帖子,需要登录
|
||||||
|
if currentUserID == "" {
|
||||||
|
response.Unauthorized(c, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err = h.postService.GetFollowingPostsByCursor(c.Request.Context(), currentUserID, req)
|
||||||
|
case "hot":
|
||||||
|
// 获取热门帖子
|
||||||
|
result, err = h.postService.GetHotPostsByCursor(c.Request.Context(), req)
|
||||||
|
case "recommend":
|
||||||
|
// 推荐帖子(从Gorse获取个性化推荐)
|
||||||
|
result, err = h.postService.GetRecommendedPostsByCursor(c.Request.Context(), currentUserID, req)
|
||||||
|
case "latest":
|
||||||
|
// 最新帖子
|
||||||
|
result, err = h.postService.ListByCursor(c.Request.Context(), userID, includePending, req)
|
||||||
|
default:
|
||||||
|
// 默认获取最新帖子
|
||||||
|
result, err = h.postService.ListByCursor(c.Request.Context(), userID, includePending, req)
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to get posts")
|
response.InternalServerError(c, "failed to get posts")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -906,3 +906,113 @@ func (r *PostRepository) GetUserPostsByCursor(ctx context.Context, userID string
|
|||||||
|
|
||||||
return cursor.NewCursorPageResult(posts, nextCursor, prevCursor, hasMore), nil
|
return cursor.NewCursorPageResult(posts, nextCursor, prevCursor, hasMore), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetFollowingPostsByCursor 游标分页获取关注用户的帖子
|
||||||
|
func (r *PostRepository) GetFollowingPostsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
|
||||||
|
db := r.getDB(ctx).WithContext(ctx)
|
||||||
|
|
||||||
|
// 子查询:获取当前用户关注的所有用户ID
|
||||||
|
subQuery := db.Model(&model.Follow{}).Where("follower_id = ?", userID).Select("following_id")
|
||||||
|
|
||||||
|
// 构建基础查询
|
||||||
|
query := db.Model(&model.Post{}).Where("user_id IN (?) AND status = ?", subQuery, model.PostStatusPublished)
|
||||||
|
|
||||||
|
// 使用游标构建器
|
||||||
|
builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc).
|
||||||
|
WithCursor(req.Cursor, req.Direction).
|
||||||
|
WithPageSize(req.PageSize)
|
||||||
|
|
||||||
|
if builder.Error() != nil {
|
||||||
|
// 无效游标,返回空列表
|
||||||
|
return cursor.NewCursorPageResult[*model.Post]([]*model.Post{}, "", "", false), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行查询
|
||||||
|
var posts []*model.Post
|
||||||
|
query = builder.Build().Preload("User").Preload("Images")
|
||||||
|
if err := query.Find(&posts).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建响应
|
||||||
|
pageSize := builder.GetPageSize()
|
||||||
|
hasMore := cursor.HasMore(len(posts), pageSize)
|
||||||
|
if hasMore {
|
||||||
|
posts = posts[:pageSize]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成游标
|
||||||
|
var nextCursor, prevCursor string
|
||||||
|
if len(posts) > 0 {
|
||||||
|
if hasMore {
|
||||||
|
lastPost := posts[len(posts)-1]
|
||||||
|
nextCursor = cursor.NewCursor(
|
||||||
|
cursor.FormatTime(lastPost.CreatedAt),
|
||||||
|
lastPost.ID,
|
||||||
|
cursor.SortByCreatedAtDesc,
|
||||||
|
).Encode()
|
||||||
|
}
|
||||||
|
firstPost := posts[0]
|
||||||
|
prevCursor = cursor.NewCursor(
|
||||||
|
cursor.FormatTime(firstPost.CreatedAt),
|
||||||
|
firstPost.ID,
|
||||||
|
cursor.SortByCreatedAtDesc,
|
||||||
|
).Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
return cursor.NewCursorPageResult(posts, nextCursor, prevCursor, hasMore), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHotPostsByCursor 游标分页获取热门帖子
|
||||||
|
func (r *PostRepository) GetHotPostsByCursor(ctx context.Context, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
|
||||||
|
db := r.getDB(ctx).WithContext(ctx)
|
||||||
|
|
||||||
|
// 构建基础查询 - 热门帖子按热度分数排序
|
||||||
|
query := db.Model(&model.Post{}).Where("status = ?", model.PostStatusPublished).
|
||||||
|
Order("hot_score DESC, created_at DESC")
|
||||||
|
|
||||||
|
// 使用游标构建器
|
||||||
|
builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc).
|
||||||
|
WithCursor(req.Cursor, req.Direction).
|
||||||
|
WithPageSize(req.PageSize)
|
||||||
|
|
||||||
|
if builder.Error() != nil {
|
||||||
|
// 无效游标,返回空列表
|
||||||
|
return cursor.NewCursorPageResult[*model.Post]([]*model.Post{}, "", "", false), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行查询
|
||||||
|
var posts []*model.Post
|
||||||
|
query = builder.Build().Preload("User").Preload("Images")
|
||||||
|
if err := query.Find(&posts).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建响应
|
||||||
|
pageSize := builder.GetPageSize()
|
||||||
|
hasMore := cursor.HasMore(len(posts), pageSize)
|
||||||
|
if hasMore {
|
||||||
|
posts = posts[:pageSize]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成游标
|
||||||
|
var nextCursor, prevCursor string
|
||||||
|
if len(posts) > 0 {
|
||||||
|
if hasMore {
|
||||||
|
lastPost := posts[len(posts)-1]
|
||||||
|
nextCursor = cursor.NewCursor(
|
||||||
|
cursor.FormatTime(lastPost.CreatedAt),
|
||||||
|
lastPost.ID,
|
||||||
|
cursor.SortByCreatedAtDesc,
|
||||||
|
).Encode()
|
||||||
|
}
|
||||||
|
firstPost := posts[0]
|
||||||
|
prevCursor = cursor.NewCursor(
|
||||||
|
cursor.FormatTime(firstPost.CreatedAt),
|
||||||
|
firstPost.ID,
|
||||||
|
cursor.SortByCreatedAtDesc,
|
||||||
|
).Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
return cursor.NewCursorPageResult(posts, nextCursor, prevCursor, hasMore), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -45,6 +46,9 @@ type PostService interface {
|
|||||||
ListByCursor(ctx context.Context, userID string, includePending bool, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error)
|
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)
|
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)
|
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)
|
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)
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user