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

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

View File

@@ -0,0 +1,152 @@
package cursor
import (
"encoding/base64"
"encoding/json"
"errors"
)
// SortOrder 游标排序类型
type SortOrder int
const (
// SortByCreatedAtDesc 按创建时间降序
SortByCreatedAtDesc SortOrder = iota
// SortByCreatedAtAsc 按创建时间升序
SortByCreatedAtAsc
// SortByIDDesc 按ID降序
SortByIDDesc
// SortByIDAsc 按ID升序
SortByIDAsc
// SortBySeqDesc 按序列号降序(用于消息)
SortBySeqDesc
// SortBySeqAsc 按序列号升序
SortBySeqAsc
// SortByUpdatedAtDesc 按更新时间降序
SortByUpdatedAtDesc
// SortByUpdatedAtAsc 按更新时间升序
SortByUpdatedAtAsc
// SortByJoinTimeDesc 按加入时间降序(用于群成员)
SortByJoinTimeDesc
// SortByJoinTimeAsc 按加入时间升序(用于群成员)
SortByJoinTimeAsc
)
// String 返回排序类型的字符串表示
func (s SortOrder) String() string {
switch s {
case SortByCreatedAtDesc:
return "created_at DESC"
case SortByCreatedAtAsc:
return "created_at ASC"
case SortByIDDesc:
return "id DESC"
case SortByIDAsc:
return "id ASC"
case SortBySeqDesc:
return "seq DESC"
case SortBySeqAsc:
return "seq ASC"
case SortByUpdatedAtDesc:
return "updated_at DESC"
case SortByUpdatedAtAsc:
return "updated_at ASC"
case SortByJoinTimeDesc:
return "join_time DESC"
case SortByJoinTimeAsc:
return "join_time ASC"
default:
return "created_at DESC"
}
}
// IsDescending 是否为降序
func (s SortOrder) IsDescending() bool {
return s == SortByCreatedAtDesc || s == SortByIDDesc || s == SortBySeqDesc || s == SortByUpdatedAtDesc || s == SortByJoinTimeDesc
}
// SortField 返回排序字段名
func (s SortOrder) SortField() string {
switch s {
case SortByCreatedAtDesc, SortByCreatedAtAsc:
return "created_at"
case SortByIDDesc, SortByIDAsc:
return "id"
case SortBySeqDesc, SortBySeqAsc:
return "seq"
case SortByUpdatedAtDesc, SortByUpdatedAtAsc:
return "updated_at"
case SortByJoinTimeDesc, SortByJoinTimeAsc:
return "join_time"
default:
return "created_at"
}
}
// cursorJSON 游标 JSON 结构(用于序列化)
type cursorJSON struct {
SortValue string `json:"sv"` // 排序字段值时间戳或ID
ID string `json:"id"` // 记录唯一ID
Order SortOrder `json:"so"` // 排序方式
}
// Cursor 游标数据结构
type Cursor struct {
SortValue string // 排序字段值时间戳或ID
ID string // 记录唯一ID
Order SortOrder // 排序方式
}
// Encode 编码游标为 Base64URL 字符串
func (c *Cursor) Encode() string {
if c == nil {
return ""
}
data, err := json.Marshal(&cursorJSON{
SortValue: c.SortValue,
ID: c.ID,
Order: c.Order,
})
if err != nil {
return ""
}
return base64.URLEncoding.EncodeToString(data)
}
// DecodeCursor 解码 Base64URL 字符串为游标
func DecodeCursor(encoded string) (*Cursor, error) {
if encoded == "" {
return nil, nil
}
data, err := base64.URLEncoding.DecodeString(encoded)
if err != nil {
// 尝试使用 RawURLEncoding不带填充的编码
data, err = base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return nil, errors.New("invalid cursor encoding: " + err.Error())
}
}
var cj cursorJSON
if err := json.Unmarshal(data, &cj); err != nil {
return nil, errors.New("invalid cursor format: " + err.Error())
}
return &Cursor{
SortValue: cj.SortValue,
ID: cj.ID,
Order: cj.Order,
}, nil
}
// NewCursor 创建新的游标
func NewCursor(sortValue, id string, order SortOrder) *Cursor {
return &Cursor{
SortValue: sortValue,
ID: id,
Order: order,
}
}

