feat(api): add cursor-based pagination for posts, comments, messages, groups, and notifications
All checks were successful
Build Backend / build (push) Successful in 4m42s
Build Backend / build-docker (push) Successful in 3m53s

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
This commit is contained in:
lafay
2026-03-20 23:03:23 +08:00
parent 98f0c9f2b6
commit 92babe509f
21 changed files with 2087 additions and 3 deletions

View File

@@ -2,6 +2,8 @@ package repository
import (
"carrot_bbs/internal/model"
"carrot_bbs/internal/pkg/cursor"
"context"
"gorm.io/gorm"
)
@@ -391,3 +393,181 @@ func (r *CommentRepository) GetCommentsByPostIDForAdmin(postID string, page, pag
return comments, total, err
}
// ========== Cursor Pagination Methods ==========
// GetCommentsByCursor 游标分页获取帖子评论(包含回复)
// 排序方式created_at DESC
func (r *CommentRepository) GetCommentsByCursor(ctx context.Context, postID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Comment], error) {
// 构建基础查询 - 只查询顶级评论
query := r.db.WithContext(ctx).Model(&model.Comment{}).
Where("post_id = ? AND parent_id IS NULL AND status = ?", postID, model.CommentStatusPublished)
// 使用游标构建器
builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc).
WithCursor(req.Cursor, req.Direction).
WithPageSize(req.PageSize)
if builder.Error() != nil {
// 无效游标,返回空列表
return cursor.NewCursorPageResult[*model.Comment]([]*model.Comment{}, "", "", false), nil
}
// 执行查询
var comments []*model.Comment
query = builder.Build().Preload("User")
if err := query.Find(&comments).Error; err != nil {
return nil, err
}
// 构建响应
pageSize := builder.GetPageSize()
hasMore := cursor.HasMore(len(comments), pageSize)
if hasMore {
comments = comments[:pageSize]
}
// 加载每个评论的回复默认加载前3条
if len(comments) > 0 {
r.loadRepliesForComments(comments, 3)
}
// 生成游标
var nextCursor, prevCursor string
if len(comments) > 0 {
// 下一页游标
if hasMore {
lastComment := comments[len(comments)-1]
nextCursor = cursor.NewCursor(
cursor.FormatTime(lastComment.CreatedAt),
lastComment.ID,
cursor.SortByCreatedAtDesc,
).Encode()
}
// 上一页游标
firstComment := comments[0]
prevCursor = cursor.NewCursor(
cursor.FormatTime(firstComment.CreatedAt),
firstComment.ID,
cursor.SortByCreatedAtDesc,
).Encode()
}
return cursor.NewCursorPageResult(comments, nextCursor, prevCursor, hasMore), nil
}
// loadRepliesForComments 为评论列表加载回复
func (r *CommentRepository) loadRepliesForComments(comments []*model.Comment, replyLimit int) {
if len(comments) == 0 {
return
}
rootIDs := make([]string, 0, len(comments))
commentsByID := make(map[string]*model.Comment, len(comments))
for _, comment := range comments {
rootIDs = append(rootIDs, comment.ID)
commentsByID[comment.ID] = comment
}
// 批量加载所有回复
var allReplies []*model.Comment
if err := r.db.Where("root_id IN ? AND status = ?", rootIDs, model.CommentStatusPublished).
Preload("User").
Order("created_at ASC").
Find(&allReplies).Error; err != nil {
return
}
// 按 root_id 分组并裁剪
repliesByRoot := make(map[string][]*model.Comment, len(rootIDs))
for _, reply := range allReplies {
if reply.RootID == nil {
continue
}
rootID := *reply.RootID
if replyLimit <= 0 || len(repliesByRoot[rootID]) < replyLimit {
repliesByRoot[rootID] = append(repliesByRoot[rootID], reply)
}
}
// 查询每个根评论的回复总数
type replyCountRow struct {
RootID string
Total int64
}
var replyCountRows []replyCountRow
if err := r.db.Model(&model.Comment{}).
Select("root_id, COUNT(*) AS total").
Where("root_id IN ? AND status = ?", rootIDs, model.CommentStatusPublished).
Group("root_id").
Scan(&replyCountRows).Error; err != nil {
return
}
replyCountMap := make(map[string]int64, len(replyCountRows))
for _, row := range replyCountRows {
replyCountMap[row.RootID] = row.Total
}
// 设置回复和回复数
for _, rootID := range rootIDs {
comment := commentsByID[rootID]
comment.Replies = repliesByRoot[rootID]
comment.RepliesCount = int(replyCountMap[rootID])
}
}
// GetRepliesByCursor 游标分页获取根评论的回复
// 排序方式created_at ASC回复按时间正序
func (r *CommentRepository) GetRepliesByCursor(ctx context.Context, rootID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Comment], error) {
// 构建基础查询
query := r.db.WithContext(ctx).Model(&model.Comment{}).
Where("root_id = ? AND status = ?", rootID, model.CommentStatusPublished)
// 使用游标构建器(回复按时间升序)
builder := cursor.NewBuilder(query, cursor.SortByCreatedAtAsc).
WithCursor(req.Cursor, req.Direction).
WithPageSize(req.PageSize)
if builder.Error() != nil {
// 无效游标,返回空列表
return cursor.NewCursorPageResult[*model.Comment]([]*model.Comment{}, "", "", false), nil
}
// 执行查询
var replies []*model.Comment
query = builder.Build().Preload("User")
if err := query.Find(&replies).Error; err != nil {
return nil, err
}
// 构建响应
pageSize := builder.GetPageSize()
hasMore := cursor.HasMore(len(replies), pageSize)
if hasMore {
replies = replies[:pageSize]
}
// 生成游标
var nextCursor, prevCursor string
if len(replies) > 0 {
// 下一页游标
if hasMore {
lastReply := replies[len(replies)-1]
nextCursor = cursor.NewCursor(
cursor.FormatTime(lastReply.CreatedAt),
lastReply.ID,
cursor.SortByCreatedAtAsc,
).Encode()
}
// 上一页游标
firstReply := replies[0]
prevCursor = cursor.NewCursor(
cursor.FormatTime(firstReply.CreatedAt),
firstReply.ID,
cursor.SortByCreatedAtAsc,
).Encode()
}
return cursor.NewCursorPageResult(replies, nextCursor, prevCursor, hasMore), nil
}