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:
@@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -709,3 +710,199 @@ func (r *PostRepository) UpdateFeatureStatus(postID string, isFeatured bool) err
|
||||
return r.db.Model(&model.Post{}).Where("id = ?", postID).
|
||||
Update("is_featured", isFeatured).Error
|
||||
}
|
||||
|
||||
// ========== Cursor Pagination Methods ==========
|
||||
|
||||
// GetPostsByCursor 游标分页获取帖子列表
|
||||
// includePending=true 时,仅在指定 userID 下额外返回 pending(用于作者查看自己待审核帖子)
|
||||
func (r *PostRepository) GetPostsByCursor(ctx context.Context, userID string, includePending bool, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
|
||||
db := r.getDB(ctx).WithContext(ctx)
|
||||
|
||||
// 构建基础查询
|
||||
query := db.Model(&model.Post{})
|
||||
|
||||
if userID != "" {
|
||||
query = query.Where("user_id = ?", userID)
|
||||
}
|
||||
if includePending && userID != "" {
|
||||
query = query.Where("status IN ?", []model.PostStatus{
|
||||
model.PostStatusPublished,
|
||||
model.PostStatusPending,
|
||||
})
|
||||
} else {
|
||||
query = query.Where("status = ?", model.PostStatusPublished)
|
||||
}
|
||||
|
||||
// 使用游标构建器
|
||||
builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc).
|
||||
WithCursor(req.Cursor, req.Direction).
|
||||
WithPageSize(req.PageSize)
|
||||
|
||||
if builder.Error() != nil {
|
||||
// 无效游标,返回空列表
|
||||
return cursor.NewCursorPageResult[*model.Post]([]*model.Post{}, "", "", false), nil
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
var posts []*model.Post
|
||||
query = builder.Build().Preload("User").Preload("Images")
|
||||
if err := query.Find(&posts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
pageSize := builder.GetPageSize()
|
||||
hasMore := cursor.HasMore(len(posts), pageSize)
|
||||
if hasMore {
|
||||
posts = posts[:pageSize]
|
||||
}
|
||||
|
||||
// 生成游标
|
||||
var nextCursor, prevCursor string
|
||||
if len(posts) > 0 {
|
||||
// 下一页游标
|
||||
if hasMore {
|
||||
lastPost := posts[len(posts)-1]
|
||||
nextCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(lastPost.CreatedAt),
|
||||
lastPost.ID,
|
||||
cursor.SortByCreatedAtDesc,
|
||||
).Encode()
|
||||
}
|
||||
// 上一页游标
|
||||
firstPost := posts[0]
|
||||
prevCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(firstPost.CreatedAt),
|
||||
firstPost.ID,
|
||||
cursor.SortByCreatedAtDesc,
|
||||
).Encode()
|
||||
}
|
||||
|
||||
return cursor.NewCursorPageResult(posts, nextCursor, prevCursor, hasMore), nil
|
||||
}
|
||||
|
||||
// SearchPostsByCursor 游标分页搜索帖子
|
||||
func (r *PostRepository) SearchPostsByCursor(ctx context.Context, keyword string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
|
||||
db := r.getDB(ctx).WithContext(ctx)
|
||||
|
||||
query := db.Model(&model.Post{}).Where("status = ?", model.PostStatusPublished)
|
||||
|
||||
// 搜索标题和内容
|
||||
if keyword != "" {
|
||||
if r.db.Dialector.Name() == "postgres" {
|
||||
// PostgreSQL 使用全文检索表达式
|
||||
query = query.Where(
|
||||
"to_tsvector('simple', COALESCE(title, '') || ' ' || COALESCE(content, '')) @@ plainto_tsquery('simple', ?)",
|
||||
keyword,
|
||||
)
|
||||
} else {
|
||||
searchPattern := "%" + keyword + "%"
|
||||
query = query.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用游标构建器
|
||||
builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc).
|
||||
WithCursor(req.Cursor, req.Direction).
|
||||
WithPageSize(req.PageSize)
|
||||
|
||||
if builder.Error() != nil {
|
||||
// 无效游标,返回空列表
|
||||
return cursor.NewCursorPageResult[*model.Post]([]*model.Post{}, "", "", false), nil
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
var posts []*model.Post
|
||||
query = builder.Build().Preload("User").Preload("Images")
|
||||
if err := query.Find(&posts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
pageSize := builder.GetPageSize()
|
||||
hasMore := cursor.HasMore(len(posts), pageSize)
|
||||
if hasMore {
|
||||
posts = posts[:pageSize]
|
||||
}
|
||||
|
||||
// 生成游标
|
||||
var nextCursor, prevCursor string
|
||||
if len(posts) > 0 {
|
||||
if hasMore {
|
||||
lastPost := posts[len(posts)-1]
|
||||
nextCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(lastPost.CreatedAt),
|
||||
lastPost.ID,
|
||||
cursor.SortByCreatedAtDesc,
|
||||
).Encode()
|
||||
}
|
||||
firstPost := posts[0]
|
||||
prevCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(firstPost.CreatedAt),
|
||||
firstPost.ID,
|
||||
cursor.SortByCreatedAtDesc,
|
||||
).Encode()
|
||||
}
|
||||
|
||||
return cursor.NewCursorPageResult(posts, nextCursor, prevCursor, hasMore), nil
|
||||
}
|
||||
|
||||
// GetUserPostsByCursor 游标分页获取用户帖子
|
||||
func (r *PostRepository) GetUserPostsByCursor(ctx context.Context, userID string, includePending bool, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
|
||||
db := r.getDB(ctx).WithContext(ctx)
|
||||
|
||||
query := db.Model(&model.Post{}).Where("user_id = ?", userID)
|
||||
if includePending {
|
||||
query = query.Where("status IN ?", []model.PostStatus{
|
||||
model.PostStatusPublished,
|
||||
model.PostStatusPending,
|
||||
})
|
||||
} else {
|
||||
query = query.Where("status = ?", model.PostStatusPublished)
|
||||
}
|
||||
|
||||
// 使用游标构建器
|
||||
builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc).
|
||||
WithCursor(req.Cursor, req.Direction).
|
||||
WithPageSize(req.PageSize)
|
||||
|
||||
if builder.Error() != nil {
|
||||
// 无效游标,返回空列表
|
||||
return cursor.NewCursorPageResult[*model.Post]([]*model.Post{}, "", "", false), nil
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
var posts []*model.Post
|
||||
query = builder.Build().Preload("User").Preload("Images")
|
||||
if err := query.Find(&posts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
pageSize := builder.GetPageSize()
|
||||
hasMore := cursor.HasMore(len(posts), pageSize)
|
||||
if hasMore {
|
||||
posts = posts[:pageSize]
|
||||
}
|
||||
|
||||
// 生成游标
|
||||
var nextCursor, prevCursor string
|
||||
if len(posts) > 0 {
|
||||
if hasMore {
|
||||
lastPost := posts[len(posts)-1]
|
||||
nextCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(lastPost.CreatedAt),
|
||||
lastPost.ID,
|
||||
cursor.SortByCreatedAtDesc,
|
||||
).Encode()
|
||||
}
|
||||
firstPost := posts[0]
|
||||
prevCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(firstPost.CreatedAt),
|
||||
firstPost.ID,
|
||||
cursor.SortByCreatedAtDesc,
|
||||
).Encode()
|
||||
}
|
||||
|
||||
return cursor.NewCursorPageResult(posts, nextCursor, prevCursor, hasMore), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user