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:
@@ -1179,3 +1179,92 @@ type PendingContentAuthor struct {
|
||||
type OnlineUsersResponse struct {
|
||||
Count int64 `json:"count"` // 在线用户数
|
||||
}
|
||||
|
||||
// ==================== Cursor Pagination DTOs ====================
|
||||
|
||||
// CursorPageRequest 游标分页请求
|
||||
type CursorPageRequest struct {
|
||||
Cursor string `json:"cursor" form:"cursor"` // 游标(首次请求为空)
|
||||
Direction string `json:"direction" form:"direction"` // forward | backward
|
||||
PageSize int `json:"page_size" form:"page_size"` // 每页数量
|
||||
}
|
||||
|
||||
// CursorPageResponse 游标分页响应(泛型版本)
|
||||
// 注意:由于 Go 的限制,这里使用 interface{} 作为 Items 类型
|
||||
// 实际使用时可以创建特定类型的响应结构体
|
||||
type CursorPageResponse struct {
|
||||
Items interface{} `json:"items"`
|
||||
NextCursor string `json:"next_cursor,omitempty"` // 下一页游标
|
||||
PrevCursor string `json:"prev_cursor,omitempty"` // 上一页游标(可选)
|
||||
HasMore bool `json:"has_more"` // 是否有更多数据
|
||||
}
|
||||
|
||||
// CursorPageMeta 分页元信息
|
||||
type CursorPageMeta struct {
|
||||
PageSize int `json:"page_size"`
|
||||
Direction string `json:"direction"`
|
||||
}
|
||||
|
||||
// PostCursorPageResponse 帖子游标分页响应
|
||||
type PostCursorPageResponse struct {
|
||||
Items []*PostResponse `json:"items"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
PrevCursor string `json:"prev_cursor,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// CommentCursorPageResponse 评论游标分页响应
|
||||
type CommentCursorPageResponse struct {
|
||||
Items []*CommentResponse `json:"items"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
PrevCursor string `json:"prev_cursor,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// MessageCursorPageResponse 消息游标分页响应
|
||||
type MessageCursorPageResponse struct {
|
||||
Items []*MessageResponse `json:"items"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
PrevCursor string `json:"prev_cursor,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// ConversationCursorPageResponse 会话游标分页响应
|
||||
type ConversationCursorPageResponse struct {
|
||||
Items []*ConversationResponse `json:"items"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
PrevCursor string `json:"prev_cursor,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// GroupCursorPageResponse 群组游标分页响应
|
||||
type GroupCursorPageResponse struct {
|
||||
Items []*GroupResponse `json:"items"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
PrevCursor string `json:"prev_cursor,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// NotificationCursorPageResponse 通知游标分页响应
|
||||
type NotificationCursorPageResponse struct {
|
||||
Items []*NotificationResponse `json:"items"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
PrevCursor string `json:"prev_cursor,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// GroupMemberCursorPageResponse 群成员游标分页响应
|
||||
type GroupMemberCursorPageResponse struct {
|
||||
Items []*GroupMemberResponse `json:"items"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
PrevCursor string `json:"prev_cursor,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// GroupAnnouncementCursorPageResponse 群公告游标分页响应
|
||||
type GroupAnnouncementCursorPageResponse struct {
|
||||
Items []*GroupAnnouncementResponse `json:"items"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
PrevCursor string `json:"prev_cursor,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
@@ -94,3 +94,30 @@ func GroupAnnouncementsToResponse(announcements []model.GroupAnnouncement) []*Gr
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GroupsToResponseFromPtr 将Group指针切片转换为GroupResponse列表
|
||||
func GroupsToResponseFromPtr(groups []*model.Group) []*GroupResponse {
|
||||
result := make([]*GroupResponse, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
result = append(result, GroupToResponse(group))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GroupMembersToResponseFromPtr 将GroupMember指针切片转换为GroupMemberResponse列表
|
||||
func GroupMembersToResponseFromPtr(members []*model.GroupMember) []*GroupMemberResponse {
|
||||
result := make([]*GroupMemberResponse, 0, len(members))
|
||||
for _, member := range members {
|
||||
result = append(result, GroupMemberToResponse(member))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GroupAnnouncementsToResponseFromPtr 将GroupAnnouncement指针切片转换为GroupAnnouncementResponse列表
|
||||
func GroupAnnouncementsToResponseFromPtr(announcements []*model.GroupAnnouncement) []*GroupAnnouncementResponse {
|
||||
result := make([]*GroupAnnouncementResponse, 0, len(announcements))
|
||||
for _, announcement := range announcements {
|
||||
result = append(result, GroupAnnouncementToResponse(announcement))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
)
|
||||
@@ -251,3 +252,70 @@ func (h *CommentHandler) Unlike(c *gin.Context) {
|
||||
|
||||
response.SuccessWithMessage(c, "unliked", nil)
|
||||
}
|
||||
|
||||
// GetByPostIDByCursor 游标分页获取帖子评论
|
||||
func (h *CommentHandler) GetByPostIDByCursor(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
postID := c.Param("id")
|
||||
|
||||
// 解析游标分页请求
|
||||
req := h.parseCursorRequest(c)
|
||||
|
||||
// 调用游标分页服务
|
||||
result, err := h.commentService.GetCommentsByCursor(c.Request.Context(), postID, req)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get comments")
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应结构,检查每个评论的点赞状态
|
||||
commentResponses := dto.ConvertCommentsToResponseWithUser(result.Items, userID, h.commentService)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.CommentCursorPageResponse{
|
||||
Items: commentResponses,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
}
|
||||
|
||||
response.Success(c, cursorResp)
|
||||
}
|
||||
|
||||
// GetRepliesByRootIDByCursor 游标分页获取根评论的回复
|
||||
func (h *CommentHandler) GetRepliesByRootIDByCursor(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
rootID := c.Param("id")
|
||||
|
||||
// 解析游标分页请求
|
||||
req := h.parseCursorRequest(c)
|
||||
|
||||
// 调用游标分页服务
|
||||
result, err := h.commentService.GetRepliesByCursor(c.Request.Context(), rootID, req)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get replies")
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应结构,检查每个回复的点赞状态
|
||||
replyResponses := dto.ConvertCommentsToResponseWithUser(result.Items, userID, h.commentService)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.CommentCursorPageResponse{
|
||||
Items: replyResponses,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
}
|
||||
|
||||
response.Success(c, cursorResp)
|
||||
}
|
||||
|
||||
// parseCursorRequest 解析游标分页请求参数
|
||||
func (h *CommentHandler) parseCursorRequest(c *gin.Context) *cursor.PageRequest {
|
||||
cursorStr := c.Query("cursor")
|
||||
direction := cursor.Direction(c.DefaultQuery("direction", "forward"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
return cursor.NewPageRequest(cursorStr, direction, pageSize)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
)
|
||||
@@ -1279,6 +1280,163 @@ func (h *GroupHandler) DeleteAnnouncement(c *gin.Context) {
|
||||
response.SuccessWithMessage(c, "公告已删除", nil)
|
||||
}
|
||||
|
||||
// ==================== 游标分页方法 ====================
|
||||
|
||||
// GetGroupsByCursor 游标分页获取群组列表
|
||||
// GET /api/groups/cursor
|
||||
func (h *GroupHandler) GetGroupsByCursor(c *gin.Context) {
|
||||
// 解析游标分页请求
|
||||
req := h.parseCursorRequest(c)
|
||||
|
||||
// 调用游标分页服务
|
||||
result, err := h.groupService.GetGroupsByCursor(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应结构
|
||||
groupResponses := dto.GroupsToResponseFromPtr(result.Items)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.GroupCursorPageResponse{
|
||||
Items: groupResponses,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
}
|
||||
|
||||
response.Success(c, cursorResp)
|
||||
}
|
||||
|
||||
// GetUserGroupsByCursor 游标分页获取用户加入的群组列表
|
||||
// GET /api/users/:id/groups/cursor
|
||||
func (h *GroupHandler) GetUserGroupsByCursor(c *gin.Context) {
|
||||
userID := parseUserIDFromPath(c)
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析游标分页请求
|
||||
req := h.parseCursorRequest(c)
|
||||
|
||||
// 调用游标分页服务
|
||||
result, err := h.groupService.GetUserGroupsByCursor(c.Request.Context(), userID, req)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应结构
|
||||
groupResponses := dto.GroupsToResponseFromPtr(result.Items)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.GroupCursorPageResponse{
|
||||
Items: groupResponses,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
}
|
||||
|
||||
response.Success(c, cursorResp)
|
||||
}
|
||||
|
||||
// GetMembersByCursor 游标分页获取群成员列表
|
||||
// GET /api/groups/:id/members/cursor
|
||||
func (h *GroupHandler) GetMembersByCursor(c *gin.Context) {
|
||||
userID := parseUserID(c)
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
groupID := parseGroupID(c)
|
||||
if groupID == "" {
|
||||
response.BadRequest(c, "invalid group id")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析游标分页请求
|
||||
req := h.parseCursorRequest(c)
|
||||
|
||||
// 调用游标分页服务
|
||||
result, err := h.groupService.GetMembersByCursor(c.Request.Context(), groupID, req)
|
||||
if err != nil {
|
||||
if err == service.ErrGroupNotFound {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
response.InternalServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应结构
|
||||
memberResponses := dto.GroupMembersToResponseFromPtr(result.Items)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.GroupMemberCursorPageResponse{
|
||||
Items: memberResponses,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
}
|
||||
|
||||
response.Success(c, cursorResp)
|
||||
}
|
||||
|
||||
// GetAnnouncementsByCursor 游标分页获取群公告列表
|
||||
// GET /api/groups/:id/announcements/cursor
|
||||
func (h *GroupHandler) GetAnnouncementsByCursor(c *gin.Context) {
|
||||
userID := parseUserID(c)
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
groupID := parseGroupID(c)
|
||||
if groupID == "" {
|
||||
response.BadRequest(c, "invalid group id")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析游标分页请求
|
||||
req := h.parseCursorRequest(c)
|
||||
|
||||
// 调用游标分页服务
|
||||
result, err := h.groupService.GetAnnouncementsByCursor(c.Request.Context(), groupID, req)
|
||||
if err != nil {
|
||||
if err == service.ErrGroupNotFound {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
response.InternalServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应结构
|
||||
announcementResponses := dto.GroupAnnouncementsToResponseFromPtr(result.Items)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.GroupAnnouncementCursorPageResponse{
|
||||
Items: announcementResponses,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
}
|
||||
|
||||
response.Success(c, cursorResp)
|
||||
}
|
||||
|
||||
// parseCursorRequest 解析游标分页请求参数
|
||||
func (h *GroupHandler) parseCursorRequest(c *gin.Context) *cursor.PageRequest {
|
||||
cursorStr := c.Query("cursor")
|
||||
direction := cursor.Direction(c.DefaultQuery("direction", "forward"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
return cursor.NewPageRequest(cursorStr, direction, pageSize)
|
||||
}
|
||||
|
||||
// ==================== RESTful Action 端点 ====================
|
||||
|
||||
// HandleSetGroupKick 群组踢人
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/pkg/sse"
|
||||
"carrot_bbs/internal/service"
|
||||
@@ -975,3 +976,123 @@ func (h *MessageHandler) HandleSetConversationPinned(c *gin.Context) {
|
||||
"is_pinned": req.IsPinned,
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Cursor Pagination Handlers ====================
|
||||
|
||||
// GetMessagesByCursor 游标分页获取会话消息
|
||||
// GET /api/v1/conversations/:id/messages/cursor
|
||||
// 请求参数:
|
||||
// - cursor: 游标字符串(可选)
|
||||
// - direction: 分页方向 forward 或 backward(默认 forward)
|
||||
// - page_size: 每页数量(默认 20,最大 100)
|
||||
func (h *MessageHandler) GetMessagesByCursor(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
conversationID := getIDParam(c, "id")
|
||||
if conversationID == "" {
|
||||
response.BadRequest(c, "conversation_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析游标分页参数
|
||||
pageReq := parseCursorPageRequest(c)
|
||||
|
||||
// 调用服务层
|
||||
result, err := h.messageService.GetMessagesByCursor(c.Request.Context(), conversationID, pageReq)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get messages")
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
items := dto.ConvertMessagesToResponse(result.Items)
|
||||
|
||||
response.Success(c, &dto.MessageCursorPageResponse{
|
||||
Items: items,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
})
|
||||
}
|
||||
|
||||
// GetConversationsByCursor 游标分页获取会话列表
|
||||
// GET /api/v1/conversations/cursor
|
||||
// 请求参数:
|
||||
// - cursor: 游标字符串(可选)
|
||||
// - direction: 分页方向 forward 或 backward(默认 forward)
|
||||
// - page_size: 每页数量(默认 20,最大 100)
|
||||
func (h *MessageHandler) GetConversationsByCursor(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析游标分页参数
|
||||
pageReq := parseCursorPageRequest(c)
|
||||
|
||||
// 调用服务层
|
||||
result, err := h.messageService.GetConversationsByCursor(c.Request.Context(), userID, pageReq)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get conversations")
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤掉系统会话
|
||||
filteredItems := make([]*model.Conversation, 0)
|
||||
for _, conv := range result.Items {
|
||||
if conv.ID != model.SystemConversationID {
|
||||
filteredItems = append(filteredItems, conv)
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
items := make([]*dto.ConversationResponse, len(filteredItems))
|
||||
for i, conv := range filteredItems {
|
||||
// 获取未读数
|
||||
unreadCount, _ := h.chatService.GetUnreadCount(c.Request.Context(), conv.ID, userID)
|
||||
|
||||
// 获取最后一条消息
|
||||
var lastMessage *model.Message
|
||||
messages, _, _ := h.chatService.GetMessages(c.Request.Context(), conv.ID, userID, 1, 1)
|
||||
if len(messages) > 0 {
|
||||
lastMessage = messages[0]
|
||||
}
|
||||
|
||||
// 群聊时返回member_count,私聊时返回participants
|
||||
var resp *dto.ConversationResponse
|
||||
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
|
||||
isPinned := myParticipant != nil && myParticipant.IsPinned
|
||||
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
|
||||
// 群聊:实时计算群成员数量
|
||||
memberCount, _ := h.groupService.GetMemberCount(*conv.GroupID)
|
||||
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned)
|
||||
resp.MemberCount = memberCount
|
||||
} else {
|
||||
// 私聊:获取参与者信息
|
||||
participants, _ := h.getConversationParticipants(c.Request.Context(), conv.ID, userID)
|
||||
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned)
|
||||
}
|
||||
items[i] = resp
|
||||
}
|
||||
|
||||
response.Success(c, &dto.ConversationCursorPageResponse{
|
||||
Items: items,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
})
|
||||
}
|
||||
|
||||
// parseCursorPageRequest 解析游标分页请求参数
|
||||
func parseCursorPageRequest(c *gin.Context) *cursor.PageRequest {
|
||||
cursorStr := c.Query("cursor")
|
||||
direction := cursor.Direction(c.DefaultQuery("direction", "forward"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
return cursor.NewPageRequest(cursorStr, direction, pageSize)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
)
|
||||
@@ -130,3 +132,46 @@ func (h *NotificationHandler) ClearAllNotifications(c *gin.Context) {
|
||||
|
||||
response.Success(c, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// GetNotificationsByCursor 游标分页获取通知列表
|
||||
func (h *NotificationHandler) GetNotificationsByCursor(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析游标分页请求
|
||||
req := h.parseCursorRequest(c)
|
||||
|
||||
unreadOnly := c.Query("unread_only") == "true"
|
||||
|
||||
// 调用游标分页服务
|
||||
result, err := h.notificationService.GetNotificationsByCursor(c.Request.Context(), userID, unreadOnly, req)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get notifications")
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应结构
|
||||
notificationResponses := dto.ConvertNotificationsToResponse(result.Items)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.NotificationCursorPageResponse{
|
||||
Items: notificationResponses,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
}
|
||||
|
||||
response.Success(c, cursorResp)
|
||||
}
|
||||
|
||||
// parseCursorRequest 解析游标分页请求参数
|
||||
func (h *NotificationHandler) parseCursorRequest(c *gin.Context) *cursor.PageRequest {
|
||||
cursorStr := c.Query("cursor")
|
||||
direction := cursor.Direction(c.DefaultQuery("direction", "forward"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
return cursor.NewPageRequest(cursorStr, direction, pageSize)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
)
|
||||
@@ -143,8 +144,16 @@ func (h *PostHandler) RecordView(c *gin.Context) {
|
||||
response.Success(c, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// List 获取帖子列表
|
||||
// List 获取帖子列表(支持游标分页和偏移分页)
|
||||
func (h *PostHandler) List(c *gin.Context) {
|
||||
// 检查是否使用游标分页
|
||||
cursorStr := c.Query("cursor")
|
||||
if cursorStr != "" {
|
||||
h.ListByCursor(c)
|
||||
return
|
||||
}
|
||||
|
||||
// 偏移分页(向后兼容)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
page, pageSize = normalizePagination(page, pageSize)
|
||||
@@ -207,6 +216,45 @@ func (h *PostHandler) List(c *gin.Context) {
|
||||
response.Paginated(c, postResponses, total, page, pageSize)
|
||||
}
|
||||
|
||||
// ListByCursor 游标分页获取帖子列表
|
||||
func (h *PostHandler) ListByCursor(c *gin.Context) {
|
||||
userID := c.Query("user_id")
|
||||
currentUserID := c.GetString("user_id")
|
||||
|
||||
// 解析游标分页请求
|
||||
req := h.parseCursorRequest(c)
|
||||
|
||||
// 判断是否包含待审核帖子
|
||||
includePending := userID != "" && userID == currentUserID
|
||||
|
||||
// 调用游标分页服务
|
||||
result, err := h.postService.ListByCursor(c.Request.Context(), userID, includePending, req)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get posts")
|
||||
return
|
||||
}
|
||||
|
||||
// 批量获取交互状态
|
||||
postIDs := make([]string, len(result.Items))
|
||||
for i, post := range result.Items {
|
||||
postIDs[i] = post.ID
|
||||
}
|
||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||
|
||||
// 转换为响应结构
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(result.Items, isLikedMap, isFavoritedMap)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.PostCursorPageResponse{
|
||||
Items: postResponses,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
}
|
||||
|
||||
response.Success(c, cursorResp)
|
||||
}
|
||||
|
||||
// Update 更新帖子
|
||||
func (h *PostHandler) Update(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
@@ -412,8 +460,16 @@ func (h *PostHandler) Unfavorite(c *gin.Context) {
|
||||
response.Success(c, dto.BuildPostResponse(post, isLiked, isFavorited))
|
||||
}
|
||||
|
||||
// GetUserPosts 获取用户帖子列表
|
||||
// GetUserPosts 获取用户帖子列表(支持游标分页和偏移分页)
|
||||
func (h *PostHandler) GetUserPosts(c *gin.Context) {
|
||||
// 检查是否使用游标分页
|
||||
cursorStr := c.Query("cursor")
|
||||
if cursorStr != "" {
|
||||
h.GetUserPostsByCursor(c)
|
||||
return
|
||||
}
|
||||
|
||||
// 偏移分页(向后兼容)
|
||||
userID := c.Param("id")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
@@ -440,6 +496,45 @@ func (h *PostHandler) GetUserPosts(c *gin.Context) {
|
||||
response.Paginated(c, postResponses, total, page, pageSize)
|
||||
}
|
||||
|
||||
// GetUserPostsByCursor 游标分页获取用户帖子列表
|
||||
func (h *PostHandler) GetUserPostsByCursor(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
currentUserID := c.GetString("user_id")
|
||||
|
||||
// 解析游标分页请求
|
||||
req := h.parseCursorRequest(c)
|
||||
|
||||
// 判断是否包含待审核帖子
|
||||
includePending := currentUserID != "" && currentUserID == userID
|
||||
|
||||
// 调用游标分页服务
|
||||
result, err := h.postService.GetUserPostsByCursor(c.Request.Context(), userID, includePending, req)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get user posts")
|
||||
return
|
||||
}
|
||||
|
||||
// 批量获取交互状态
|
||||
postIDs := make([]string, len(result.Items))
|
||||
for i, post := range result.Items {
|
||||
postIDs[i] = post.ID
|
||||
}
|
||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||
|
||||
// 转换为响应结构
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(result.Items, isLikedMap, isFavoritedMap)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.PostCursorPageResponse{
|
||||
Items: postResponses,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
}
|
||||
|
||||
response.Success(c, cursorResp)
|
||||
}
|
||||
|
||||
// GetFavorites 获取收藏列表
|
||||
func (h *PostHandler) GetFavorites(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
@@ -470,6 +565,15 @@ func (h *PostHandler) GetFavorites(c *gin.Context) {
|
||||
// Search 搜索帖子
|
||||
func (h *PostHandler) Search(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
// 检查是否使用游标分页
|
||||
cursorStr := c.Query("cursor")
|
||||
if cursorStr != "" {
|
||||
h.SearchByCursor(c)
|
||||
return
|
||||
}
|
||||
|
||||
// 偏移分页(向后兼容)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
page, pageSize = normalizePagination(page, pageSize)
|
||||
@@ -493,3 +597,48 @@ func (h *PostHandler) Search(c *gin.Context) {
|
||||
|
||||
response.Paginated(c, postResponses, total, page, pageSize)
|
||||
}
|
||||
|
||||
// SearchByCursor 游标分页搜索帖子
|
||||
func (h *PostHandler) SearchByCursor(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
currentUserID := c.GetString("user_id")
|
||||
|
||||
// 解析游标分页请求
|
||||
req := h.parseCursorRequest(c)
|
||||
|
||||
// 调用游标分页服务
|
||||
result, err := h.postService.SearchByCursor(c.Request.Context(), keyword, req)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to search posts")
|
||||
return
|
||||
}
|
||||
|
||||
// 批量获取交互状态
|
||||
postIDs := make([]string, len(result.Items))
|
||||
for i, post := range result.Items {
|
||||
postIDs[i] = post.ID
|
||||
}
|
||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||
|
||||
// 转换为响应结构
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(result.Items, isLikedMap, isFavoritedMap)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.PostCursorPageResponse{
|
||||
Items: postResponses,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
}
|
||||
|
||||
response.Success(c, cursorResp)
|
||||
}
|
||||
|
||||
// parseCursorRequest 解析游标分页请求参数
|
||||
func (h *PostHandler) parseCursorRequest(c *gin.Context) *cursor.PageRequest {
|
||||
cursorStr := c.Query("cursor")
|
||||
direction := cursor.Direction(c.DefaultQuery("direction", "forward"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
return cursor.NewPageRequest(cursorStr, direction, pageSize)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package repository
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -391,3 +393,181 @@ func (r *CommentRepository) GetCommentsByPostIDForAdmin(postID string, page, pag
|
||||
|
||||
return comments, total, err
|
||||
}
|
||||
|
||||
// ========== Cursor Pagination Methods ==========
|
||||
|
||||
// GetCommentsByCursor 游标分页获取帖子评论(包含回复)
|
||||
// 排序方式:created_at DESC
|
||||
func (r *CommentRepository) GetCommentsByCursor(ctx context.Context, postID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Comment], error) {
|
||||
// 构建基础查询 - 只查询顶级评论
|
||||
query := r.db.WithContext(ctx).Model(&model.Comment{}).
|
||||
Where("post_id = ? AND parent_id IS NULL AND status = ?", postID, model.CommentStatusPublished)
|
||||
|
||||
// 使用游标构建器
|
||||
builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc).
|
||||
WithCursor(req.Cursor, req.Direction).
|
||||
WithPageSize(req.PageSize)
|
||||
|
||||
if builder.Error() != nil {
|
||||
// 无效游标,返回空列表
|
||||
return cursor.NewCursorPageResult[*model.Comment]([]*model.Comment{}, "", "", false), nil
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
var comments []*model.Comment
|
||||
query = builder.Build().Preload("User")
|
||||
if err := query.Find(&comments).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
pageSize := builder.GetPageSize()
|
||||
hasMore := cursor.HasMore(len(comments), pageSize)
|
||||
if hasMore {
|
||||
comments = comments[:pageSize]
|
||||
}
|
||||
|
||||
// 加载每个评论的回复(默认加载前3条)
|
||||
if len(comments) > 0 {
|
||||
r.loadRepliesForComments(comments, 3)
|
||||
}
|
||||
|
||||
// 生成游标
|
||||
var nextCursor, prevCursor string
|
||||
if len(comments) > 0 {
|
||||
// 下一页游标
|
||||
if hasMore {
|
||||
lastComment := comments[len(comments)-1]
|
||||
nextCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(lastComment.CreatedAt),
|
||||
lastComment.ID,
|
||||
cursor.SortByCreatedAtDesc,
|
||||
).Encode()
|
||||
}
|
||||
// 上一页游标
|
||||
firstComment := comments[0]
|
||||
prevCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(firstComment.CreatedAt),
|
||||
firstComment.ID,
|
||||
cursor.SortByCreatedAtDesc,
|
||||
).Encode()
|
||||
}
|
||||
|
||||
return cursor.NewCursorPageResult(comments, nextCursor, prevCursor, hasMore), nil
|
||||
}
|
||||
|
||||
// loadRepliesForComments 为评论列表加载回复
|
||||
func (r *CommentRepository) loadRepliesForComments(comments []*model.Comment, replyLimit int) {
|
||||
if len(comments) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
rootIDs := make([]string, 0, len(comments))
|
||||
commentsByID := make(map[string]*model.Comment, len(comments))
|
||||
for _, comment := range comments {
|
||||
rootIDs = append(rootIDs, comment.ID)
|
||||
commentsByID[comment.ID] = comment
|
||||
}
|
||||
|
||||
// 批量加载所有回复
|
||||
var allReplies []*model.Comment
|
||||
if err := r.db.Where("root_id IN ? AND status = ?", rootIDs, model.CommentStatusPublished).
|
||||
Preload("User").
|
||||
Order("created_at ASC").
|
||||
Find(&allReplies).Error; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 按 root_id 分组并裁剪
|
||||
repliesByRoot := make(map[string][]*model.Comment, len(rootIDs))
|
||||
for _, reply := range allReplies {
|
||||
if reply.RootID == nil {
|
||||
continue
|
||||
}
|
||||
rootID := *reply.RootID
|
||||
if replyLimit <= 0 || len(repliesByRoot[rootID]) < replyLimit {
|
||||
repliesByRoot[rootID] = append(repliesByRoot[rootID], reply)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询每个根评论的回复总数
|
||||
type replyCountRow struct {
|
||||
RootID string
|
||||
Total int64
|
||||
}
|
||||
var replyCountRows []replyCountRow
|
||||
if err := r.db.Model(&model.Comment{}).
|
||||
Select("root_id, COUNT(*) AS total").
|
||||
Where("root_id IN ? AND status = ?", rootIDs, model.CommentStatusPublished).
|
||||
Group("root_id").
|
||||
Scan(&replyCountRows).Error; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
replyCountMap := make(map[string]int64, len(replyCountRows))
|
||||
for _, row := range replyCountRows {
|
||||
replyCountMap[row.RootID] = row.Total
|
||||
}
|
||||
|
||||
// 设置回复和回复数
|
||||
for _, rootID := range rootIDs {
|
||||
comment := commentsByID[rootID]
|
||||
comment.Replies = repliesByRoot[rootID]
|
||||
comment.RepliesCount = int(replyCountMap[rootID])
|
||||
}
|
||||
}
|
||||
|
||||
// GetRepliesByCursor 游标分页获取根评论的回复
|
||||
// 排序方式:created_at ASC(回复按时间正序)
|
||||
func (r *CommentRepository) GetRepliesByCursor(ctx context.Context, rootID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Comment], error) {
|
||||
// 构建基础查询
|
||||
query := r.db.WithContext(ctx).Model(&model.Comment{}).
|
||||
Where("root_id = ? AND status = ?", rootID, model.CommentStatusPublished)
|
||||
|
||||
// 使用游标构建器(回复按时间升序)
|
||||
builder := cursor.NewBuilder(query, cursor.SortByCreatedAtAsc).
|
||||
WithCursor(req.Cursor, req.Direction).
|
||||
WithPageSize(req.PageSize)
|
||||
|
||||
if builder.Error() != nil {
|
||||
// 无效游标,返回空列表
|
||||
return cursor.NewCursorPageResult[*model.Comment]([]*model.Comment{}, "", "", false), nil
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
var replies []*model.Comment
|
||||
query = builder.Build().Preload("User")
|
||||
if err := query.Find(&replies).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
pageSize := builder.GetPageSize()
|
||||
hasMore := cursor.HasMore(len(replies), pageSize)
|
||||
if hasMore {
|
||||
replies = replies[:pageSize]
|
||||
}
|
||||
|
||||
// 生成游标
|
||||
var nextCursor, prevCursor string
|
||||
if len(replies) > 0 {
|
||||
// 下一页游标
|
||||
if hasMore {
|
||||
lastReply := replies[len(replies)-1]
|
||||
nextCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(lastReply.CreatedAt),
|
||||
lastReply.ID,
|
||||
cursor.SortByCreatedAtAsc,
|
||||
).Encode()
|
||||
}
|
||||
// 上一页游标
|
||||
firstReply := replies[0]
|
||||
prevCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(firstReply.CreatedAt),
|
||||
firstReply.ID,
|
||||
cursor.SortByCreatedAtAsc,
|
||||
).Encode()
|
||||
}
|
||||
|
||||
return cursor.NewCursorPageResult(replies, nextCursor, prevCursor, hasMore), nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package repository
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -43,6 +45,12 @@ type GroupRepository interface {
|
||||
UpdateGroupStatus(groupID string, status string) error
|
||||
GetGroupPostCount(groupID string) (int64, error)
|
||||
GetMembersWithUserInfo(groupID string, page, pageSize int) ([]GroupMemberWithUser, int64, error)
|
||||
|
||||
// 游标分页方法
|
||||
GetGroupsByCursor(ctx context.Context, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Group], error)
|
||||
GetUserGroupsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Group], error)
|
||||
GetMembersByCursor(ctx context.Context, groupID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.GroupMember], error)
|
||||
GetAnnouncementsByCursor(ctx context.Context, groupID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.GroupAnnouncement], error)
|
||||
}
|
||||
|
||||
// GroupMemberWithUser 群成员带用户信息的结构
|
||||
@@ -410,3 +418,227 @@ func (r *groupRepository) GetMembersWithUserInfo(groupID string, page, pageSize
|
||||
|
||||
return members, total, nil
|
||||
}
|
||||
|
||||
// ========== Cursor Pagination Methods ==========
|
||||
|
||||
// GetGroupsByCursor 游标分页获取群组列表
|
||||
// 排序方式:created_at DESC
|
||||
func (r *groupRepository) GetGroupsByCursor(ctx context.Context, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Group], error) {
|
||||
// 构建基础查询
|
||||
query := r.db.WithContext(ctx).Model(&model.Group{})
|
||||
|
||||
// 使用游标构建器
|
||||
builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc).
|
||||
WithCursor(req.Cursor, req.Direction).
|
||||
WithPageSize(req.PageSize)
|
||||
|
||||
if builder.Error() != nil {
|
||||
// 无效游标,返回空列表
|
||||
return cursor.NewCursorPageResult[*model.Group]([]*model.Group{}, "", "", false), nil
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
var groups []*model.Group
|
||||
query = builder.Build()
|
||||
if err := query.Find(&groups).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
pageSize := builder.GetPageSize()
|
||||
hasMore := cursor.HasMore(len(groups), pageSize)
|
||||
if hasMore {
|
||||
groups = groups[:pageSize]
|
||||
}
|
||||
|
||||
// 生成游标
|
||||
var nextCursor, prevCursor string
|
||||
if len(groups) > 0 {
|
||||
// 下一页游标
|
||||
if hasMore {
|
||||
lastGroup := groups[len(groups)-1]
|
||||
nextCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(lastGroup.CreatedAt),
|
||||
lastGroup.ID,
|
||||
cursor.SortByCreatedAtDesc,
|
||||
).Encode()
|
||||
}
|
||||
// 上一页游标
|
||||
firstGroup := groups[0]
|
||||
prevCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(firstGroup.CreatedAt),
|
||||
firstGroup.ID,
|
||||
cursor.SortByCreatedAtDesc,
|
||||
).Encode()
|
||||
}
|
||||
|
||||
return cursor.NewCursorPageResult(groups, nextCursor, prevCursor, hasMore), nil
|
||||
}
|
||||
|
||||
// GetUserGroupsByCursor 游标分页获取用户加入的群组列表
|
||||
// 排序方式:created_at DESC
|
||||
func (r *groupRepository) GetUserGroupsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Group], error) {
|
||||
// 通过群成员表查询用户加入的群组
|
||||
subQuery := r.db.Model(&model.GroupMember{}).
|
||||
Select("group_id").
|
||||
Where("user_id = ?", userID)
|
||||
|
||||
// 构建基础查询
|
||||
query := r.db.WithContext(ctx).Model(&model.Group{}).Where("id IN (?)", subQuery)
|
||||
|
||||
// 使用游标构建器
|
||||
builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc).
|
||||
WithCursor(req.Cursor, req.Direction).
|
||||
WithPageSize(req.PageSize)
|
||||
|
||||
if builder.Error() != nil {
|
||||
// 无效游标,返回空列表
|
||||
return cursor.NewCursorPageResult[*model.Group]([]*model.Group{}, "", "", false), nil
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
var groups []*model.Group
|
||||
query = builder.Build()
|
||||
if err := query.Find(&groups).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
pageSize := builder.GetPageSize()
|
||||
hasMore := cursor.HasMore(len(groups), pageSize)
|
||||
if hasMore {
|
||||
groups = groups[:pageSize]
|
||||
}
|
||||
|
||||
// 生成游标
|
||||
var nextCursor, prevCursor string
|
||||
if len(groups) > 0 {
|
||||
// 下一页游标
|
||||
if hasMore {
|
||||
lastGroup := groups[len(groups)-1]
|
||||
nextCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(lastGroup.CreatedAt),
|
||||
lastGroup.ID,
|
||||
cursor.SortByCreatedAtDesc,
|
||||
).Encode()
|
||||
}
|
||||
// 上一页游标
|
||||
firstGroup := groups[0]
|
||||
prevCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(firstGroup.CreatedAt),
|
||||
firstGroup.ID,
|
||||
cursor.SortByCreatedAtDesc,
|
||||
).Encode()
|
||||
}
|
||||
|
||||
return cursor.NewCursorPageResult(groups, nextCursor, prevCursor, hasMore), nil
|
||||
}
|
||||
|
||||
// GetMembersByCursor 游标分页获取群成员列表
|
||||
// 排序方式:join_time DESC(新成员优先)
|
||||
func (r *groupRepository) GetMembersByCursor(ctx context.Context, groupID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.GroupMember], error) {
|
||||
// 构建基础查询
|
||||
query := r.db.WithContext(ctx).Model(&model.GroupMember{}).Where("group_id = ?", groupID)
|
||||
|
||||
// 使用游标构建器
|
||||
builder := cursor.NewBuilder(query, cursor.SortByJoinTimeDesc).
|
||||
WithCursor(req.Cursor, req.Direction).
|
||||
WithPageSize(req.PageSize)
|
||||
|
||||
if builder.Error() != nil {
|
||||
// 无效游标,返回空列表
|
||||
return cursor.NewCursorPageResult[*model.GroupMember]([]*model.GroupMember{}, "", "", false), nil
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
var members []*model.GroupMember
|
||||
query = builder.Build()
|
||||
if err := query.Find(&members).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
pageSize := builder.GetPageSize()
|
||||
hasMore := cursor.HasMore(len(members), pageSize)
|
||||
if hasMore {
|
||||
members = members[:pageSize]
|
||||
}
|
||||
|
||||
// 生成游标
|
||||
var nextCursor, prevCursor string
|
||||
if len(members) > 0 {
|
||||
// 下一页游标
|
||||
if hasMore {
|
||||
lastMember := members[len(members)-1]
|
||||
nextCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(lastMember.JoinTime),
|
||||
lastMember.ID,
|
||||
cursor.SortByJoinTimeDesc,
|
||||
).Encode()
|
||||
}
|
||||
// 上一页游标
|
||||
firstMember := members[0]
|
||||
prevCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(firstMember.JoinTime),
|
||||
firstMember.ID,
|
||||
cursor.SortByJoinTimeDesc,
|
||||
).Encode()
|
||||
}
|
||||
|
||||
return cursor.NewCursorPageResult(members, nextCursor, prevCursor, hasMore), nil
|
||||
}
|
||||
|
||||
// GetAnnouncementsByCursor 游标分页获取群公告列表
|
||||
// 排序方式:is_pinned DESC, created_at DESC(置顶优先,然后按时间倒序)
|
||||
// 注意:由于有置顶逻辑,这里使用 created_at DESC 作为游标排序
|
||||
func (r *groupRepository) GetAnnouncementsByCursor(ctx context.Context, groupID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.GroupAnnouncement], error) {
|
||||
// 构建基础查询
|
||||
query := r.db.WithContext(ctx).Model(&model.GroupAnnouncement{}).Where("group_id = ?", groupID)
|
||||
|
||||
// 使用游标构建器
|
||||
builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc).
|
||||
WithCursor(req.Cursor, req.Direction).
|
||||
WithPageSize(req.PageSize)
|
||||
|
||||
if builder.Error() != nil {
|
||||
// 无效游标,返回空列表
|
||||
return cursor.NewCursorPageResult[*model.GroupAnnouncement]([]*model.GroupAnnouncement{}, "", "", false), nil
|
||||
}
|
||||
|
||||
// 执行查询(置顶的排在前面)
|
||||
var announcements []*model.GroupAnnouncement
|
||||
query = builder.Build().Order("is_pinned DESC, created_at DESC")
|
||||
if err := query.Find(&announcements).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
pageSize := builder.GetPageSize()
|
||||
hasMore := cursor.HasMore(len(announcements), pageSize)
|
||||
if hasMore {
|
||||
announcements = announcements[:pageSize]
|
||||
}
|
||||
|
||||
// 生成游标
|
||||
var nextCursor, prevCursor string
|
||||
if len(announcements) > 0 {
|
||||
// 下一页游标
|
||||
if hasMore {
|
||||
lastAnnouncement := announcements[len(announcements)-1]
|
||||
nextCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(lastAnnouncement.CreatedAt),
|
||||
lastAnnouncement.ID,
|
||||
cursor.SortByCreatedAtDesc,
|
||||
).Encode()
|
||||
}
|
||||
// 上一页游标
|
||||
firstAnnouncement := announcements[0]
|
||||
prevCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(firstAnnouncement.CreatedAt),
|
||||
firstAnnouncement.ID,
|
||||
cursor.SortByCreatedAtDesc,
|
||||
).Encode()
|
||||
}
|
||||
|
||||
return cursor.NewCursorPageResult(announcements, nextCursor, prevCursor, hasMore), nil
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ package repository
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -655,3 +657,125 @@ func (r *MessageRepository) UpdateConversationLastSeqWithTx(tx *gorm.DB, convID
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// ========== Cursor Pagination Methods ==========
|
||||
|
||||
// GetMessagesByCursor 游标分页获取会话消息
|
||||
// 消息按 seq DESC 排序
|
||||
func (r *MessageRepository) GetMessagesByCursor(ctx context.Context, conversationID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Message], error) {
|
||||
// 构建基础查询
|
||||
query := r.db.WithContext(ctx).Model(&model.Message{}).Where("conversation_id = ?", conversationID)
|
||||
|
||||
// 使用游标构建器,消息按 seq DESC 排序
|
||||
builder := cursor.NewBuilder(query, cursor.SortBySeqDesc).
|
||||
WithCursor(req.Cursor, req.Direction).
|
||||
WithPageSize(req.PageSize)
|
||||
|
||||
if builder.Error() != nil {
|
||||
// 无效游标,返回空列表
|
||||
return cursor.NewCursorPageResult[*model.Message]([]*model.Message{}, "", "", false), nil
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
var messages []*model.Message
|
||||
if err := builder.Build().Find(&messages).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 解密消息
|
||||
if len(messages) > 0 {
|
||||
model.BatchDecryptMessagesParallel(messages)
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
pageSize := builder.GetPageSize()
|
||||
hasMore := cursor.HasMore(len(messages), pageSize)
|
||||
if hasMore {
|
||||
messages = messages[:pageSize]
|
||||
}
|
||||
|
||||
// 生成游标
|
||||
var nextCursor, prevCursor string
|
||||
if len(messages) > 0 {
|
||||
// 下一页游标
|
||||
if hasMore {
|
||||
lastMsg := messages[len(messages)-1]
|
||||
nextCursor = cursor.NewCursor(
|
||||
strconv.FormatInt(lastMsg.Seq, 10),
|
||||
lastMsg.ID,
|
||||
cursor.SortBySeqDesc,
|
||||
).Encode()
|
||||
}
|
||||
// 上一页游标
|
||||
firstMsg := messages[0]
|
||||
prevCursor = cursor.NewCursor(
|
||||
strconv.FormatInt(firstMsg.Seq, 10),
|
||||
firstMsg.ID,
|
||||
cursor.SortBySeqDesc,
|
||||
).Encode()
|
||||
}
|
||||
|
||||
return cursor.NewCursorPageResult(messages, nextCursor, prevCursor, hasMore), nil
|
||||
}
|
||||
|
||||
// GetConversationsByCursor 游标分页获取用户会话列表
|
||||
// 会话按置顶优先,然后按 updated_at DESC 排序
|
||||
// 注意:会话列表的排序涉及 conversation_participants 表的 is_pinned 和 updated_at 字段
|
||||
func (r *MessageRepository) GetConversationsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Conversation], error) {
|
||||
// 构建基础查询 - 关联 conversation_participants 表
|
||||
query := r.db.WithContext(ctx).Model(&model.Conversation{}).
|
||||
Joins("INNER JOIN conversation_participants cp ON conversations.id = cp.conversation_id").
|
||||
Where("cp.user_id = ? AND cp.hidden_at IS NULL", userID).
|
||||
Preload("Group")
|
||||
|
||||
// 会话列表需要特殊处理:先按置顶排序,再按更新时间排序
|
||||
// 由于涉及多字段排序(is_pinned DESC, updated_at DESC),需要自定义处理
|
||||
builder := cursor.NewBuilder(query, cursor.SortByUpdatedAtDesc).
|
||||
WithCursor(req.Cursor, req.Direction).
|
||||
WithPageSize(req.PageSize)
|
||||
|
||||
if builder.Error() != nil {
|
||||
// 无效游标,返回空列表
|
||||
return cursor.NewCursorPageResult[*model.Conversation]([]*model.Conversation{}, "", "", false), nil
|
||||
}
|
||||
|
||||
// 执行查询 - 需要添加置顶排序
|
||||
var conversations []*model.Conversation
|
||||
query = builder.Build()
|
||||
// 添加置顶排序(在游标排序之前)
|
||||
query = query.Order("cp.is_pinned DESC").Order("conversations.updated_at DESC")
|
||||
|
||||
if err := query.Find(&conversations).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
pageSize := builder.GetPageSize()
|
||||
hasMore := cursor.HasMore(len(conversations), pageSize)
|
||||
if hasMore {
|
||||
conversations = conversations[:pageSize]
|
||||
}
|
||||
|
||||
// 生成游标
|
||||
var nextCursor, prevCursor string
|
||||
if len(conversations) > 0 {
|
||||
// 下一页游标
|
||||
if hasMore {
|
||||
lastConv := conversations[len(conversations)-1]
|
||||
nextCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(lastConv.UpdatedAt),
|
||||
lastConv.ID,
|
||||
cursor.SortByUpdatedAtDesc,
|
||||
).Encode()
|
||||
}
|
||||
// 上一页游标
|
||||
firstConv := conversations[0]
|
||||
prevCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(firstConv.UpdatedAt),
|
||||
firstConv.ID,
|
||||
cursor.SortByUpdatedAtDesc,
|
||||
).Encode()
|
||||
}
|
||||
|
||||
return cursor.NewCursorPageResult(conversations, nextCursor, prevCursor, hasMore), nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package repository
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -76,3 +78,63 @@ func (r *NotificationRepository) GetUnreadCount(userID string) (int64, error) {
|
||||
func (r *NotificationRepository) DeleteAllByUserID(userID string) error {
|
||||
return r.db.Where("user_id = ?", userID).Delete(&model.Notification{}).Error
|
||||
}
|
||||
|
||||
// ========== Cursor Pagination Methods ==========
|
||||
|
||||
// GetNotificationsByCursor 游标分页获取用户通知
|
||||
// 排序方式:created_at DESC
|
||||
func (r *NotificationRepository) GetNotificationsByCursor(ctx context.Context, userID string, unreadOnly bool, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Notification], error) {
|
||||
// 构建基础查询
|
||||
query := r.db.WithContext(ctx).Model(&model.Notification{}).Where("user_id = ?", userID)
|
||||
|
||||
if unreadOnly {
|
||||
query = query.Where("is_read = ?", false)
|
||||
}
|
||||
|
||||
// 使用游标构建器
|
||||
builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc).
|
||||
WithCursor(req.Cursor, req.Direction).
|
||||
WithPageSize(req.PageSize)
|
||||
|
||||
if builder.Error() != nil {
|
||||
// 无效游标,返回空列表
|
||||
return cursor.NewCursorPageResult[*model.Notification]([]*model.Notification{}, "", "", false), nil
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
var notifications []*model.Notification
|
||||
query = builder.Build()
|
||||
if err := query.Find(¬ifications).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
pageSize := builder.GetPageSize()
|
||||
hasMore := cursor.HasMore(len(notifications), pageSize)
|
||||
if hasMore {
|
||||
notifications = notifications[:pageSize]
|
||||
}
|
||||
|
||||
// 生成游标
|
||||
var nextCursor, prevCursor string
|
||||
if len(notifications) > 0 {
|
||||
// 下一页游标
|
||||
if hasMore {
|
||||
lastNotification := notifications[len(notifications)-1]
|
||||
nextCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(lastNotification.CreatedAt),
|
||||
lastNotification.ID,
|
||||
cursor.SortByCreatedAtDesc,
|
||||
).Encode()
|
||||
}
|
||||
// 上一页游标
|
||||
firstNotification := notifications[0]
|
||||
prevCursor = cursor.NewCursor(
|
||||
cursor.FormatTime(firstNotification.CreatedAt),
|
||||
firstNotification.ID,
|
||||
cursor.SortByCreatedAtDesc,
|
||||
).Encode()
|
||||
}
|
||||
|
||||
return cursor.NewCursorPageResult(notifications, nextCursor, prevCursor, hasMore), nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -263,12 +263,14 @@ func (r *Router) setupRoutes() {
|
||||
comments := v1.Group("/comments")
|
||||
{
|
||||
comments.GET("/post/:id", middleware.OptionalAuth(r.jwtService), r.commentHandler.GetByPostID)
|
||||
comments.GET("/post/:id/cursor", middleware.OptionalAuth(r.jwtService), r.commentHandler.GetByPostIDByCursor) // 帖子评论游标分页
|
||||
comments.GET("/:id", middleware.OptionalAuth(r.jwtService), r.commentHandler.GetByID)
|
||||
comments.POST("", authMiddleware, r.commentHandler.Create)
|
||||
comments.PUT("/:id", authMiddleware, r.commentHandler.Update)
|
||||
comments.DELETE("/:id", authMiddleware, r.commentHandler.Delete)
|
||||
comments.GET("/:id/replies", middleware.OptionalAuth(r.jwtService), r.commentHandler.GetReplies)
|
||||
comments.GET("/:id/replies/flat", middleware.OptionalAuth(r.jwtService), r.commentHandler.GetRepliesByRootID) // 扁平化分页获取回复
|
||||
comments.GET("/:id/replies/flat", middleware.OptionalAuth(r.jwtService), r.commentHandler.GetRepliesByRootID) // 扁平化分页获取回复
|
||||
comments.GET("/:id/replies/cursor", middleware.OptionalAuth(r.jwtService), r.commentHandler.GetRepliesByRootIDByCursor) // 回复游标分页
|
||||
// 评论点赞
|
||||
comments.POST("/:id/like", authMiddleware, r.commentHandler.Like)
|
||||
comments.DELETE("/:id/like", authMiddleware, r.commentHandler.Unlike)
|
||||
@@ -282,9 +284,11 @@ func (r *Router) setupRoutes() {
|
||||
// 新的 RESTful 风格路由(推荐使用)
|
||||
// ================================================================
|
||||
conversations.GET("", r.messageHandler.HandleGetConversationList) // 列表
|
||||
conversations.GET("/cursor", r.messageHandler.GetConversationsByCursor) // 会话列表游标分页
|
||||
conversations.POST("", r.messageHandler.HandleCreateConversation) // 创建
|
||||
conversations.GET("/:id", r.messageHandler.HandleGetConversation) // 详情
|
||||
conversations.GET("/:id/messages", r.messageHandler.HandleGetMessages) // 消息列表
|
||||
conversations.GET("/:id/messages/cursor", r.messageHandler.GetMessagesByCursor) // 消息列表游标分页
|
||||
conversations.POST("/:id/messages", r.messageHandler.HandleSendMessage) // 发送消息
|
||||
conversations.POST("/:id/read", r.messageHandler.HandleMarkRead) // 标记已读
|
||||
conversations.PUT("/:id/pinned", r.messageHandler.HandleSetConversationPinned) // 置顶设置
|
||||
@@ -312,6 +316,7 @@ func (r *Router) setupRoutes() {
|
||||
notifications := v1.Group("/notifications")
|
||||
{
|
||||
notifications.GET("", authMiddleware, r.notificationHandler.GetNotifications)
|
||||
notifications.GET("/cursor", authMiddleware, r.notificationHandler.GetNotificationsByCursor) // 通知游标分页
|
||||
notifications.POST("/:id/read", authMiddleware, r.notificationHandler.MarkAsRead)
|
||||
notifications.POST("/read-all", authMiddleware, r.notificationHandler.MarkAllAsRead)
|
||||
notifications.GET("/unread-count", authMiddleware, r.notificationHandler.GetUnreadCount)
|
||||
@@ -360,6 +365,7 @@ func (r *Router) setupRoutes() {
|
||||
// ================================================================
|
||||
// 群组基本操作
|
||||
groups.GET("", r.groupHandler.HandleGetUserGroups) // 列表
|
||||
groups.GET("/cursor", r.groupHandler.GetGroupsByCursor) // 群组游标分页
|
||||
groups.POST("", r.groupHandler.HandleCreateGroup) // 创建
|
||||
groups.GET("/:id", r.groupHandler.HandleGetGroupInfo) // 详情
|
||||
groups.GET("/:id/me", r.groupHandler.HandleGetMyMemberInfo) // 当前用户成员信息
|
||||
@@ -372,6 +378,7 @@ func (r *Router) setupRoutes() {
|
||||
groups.POST("/:id/join-requests/respond", r.groupHandler.HandleRespondInvite) // 响应加群邀请/申请
|
||||
groups.POST("/:id/leave", r.groupHandler.HandleSetGroupLeave) // 退群
|
||||
groups.GET("/:id/members", r.groupHandler.HandleGetGroupMemberList) // 成员列表
|
||||
groups.GET("/:id/members/cursor", r.groupHandler.GetMembersByCursor) // 成员游标分页
|
||||
groups.POST("/:id/members/kick", r.groupHandler.HandleSetGroupKick) // 踢出成员
|
||||
groups.PUT("/:id/members/:user_id/admin", r.groupHandler.HandleSetGroupAdmin) // 设置管理员
|
||||
groups.PUT("/:id/members/me/nickname", r.groupHandler.HandleSetNickname) // 设置群昵称
|
||||
@@ -386,6 +393,7 @@ func (r *Router) setupRoutes() {
|
||||
// 群公告
|
||||
groups.POST("/:id/announcements", r.groupHandler.HandleCreateAnnouncement) // 创建公告
|
||||
groups.GET("/:id/announcements", r.groupHandler.HandleGetAnnouncements) // 公告列表
|
||||
groups.GET("/:id/announcements/cursor", r.groupHandler.GetAnnouncementsByCursor) // 公告游标分页
|
||||
groups.DELETE("/:id/announcements/:announcement_id", r.groupHandler.HandleDeleteAnnouncement) // 删除公告
|
||||
|
||||
// 加群请求处理
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"carrot_bbs/internal/cache"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"carrot_bbs/internal/pkg/gorse"
|
||||
"carrot_bbs/internal/pkg/hook"
|
||||
"carrot_bbs/internal/repository"
|
||||
@@ -355,3 +356,13 @@ func (s *CommentService) Unlike(ctx context.Context, commentID, userID string) e
|
||||
func (s *CommentService) IsLiked(ctx context.Context, commentID, userID string) bool {
|
||||
return s.commentRepo.IsLiked(commentID, userID)
|
||||
}
|
||||
|
||||
// GetCommentsByCursor 游标分页获取帖子评论
|
||||
func (s *CommentService) GetCommentsByCursor(ctx context.Context, postID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Comment], error) {
|
||||
return s.commentRepo.GetCommentsByCursor(ctx, postID, req)
|
||||
}
|
||||
|
||||
// GetRepliesByCursor 游标分页获取根评论的回复
|
||||
func (s *CommentService) GetRepliesByCursor(ctx context.Context, rootID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Comment], error) {
|
||||
return s.commentRepo.GetRepliesByCursor(ctx, rootID, req)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"carrot_bbs/internal/cache"
|
||||
apperrors "carrot_bbs/internal/errors"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"carrot_bbs/internal/pkg/sse"
|
||||
"carrot_bbs/internal/pkg/utils"
|
||||
"carrot_bbs/internal/repository"
|
||||
@@ -88,6 +90,12 @@ type GroupService interface {
|
||||
|
||||
// 获取成员信息
|
||||
GetMember(groupID string, userID string) (*model.GroupMember, error)
|
||||
|
||||
// 游标分页方法
|
||||
GetGroupsByCursor(ctx context.Context, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Group], error)
|
||||
GetUserGroupsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Group], error)
|
||||
GetMembersByCursor(ctx context.Context, groupID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.GroupMember], error)
|
||||
GetAnnouncementsByCursor(ctx context.Context, groupID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.GroupAnnouncement], error)
|
||||
}
|
||||
|
||||
// GroupMembersResult 群组成员缓存结果
|
||||
@@ -1614,3 +1622,25 @@ func (s *groupService) IsGroupOwner(userID string, groupID string) bool {
|
||||
func (s *groupService) GetMember(groupID string, userID string) (*model.GroupMember, error) {
|
||||
return s.groupRepo.GetMember(groupID, userID)
|
||||
}
|
||||
|
||||
// ==================== 游标分页方法 ====================
|
||||
|
||||
// GetGroupsByCursor 游标分页获取群组列表
|
||||
func (s *groupService) GetGroupsByCursor(ctx context.Context, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Group], error) {
|
||||
return s.groupRepo.GetGroupsByCursor(ctx, req)
|
||||
}
|
||||
|
||||
// GetUserGroupsByCursor 游标分页获取用户加入的群组列表
|
||||
func (s *groupService) GetUserGroupsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Group], error) {
|
||||
return s.groupRepo.GetUserGroupsByCursor(ctx, userID, req)
|
||||
}
|
||||
|
||||
// GetMembersByCursor 游标分页获取群成员列表
|
||||
func (s *groupService) GetMembersByCursor(ctx context.Context, groupID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.GroupMember], error) {
|
||||
return s.groupRepo.GetMembersByCursor(ctx, groupID, req)
|
||||
}
|
||||
|
||||
// GetAnnouncementsByCursor 游标分页获取群公告列表
|
||||
func (s *groupService) GetAnnouncementsByCursor(ctx context.Context, groupID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.GroupAnnouncement], error) {
|
||||
return s.groupRepo.GetAnnouncementsByCursor(ctx, groupID, req)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"carrot_bbs/internal/cache"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"carrot_bbs/internal/repository"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -294,3 +295,13 @@ func (s *MessageService) InvalidateUserUnreadCache(userID, conversationID string
|
||||
cache.InvalidateUnreadConversation(s.baseCache, userID)
|
||||
s.conversationCache.InvalidateUnreadCount(userID, conversationID)
|
||||
}
|
||||
|
||||
// GetMessagesByCursor 游标分页获取会话消息
|
||||
func (s *MessageService) GetMessagesByCursor(ctx context.Context, conversationID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Message], error) {
|
||||
return s.messageRepo.GetMessagesByCursor(ctx, conversationID, req)
|
||||
}
|
||||
|
||||
// GetConversationsByCursor 游标分页获取用户会话列表
|
||||
func (s *MessageService) GetConversationsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Conversation], error) {
|
||||
return s.messageRepo.GetConversationsByCursor(ctx, userID, req)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"carrot_bbs/internal/cache"
|
||||
apperrors "carrot_bbs/internal/errors"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"carrot_bbs/internal/repository"
|
||||
)
|
||||
|
||||
@@ -166,5 +167,10 @@ func (s *NotificationService) ClearAllNotifications(ctx context.Context, userID
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetNotificationsByCursor 游标分页获取用户通知
|
||||
func (s *NotificationService) GetNotificationsByCursor(ctx context.Context, userID string, unreadOnly bool, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Notification], error) {
|
||||
return s.notificationRepo.GetNotificationsByCursor(ctx, userID, unreadOnly, req)
|
||||
}
|
||||
|
||||
// 错误定义
|
||||
var ErrUnauthorizedNotification = apperrors.ErrUnauthorizedNotification
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"carrot_bbs/internal/cache"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"carrot_bbs/internal/pkg/gorse"
|
||||
"carrot_bbs/internal/pkg/hook"
|
||||
"carrot_bbs/internal/repository"
|
||||
@@ -40,6 +41,11 @@ type PostService interface {
|
||||
GetFavorites(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error)
|
||||
Search(ctx context.Context, keyword string, page, pageSize int) ([]*model.Post, int64, error)
|
||||
|
||||
// 游标分页方法
|
||||
ListByCursor(ctx context.Context, userID string, includePending bool, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error)
|
||||
SearchByCursor(ctx context.Context, keyword string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error)
|
||||
GetUserPostsByCursor(ctx context.Context, userID string, includePending bool, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error)
|
||||
|
||||
// 关注和推荐
|
||||
GetFollowingPosts(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error)
|
||||
GetHotPosts(ctx context.Context, page, pageSize int) ([]*model.Post, int64, error)
|
||||
@@ -760,3 +766,29 @@ func (s *postServiceImpl) DeletePostWithTransaction(ctx context.Context, postID
|
||||
return s.postRepo.DeleteWithContext(ctx, postID)
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 游标分页方法 ==========
|
||||
|
||||
// ListByCursor 游标分页获取帖子列表
|
||||
func (s *postServiceImpl) ListByCursor(ctx context.Context, userID string, includePending bool, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
|
||||
// 规范化请求参数
|
||||
req.Normalize()
|
||||
|
||||
return s.postRepo.GetPostsByCursor(ctx, userID, includePending, req)
|
||||
}
|
||||
|
||||
// SearchByCursor 游标分页搜索帖子
|
||||
func (s *postServiceImpl) SearchByCursor(ctx context.Context, keyword string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
|
||||
// 规范化请求参数
|
||||
req.Normalize()
|
||||
|
||||
return s.postRepo.SearchPostsByCursor(ctx, keyword, req)
|
||||
}
|
||||
|
||||
// GetUserPostsByCursor 游标分页获取用户帖子
|
||||
func (s *postServiceImpl) GetUserPostsByCursor(ctx context.Context, userID string, includePending bool, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) {
|
||||
// 规范化请求参数
|
||||
req.Normalize()
|
||||
|
||||
return s.postRepo.GetUserPostsByCursor(ctx, userID, includePending, req)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user