feat(api): add cursor-based pagination for posts, comments, messages, groups, and notifications
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:
244
internal/pkg/cursor/builder.go
Normal file
244
internal/pkg/cursor/builder.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package cursor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CursorBuilder 游标查询构建器
|
||||
type CursorBuilder struct {
|
||||
db *gorm.DB
|
||||
cursor *Cursor
|
||||
direction Direction
|
||||
pageSize int
|
||||
order SortOrder
|
||||
err error
|
||||
}
|
||||
|
||||
// NewBuilder 创建新的构建器
|
||||
func NewBuilder(db *gorm.DB, order SortOrder) *CursorBuilder {
|
||||
return &CursorBuilder{
|
||||
db: db,
|
||||
order: order,
|
||||
pageSize: DefaultPageSize,
|
||||
direction: Forward,
|
||||
}
|
||||
}
|
||||
|
||||
// WithCursor 设置游标
|
||||
func (b *CursorBuilder) WithCursor(cursor string, direction Direction) *CursorBuilder {
|
||||
if b.err != nil {
|
||||
return b
|
||||
}
|
||||
|
||||
if cursor == "" {
|
||||
return b
|
||||
}
|
||||
|
||||
decoded, err := DecodeCursor(cursor)
|
||||
if err != nil {
|
||||
b.err = err
|
||||
return b
|
||||
}
|
||||
|
||||
b.cursor = decoded
|
||||
b.direction = direction
|
||||
return b
|
||||
}
|
||||
|
||||
// WithPageSize 设置页面大小
|
||||
func (b *CursorBuilder) WithPageSize(size int) *CursorBuilder {
|
||||
if b.err != nil {
|
||||
return b
|
||||
}
|
||||
|
||||
if size <= 0 {
|
||||
b.pageSize = DefaultPageSize
|
||||
} else if size > MaxPageSize {
|
||||
b.pageSize = MaxPageSize
|
||||
} else {
|
||||
b.pageSize = size
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// WithDirection 设置分页方向
|
||||
func (b *CursorBuilder) WithDirection(direction Direction) *CursorBuilder {
|
||||
if b.err != nil {
|
||||
return b
|
||||
}
|
||||
|
||||
if direction == "" {
|
||||
b.direction = Forward
|
||||
} else {
|
||||
b.direction = direction
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Error 返回构建过程中的错误
|
||||
func (b *CursorBuilder) Error() error {
|
||||
return b.err
|
||||
}
|
||||
|
||||
// Build 构建查询(返回 GORM 查询)
|
||||
// 注意:调用者需要自己执行 Find 并处理结果
|
||||
func (b *CursorBuilder) Build() *gorm.DB {
|
||||
if b.err != nil {
|
||||
return b.db.Where("1 = 0") // 返回空结果
|
||||
}
|
||||
|
||||
query := b.db
|
||||
|
||||
// 应用游标条件
|
||||
if b.cursor != nil {
|
||||
query = b.applyCursorCondition(query)
|
||||
}
|
||||
|
||||
// 应用排序
|
||||
query = b.applyOrder(query)
|
||||
|
||||
// 多取一条用于判断是否有更多数据
|
||||
query = query.Limit(b.pageSize + 1)
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
// applyCursorCondition 应用游标条件
|
||||
func (b *CursorBuilder) applyCursorCondition(query *gorm.DB) *gorm.DB {
|
||||
if b.cursor == nil {
|
||||
return query
|
||||
}
|
||||
|
||||
sortField := b.order.SortField()
|
||||
sortValue := b.cursor.SortValue
|
||||
id := b.cursor.ID
|
||||
|
||||
// 根据分页方向和排序类型决定比较运算符
|
||||
var op string
|
||||
if b.direction == Forward {
|
||||
// 向前分页:降序用 <,升序用 >
|
||||
if b.order.IsDescending() {
|
||||
op = "<"
|
||||
} else {
|
||||
op = ">"
|
||||
}
|
||||
} else {
|
||||
// 向后分页:降序用 >,升序用 <
|
||||
if b.order.IsDescending() {
|
||||
op = ">"
|
||||
} else {
|
||||
op = "<"
|
||||
}
|
||||
}
|
||||
|
||||
// 使用元组比较:(sort_field, id) op (sort_value, id)
|
||||
// 这样可以确保排序稳定且唯一
|
||||
condition := fmt.Sprintf("(%s, id) %s (?, ?)", sortField, op)
|
||||
return query.Where(condition, sortValue, id)
|
||||
}
|
||||
|
||||
// applyOrder 应用排序
|
||||
func (b *CursorBuilder) applyOrder(query *gorm.DB) *gorm.DB {
|
||||
// 主排序字段
|
||||
orderClause := b.order.String()
|
||||
|
||||
// 添加 ID 作为第二排序字段,确保排序稳定
|
||||
if b.order.IsDescending() {
|
||||
orderClause += ", id DESC"
|
||||
} else {
|
||||
orderClause += ", id ASC"
|
||||
}
|
||||
|
||||
return query.Order(orderClause)
|
||||
}
|
||||
|
||||
// GetPageSize 获取页面大小
|
||||
func (b *CursorBuilder) GetPageSize() int {
|
||||
return b.pageSize
|
||||
}
|
||||
|
||||
// HasMore 判断是否有更多数据
|
||||
// items 是查询结果切片,需要传入指针切片
|
||||
func HasMore(itemsLen int, pageSize int) bool {
|
||||
return itemsLen > pageSize
|
||||
}
|
||||
|
||||
// TrimItems 裁剪结果到实际页面大小
|
||||
func TrimItems(itemsLen int, pageSize int) int {
|
||||
if itemsLen > pageSize {
|
||||
return pageSize
|
||||
}
|
||||
return itemsLen
|
||||
}
|
||||
|
||||
// BuildResponse 从结果构建响应
|
||||
// items: 结果切片(需要是切片类型)
|
||||
// getSortValue: 获取排序字段值的函数
|
||||
// getID: 获取ID的函数
|
||||
func BuildResponse[T any](items []T, order SortOrder, pageSize int, getSortValue func(T) string, getID func(T) string) *PageResponse {
|
||||
hasMore := len(items) > pageSize
|
||||
|
||||
// 裁剪到实际页面大小
|
||||
if hasMore {
|
||||
items = items[:pageSize]
|
||||
}
|
||||
|
||||
var nextCursor, prevCursor string
|
||||
|
||||
// 生成下一页游标
|
||||
if hasMore && len(items) > 0 {
|
||||
lastItem := items[len(items)-1]
|
||||
nextCursor = NewCursor(getSortValue(lastItem), getID(lastItem), order).Encode()
|
||||
}
|
||||
|
||||
return &PageResponse{
|
||||
Items: items,
|
||||
NextCursor: nextCursor,
|
||||
PrevCursor: prevCursor,
|
||||
HasMore: hasMore,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildResponseWithPrev 从结果构建响应(包含上一页游标)
|
||||
func BuildResponseWithPrev[T any](items []T, order SortOrder, pageSize int, getSortValue func(T) string, getID func(T) string) *PageResponse {
|
||||
hasMore := len(items) > pageSize
|
||||
|
||||
// 裁剪到实际页面大小
|
||||
if hasMore {
|
||||
items = items[:pageSize]
|
||||
}
|
||||
|
||||
var nextCursor, prevCursor string
|
||||
|
||||
// 生成下一页游标
|
||||
if hasMore && len(items) > 0 {
|
||||
lastItem := items[len(items)-1]
|
||||
nextCursor = NewCursor(getSortValue(lastItem), getID(lastItem), order).Encode()
|
||||
}
|
||||
|
||||
// 生成上一页游标
|
||||
if len(items) > 0 {
|
||||
firstItem := items[0]
|
||||
prevCursor = NewCursor(getSortValue(firstItem), getID(firstItem), order).Encode()
|
||||
}
|
||||
|
||||
return &PageResponse{
|
||||
Items: items,
|
||||
NextCursor: nextCursor,
|
||||
PrevCursor: prevCursor,
|
||||
HasMore: hasMore,
|
||||
}
|
||||
}
|
||||
|
||||
// FormatTime 格式化时间为字符串(用于游标)
|
||||
func FormatTime(t time.Time) string {
|
||||
return t.Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
// ParseTime 解析时间字符串(从游标)
|
||||
func ParseTime(s string) (time.Time, error) {
|
||||
return time.Parse(time.RFC3339Nano, s)
|
||||
}
|
||||
Reference in New Issue
Block a user