View File

@@ -0,0 +1,138 @@
package cursor
import (
"errors"
)
const (
// DefaultPageSize 默认页面大小
DefaultPageSize = 20
// MaxPageSize 最大页面大小
MaxPageSize = 100
)
// Direction 分页方向
type Direction string
const (
// Forward 向前分页(下一页)
Forward Direction = "forward"
// Backward 向后分页(上一页)
Backward Direction = "backward"
)
// IsValid 检查分页方向是否有效
func (d Direction) IsValid() bool {
return d == Forward || d == Backward
}
// PageRequest 游标分页请求参数
type PageRequest struct {
Cursor string // 游标字符串
Direction Direction // 分页方向
PageSize int // 每页数量
}
// NewPageRequest 创建新的游标请求
func NewPageRequest(cursor string, direction Direction, pageSize int) *PageRequest {
req := &PageRequest{
Cursor: cursor,
Direction: direction,
PageSize: pageSize,
}
req.Normalize()
return req
}
// Normalize 规范化分页参数
func (r *PageRequest) Normalize() {
if r.Direction == "" {
r.Direction = Forward
}
if r.PageSize <= 0 {
r.PageSize = DefaultPageSize
}
if r.PageSize > MaxPageSize {
r.PageSize = MaxPageSize
}
}
// GetPageSize 获取有效的页面大小默认20最大100
func (r *PageRequest) GetPageSize() int {
if r.PageSize <= 0 {
return DefaultPageSize
}
if r.PageSize > MaxPageSize {
return MaxPageSize
}
return r.PageSize
}
// Validate 验证分页请求
func (r *PageRequest) Validate() error {
if r.Direction != "" && !r.Direction.IsValid() {
return errors.New("invalid direction, must be 'forward' or 'backward'")
}
return nil
}
// GetCursor 解码游标
func (r *PageRequest) GetCursor() (*Cursor, error) {
return DecodeCursor(r.Cursor)
}
// IsForward 是否向前分页
func (r *PageRequest) IsForward() bool {
return r.Direction == Forward || r.Direction == ""
}
// IsBackward 是否向后分页
func (r *PageRequest) IsBackward() bool {
return r.Direction == Backward
}
// PageResponse 游标分页响应结构
type PageResponse struct {
Items interface{} `json:"items"`
NextCursor string `json:"next_cursor"`
PrevCursor string `json:"prev_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
// NewPageResponse 创建分页响应
func NewPageResponse(items interface{}, nextCursor, prevCursor string, hasMore bool) *PageResponse {
return &PageResponse{
Items: items,
NextCursor: nextCursor,
PrevCursor: prevCursor,
HasMore: hasMore,
}
}
// CursorPageResult 游标分页结果(泛型版本,供内部使用)
type CursorPageResult[T any] struct {
Items []T
NextCursor string
PrevCursor string
HasMore bool
}
// NewCursorPageResult 创建游标分页结果
func NewCursorPageResult[T any](items []T, nextCursor, prevCursor string, hasMore bool) *CursorPageResult[T] {
return &CursorPageResult[T]{
Items: items,
NextCursor: nextCursor,
PrevCursor: prevCursor,
HasMore: hasMore,
}
}
// ToPageResponse 转换为通用分页响应
func (r *CursorPageResult[T]) ToPageResponse() *PageResponse {
return &PageResponse{
Items: r.Items,
NextCursor: r.NextCursor,
PrevCursor: r.PrevCursor,
HasMore: r.HasMore,
}
}