From 687ac92aeaab1b2f9aa6c9ee0318d1ee569be051 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Sat, 21 Mar 2026 02:24:11 +0800 Subject: [PATCH] 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. --- internal/handler/post_handler.go | 34 ++++++++- internal/repository/post_repo.go | 110 ++++++++++++++++++++++++++ internal/service/post_service.go | 127 +++++++++++++++++++++++++++++++ 3 files changed, 267 insertions(+), 4 deletions(-) diff --git a/internal/handler/post_handler.go b/internal/handler/post_handler.go index 0705800..3e85cc6 100644 --- a/internal/handler/post_handler.go +++ b/internal/handler/post_handler.go @@ -147,8 +147,9 @@ func (h *PostHandler) RecordView(c *gin.Context) { // List 获取帖子列表(支持游标分页和偏移分页) func (h *PostHandler) List(c *gin.Context) { // 检查是否使用游标分页 - cursorStr := c.Query("cursor") - if cursorStr != "" { + // 当 cursor 参数存在时(包括空字符串),使用游标分页 + _, cursorExists := c.GetQuery("cursor") + if cursorExists { h.ListByCursor(c) return } @@ -220,6 +221,7 @@ func (h *PostHandler) List(c *gin.Context) { func (h *PostHandler) ListByCursor(c *gin.Context) { userID := c.Query("user_id") currentUserID := c.GetString("user_id") + tab := c.Query("tab") // 解析游标分页请求 req := h.parseCursorRequest(c) @@ -227,8 +229,32 @@ func (h *PostHandler) ListByCursor(c *gin.Context) { // 判断是否包含待审核帖子 includePending := userID != "" && userID == currentUserID - // 调用游标分页服务 - result, err := h.postService.ListByCursor(c.Request.Context(), userID, includePending, req) + var result *cursor.CursorPageResult[*model.Post] + 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 { response.InternalServerError(c, "failed to get posts") return diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index c25137c..91b824b 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -906,3 +906,113 @@ func (r *PostRepository) GetUserPostsByCursor(ctx context.Context, userID string 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 +} diff --git a/internal/service/post_service.go b/internal/service/post_service.go index 68174e6..34f6bf7 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -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 +}