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)
|
||||
}
|
||||
152
internal/pkg/cursor/cursor.go
Normal file
152
internal/pkg/cursor/cursor.go
Normal 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,
|
||||
}
|
||||
}
|
||||
138
internal/pkg/cursor/pagination.go
Normal file
138
internal/pkg/cursor/pagination.go
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user