Files
backend/internal/pkg/cursor/builder.go
lafay 92babe509f
All checks were successful
Build Backend / build (push) Successful in 4m42s
Build Backend / build-docker (push) Successful in 3m53s
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
2026-03-20 23:03:23 +08:00

245 lines
5.2 KiB
Go

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)
}