diff --git a/internal/dto/dto.go b/internal/dto/dto.go index c303866..057586b 100644 --- a/internal/dto/dto.go +++ b/internal/dto/dto.go @@ -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"` +} diff --git a/internal/dto/group_converter.go b/internal/dto/group_converter.go index 573cb7b..7b34df1 100644 --- a/internal/dto/group_converter.go +++ b/internal/dto/group_converter.go @@ -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 +} diff --git a/internal/handler/comment_handler.go b/internal/handler/comment_handler.go index 05ec327..34e5535 100644 --- a/internal/handler/comment_handler.go +++ b/internal/handler/comment_handler.go @@ -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) +} diff --git a/internal/handler/group_handler.go b/internal/handler/group_handler.go index 6ea8d39..7abe634 100644 --- a/internal/handler/group_handler.go +++ b/internal/handler/group_handler.go @@ -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 群组踢人 diff --git a/internal/handler/message_handler.go b/internal/handler/message_handler.go index 0fcdfc2..a01fee7 100644 --- a/internal/handler/message_handler.go +++ b/internal/handler/message_handler.go @@ -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) +} diff --git a/internal/handler/notification_handler.go b/internal/handler/notification_handler.go index 9c24722..5c84035 100644 --- a/internal/handler/notification_handler.go +++ b/internal/handler/notification_handler.go @@ -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) +} diff --git a/internal/handler/post_handler.go b/internal/handler/post_handler.go index 2215d25..0705800 100644 --- a/internal/handler/post_handler.go +++ b/internal/handler/post_handler.go @@ -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) +} diff --git a/internal/pkg/cursor/builder.go b/internal/pkg/cursor/builder.go new file mode 100644 index 0000000..7cdaf8c --- /dev/null +++ b/internal/pkg/cursor/builder.go @@ -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) +} diff --git a/internal/pkg/cursor/cursor.go b/internal/pkg/cursor/cursor.go new file mode 100644 index 0000000..5de6535 --- /dev/null +++ b/internal/pkg/cursor/cursor.go @@ -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, + } +} diff --git a/internal/pkg/cursor/pagination.go b/internal/pkg/cursor/pagination.go new file mode 100644 index 0000000..9300adf --- /dev/null +++ b/internal/pkg/cursor/pagination.go @@ -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, + } +} diff --git a/internal/repository/comment_repo.go b/internal/repository/comment_repo.go index 4e2fa3d..d29893c 100644 --- a/internal/repository/comment_repo.go +++ b/internal/repository/comment_repo.go @@ -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 +} diff --git a/internal/repository/group_repo.go b/internal/repository/group_repo.go index ec2bb2b..30f9dc1 100644 --- a/internal/repository/group_repo.go +++ b/internal/repository/group_repo.go @@ -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 +} diff --git a/internal/repository/message_repo.go b/internal/repository/message_repo.go index 17e373d..d378015 100644 --- a/internal/repository/message_repo.go +++ b/internal/repository/message_repo.go @@ -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 +} diff --git a/internal/repository/notification_repo.go b/internal/repository/notification_repo.go index 83e3f28..4523316 100644 --- a/internal/repository/notification_repo.go +++ b/internal/repository/notification_repo.go @@ -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 +} diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index e487e2c..c25137c 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -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 +} diff --git a/internal/router/router.go b/internal/router/router.go index f53b1ab..7611a33 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) // 删除公告 // 加群请求处理 diff --git a/internal/service/comment_service.go b/internal/service/comment_service.go index 32c1b09..4021cbe 100644 --- a/internal/service/comment_service.go +++ b/internal/service/comment_service.go @@ -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) +} diff --git a/internal/service/group_service.go b/internal/service/group_service.go index b6d54a3..88c5a4b 100644 --- a/internal/service/group_service.go +++ b/internal/service/group_service.go @@ -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) +} diff --git a/internal/service/message_service.go b/internal/service/message_service.go index 5b6c00d..69b32ca 100644 --- a/internal/service/message_service.go +++ b/internal/service/message_service.go @@ -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) +} diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index 30a6f3b..538b90e 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -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 diff --git a/internal/service/post_service.go b/internal/service/post_service.go index 29a9449..68174e6 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -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) +}