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"
)
@@ -43,6 +45,12 @@ type GroupRepository interface {
UpdateGroupStatus(groupID string, status string) error
GetGroupPostCount(groupID string) (int64, error)
GetMembersWithUserInfo(groupID string, page, pageSize int) ([]GroupMemberWithUser, int64, error)
// 游标分页方法
GetGroupsByCursor(ctx context.Context, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Group], error)
GetUserGroupsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Group], error)
GetMembersByCursor(ctx context.Context, groupID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.GroupMember], error)
GetAnnouncementsByCursor(ctx context.Context, groupID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.GroupAnnouncement], error)
}
// GroupMemberWithUser 群成员带用户信息的结构
@@ -410,3 +418,227 @@ func (r *groupRepository) GetMembersWithUserInfo(groupID string, page, pageSize
return members, total, nil
}
// ========== Cursor Pagination Methods ==========
// GetGroupsByCursor 游标分页获取群组列表
// 排序方式created_at DESC
func (r *groupRepository) GetGroupsByCursor(ctx context.Context, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Group], error) {
// 构建基础查询
query := r.db.WithContext(ctx).Model(&model.Group{})
// 使用游标构建器
builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc).
WithCursor(req.Cursor, req.Direction).
WithPageSize(req.PageSize)
if builder.Error() != nil {
// 无效游标,返回空列表
return cursor.NewCursorPageResult[*model.Group]([]*model.Group{}, "", "", false), nil
}
// 执行查询
var groups []*model.Group
query = builder.Build()
if err := query.Find(&groups).Error; err != nil {
return nil, err
}
// 构建响应
pageSize := builder.GetPageSize()
hasMore := cursor.HasMore(len(groups), pageSize)
if hasMore {
groups = groups[:pageSize]
}
// 生成游标
var nextCursor, prevCursor string
if len(groups) > 0 {
// 下一页游标
if hasMore {
lastGroup := groups[len(groups)-1]
nextCursor = cursor.NewCursor(
cursor.FormatTime(lastGroup.CreatedAt),
lastGroup.ID,
cursor.SortByCreatedAtDesc,
).Encode()
}
// 上一页游标
firstGroup := groups[0]
prevCursor = cursor.NewCursor(
cursor.FormatTime(firstGroup.CreatedAt),
firstGroup.ID,
cursor.SortByCreatedAtDesc,
).Encode()
}
return cursor.NewCursorPageResult(groups, nextCursor, prevCursor, hasMore), nil
}
// GetUserGroupsByCursor 游标分页获取用户加入的群组列表
// 排序方式created_at DESC
func (r *groupRepository) GetUserGroupsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Group], error) {
// 通过群成员表查询用户加入的群组
subQuery := r.db.Model(&model.GroupMember{}).
Select("group_id").
Where("user_id = ?", userID)
// 构建基础查询
query := r.db.WithContext(ctx).Model(&model.Group{}).Where("id IN (?)", subQuery)
// 使用游标构建器
builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc).
WithCursor(req.Cursor, req.Direction).
WithPageSize(req.PageSize)
if builder.Error() != nil {
// 无效游标,返回空列表
return cursor.NewCursorPageResult[*model.Group]([]*model.Group{}, "", "", false), nil
}
// 执行查询
var groups []*model.Group
query = builder.Build()
if err := query.Find(&groups).Error; err != nil {
return nil, err
}
// 构建响应
pageSize := builder.GetPageSize()
hasMore := cursor.HasMore(len(groups), pageSize)
if hasMore {
groups = groups[:pageSize]
}
// 生成游标
var nextCursor, prevCursor string
if len(groups) > 0 {
// 下一页游标
if hasMore {
lastGroup := groups[len(groups)-1]
nextCursor = cursor.NewCursor(
cursor.FormatTime(lastGroup.CreatedAt),
lastGroup.ID,
cursor.SortByCreatedAtDesc,
).Encode()
}
// 上一页游标
firstGroup := groups[0]
prevCursor = cursor.NewCursor(
cursor.FormatTime(firstGroup.CreatedAt),
firstGroup.ID,
cursor.SortByCreatedAtDesc,
).Encode()
}
return cursor.NewCursorPageResult(groups, nextCursor, prevCursor, hasMore), nil
}
// GetMembersByCursor 游标分页获取群成员列表
// 排序方式join_time DESC新成员优先
func (r *groupRepository) GetMembersByCursor(ctx context.Context, groupID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.GroupMember], error) {
// 构建基础查询
query := r.db.WithContext(ctx).Model(&model.GroupMember{}).Where("group_id = ?", groupID)
// 使用游标构建器
builder := cursor.NewBuilder(query, cursor.SortByJoinTimeDesc).
WithCursor(req.Cursor, req.Direction).
WithPageSize(req.PageSize)
if builder.Error() != nil {
// 无效游标,返回空列表
return cursor.NewCursorPageResult[*model.GroupMember]([]*model.GroupMember{}, "", "", false), nil
}
// 执行查询
var members []*model.GroupMember
query = builder.Build()
if err := query.Find(&members).Error; err != nil {
return nil, err
}
// 构建响应
pageSize := builder.GetPageSize()
hasMore := cursor.HasMore(len(members), pageSize)
if hasMore {
members = members[:pageSize]
}
// 生成游标
var nextCursor, prevCursor string
if len(members) > 0 {
// 下一页游标
if hasMore {
lastMember := members[len(members)-1]
nextCursor = cursor.NewCursor(
cursor.FormatTime(lastMember.JoinTime),
lastMember.ID,
cursor.SortByJoinTimeDesc,
).Encode()
}
// 上一页游标
firstMember := members[0]
prevCursor = cursor.NewCursor(
cursor.FormatTime(firstMember.JoinTime),
firstMember.ID,
cursor.SortByJoinTimeDesc,
).Encode()
}
return cursor.NewCursorPageResult(members, nextCursor, prevCursor, hasMore), nil
}
// GetAnnouncementsByCursor 游标分页获取群公告列表
// 排序方式is_pinned DESC, created_at DESC置顶优先然后按时间倒序
// 注意:由于有置顶逻辑,这里使用 created_at DESC 作为游标排序
func (r *groupRepository) GetAnnouncementsByCursor(ctx context.Context, groupID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.GroupAnnouncement], error) {
// 构建基础查询
query := r.db.WithContext(ctx).Model(&model.GroupAnnouncement{}).Where("group_id = ?", groupID)
// 使用游标构建器
builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc).
WithCursor(req.Cursor, req.Direction).
WithPageSize(req.PageSize)
if builder.Error() != nil {
// 无效游标,返回空列表
return cursor.NewCursorPageResult[*model.GroupAnnouncement]([]*model.GroupAnnouncement{}, "", "", false), nil
}
// 执行查询(置顶的排在前面)
var announcements []*model.GroupAnnouncement
query = builder.Build().Order("is_pinned DESC, created_at DESC")
if err := query.Find(&announcements).Error; err != nil {
return nil, err
}
// 构建响应
pageSize := builder.GetPageSize()
hasMore := cursor.HasMore(len(announcements), pageSize)
if hasMore {
announcements = announcements[:pageSize]
}
// 生成游标
var nextCursor, prevCursor string
if len(announcements) > 0 {
// 下一页游标
if hasMore {
lastAnnouncement := announcements[len(announcements)-1]
nextCursor = cursor.NewCursor(
cursor.FormatTime(lastAnnouncement.CreatedAt),
lastAnnouncement.ID,
cursor.SortByCreatedAtDesc,
).Encode()
}
// 上一页游标
firstAnnouncement := announcements[0]
prevCursor = cursor.NewCursor(
cursor.FormatTime(firstAnnouncement.CreatedAt),
firstAnnouncement.ID,
cursor.SortByCreatedAtDesc,
).Encode()
}
return cursor.NewCursorPageResult(announcements, nextCursor, prevCursor, hasMore), nil
}