package handler import ( "errors" "fmt" "strconv" "github.com/gin-gonic/gin" "gorm.io/gorm" "with_you/internal/dto" "with_you/internal/model" "with_you/internal/pkg/cursor" "with_you/internal/pkg/response" "with_you/internal/service" ) // PostHandler 帖子处理器 type PostHandler struct { postService service.PostService userService service.UserService channelService service.ChannelService } // NewPostHandler 创建帖子处理器 func NewPostHandler(postService service.PostService, userService service.UserService, channelService service.ChannelService) *PostHandler { return &PostHandler{ postService: postService, userService: userService, channelService: channelService, } } // channelMapForPosts 批量解析帖子中的频道 ID,用于填充响应里的 channel 名称 func (h *PostHandler) channelMapForPosts(posts []*model.Post) map[string]*model.Channel { ids := dto.CollectPostChannelIDs(posts) if len(ids) == 0 { return nil } m, err := h.channelService.MapByIDs(ids) if err != nil { return nil } return m } // Create 创建帖子 func (h *PostHandler) Create(c *gin.Context) { userID := c.GetString("user_id") if userID == "" { response.Unauthorized(c, "") return } var req dto.CreatePostWithSegmentsRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } content := req.Content segments := req.Segments if len(segments) == 0 && content == "" { response.BadRequest(c, "content or segments is required") return } post, err := h.postService.Create(c.Request.Context(), userID, req.Title, content, segments, req.Images, req.ChannelID) if err != nil { var moderationErr *service.PostModerationRejectedError if errors.As(err, &moderationErr) { response.BadRequest(c, moderationErr.UserMessage()) return } response.InternalServerError(c, "failed to create post") return } chMap := h.channelMapForPosts([]*model.Post{post}) response.Success(c, dto.ConvertPostToResponse(post, chMap, false, false)) } // GetByID 获取帖子(不增加浏览量) func (h *PostHandler) GetByID(c *gin.Context) { id := c.Param("id") post, err := h.postService.GetByID(c.Request.Context(), id) if err != nil { response.NotFound(c, "post not found") return } // 非作者不可查看未发布内容 currentUserID := c.GetString("user_id") if post.Status != model.PostStatusPublished && post.UserID != currentUserID { response.NotFound(c, "post not found") return } // 注意:不再自动增加浏览量,浏览量通过 RecordView 端点单独记录 // 获取交互状态 isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, currentUserID) var authorWithFollowStatus *dto.UserResponse if post.User != nil { if currentUserID != "" { _, isFollowing, isFollowingMe, err := h.userService.GetUserByIDWithMutualFollowStatus(c.Request.Context(), post.UserID, currentUserID) if err == nil { authorWithFollowStatus = dto.ConvertUserToResponseWithMutualFollow(post.User, isFollowing, isFollowingMe) } else { authorWithFollowStatus = dto.ConvertUserToResponse(post.User) } } else { authorWithFollowStatus = dto.ConvertUserToResponse(post.User) } } // 构建响应 responseData := &dto.PostResponse{ ID: post.ID, UserID: post.UserID, ChannelID: post.ChannelID, Title: post.Title, Content: post.Content, Segments: dto.SegmentsOrDefault(post.Segments, post.Content), Images: dto.ConvertPostImagesToResponse(post.Images), Status: string(post.Status), LikesCount: post.LikesCount, CommentsCount: post.CommentsCount, FavoritesCount: post.FavoritesCount, SharesCount: post.SharesCount, ViewsCount: post.ViewsCount, IsPinned: post.IsPinned, IsLocked: post.IsLocked, IsVote: post.IsVote, CreatedAt: dto.FormatTime(post.CreatedAt), UpdatedAt: dto.FormatTime(post.UpdatedAt), ContentEditedAt: dto.FormatTimePointer(post.ContentEditedAt), Author: authorWithFollowStatus, IsLiked: isLiked, IsFavorited: isFavorited, } chMap := h.channelMapForPosts([]*model.Post{post}) responseData.Channel = dto.PostChannelBriefForPost(post, chMap) response.Success(c, responseData) } // RecordView 记录帖子浏览(增加浏览量) func (h *PostHandler) RecordView(c *gin.Context) { id := c.Param("id") userID := c.GetString("user_id") // 验证帖子存在 _, err := h.postService.GetByID(c.Request.Context(), id) if err != nil { response.NotFound(c, "post not found") return } // 增加浏览量 if err := h.postService.IncrementViews(c.Request.Context(), id, userID); err != nil { fmt.Printf("[ERROR] Failed to increment views for post %s: %v\n", id, err) response.InternalServerError(c, "failed to record view") return } response.Success(c, gin.H{"success": true}) } // RecordShare 记录帖子分享(shares_count +1,仅已发布) // 支持可选 JSON:{"channel":"wechat"|"qq"|"copy_link"|"system"|...},用于后续统计 func (h *PostHandler) RecordShare(c *gin.Context) { id := c.Param("id") _ = c.GetString("user_id") // 预留:登录用户维度统计 type shareBody struct { Channel string `json:"channel"` } var body shareBody if c.Request.ContentLength > 0 { if err := c.ShouldBindJSON(&body); err != nil { response.BadRequest(c, "invalid json body") return } } sharesCount, err := h.postService.RecordShare(c.Request.Context(), id, c.GetString("user_id")) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { response.NotFound(c, "post not found") return } fmt.Printf("[ERROR] RecordShare post %s: %v\n", id, err) response.InternalServerError(c, "failed to record share") return } data := gin.H{"success": true, "shares_count": sharesCount} if body.Channel != "" { data["channel"] = body.Channel } response.Success(c, data) } // List 获取帖子列表(支持游标分页和偏移分页) func (h *PostHandler) List(c *gin.Context) { // 检查是否使用游标分页 // 当 cursor 参数存在时(包括空字符串),使用游标分页 _, cursorExists := c.GetQuery("cursor") if cursorExists { h.ListByCursor(c) return } // 偏移分页(向后兼容) page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) page, pageSize = normalizePagination(page, pageSize) userID := c.Query("user_id") tab := c.Query("tab") var channelID *string if cid := c.Query("channel_id"); cid != "" { channelID = &cid } // 获取当前用户ID currentUserID := c.GetString("user_id") var posts []*model.Post var total int64 var err error // 根据 tab 参数选择不同的获取方式 switch tab { case "follow": // 获取关注用户的帖子,需要登录 if currentUserID == "" { response.Unauthorized(c, "请先登录") return } posts, total, err = h.postService.GetFollowingPosts(c.Request.Context(), currentUserID, page, pageSize) case "hot": // 获取热门帖子 posts, total, err = h.postService.GetHotPosts(c.Request.Context(), page, pageSize) case "latest": // 最新帖子 if userID != "" && userID == currentUserID { posts, total, err = h.postService.GetLatestPostsForOwner(c.Request.Context(), page, pageSize, userID, channelID) } else { posts, total, err = h.postService.GetLatestPosts(c.Request.Context(), page, pageSize, userID, channelID) } default: // 默认获取最新帖子 if userID != "" && userID == currentUserID { posts, total, err = h.postService.GetLatestPostsForOwner(c.Request.Context(), page, pageSize, userID, channelID) } else { posts, total, err = h.postService.GetLatestPosts(c.Request.Context(), page, pageSize, userID, channelID) } } if err != nil { response.InternalServerError(c, "failed to get posts") return } // 批量获取交互状态 postIDs := make([]string, len(posts)) for i, post := range posts { postIDs[i] = post.ID } isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID) chMap := h.channelMapForPosts(posts) postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap) 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") tab := c.Query("tab") var channelID *string if cid := c.Query("channel_id"); cid != "" { channelID = &cid } // 解析游标分页请求 req := h.parseCursorRequest(c) // 判断是否包含待审核帖子 includePending := userID != "" && userID == currentUserID var result *cursor.CursorPageResult[*model.Post] var err error // 根据 tab 参数选择不同的获取方式 switch tab { case "follow": // 获取关注用户的帖子,需要登录 if currentUserID == "" { response.Unauthorized(c, "请先登录") return } result, err = h.postService.GetFollowingPostsByCursor(c.Request.Context(), currentUserID, req) case "hot": // 获取热门帖子 result, err = h.postService.GetHotPostsByCursor(c.Request.Context(), req) case "latest": // 最新帖子 result, err = h.postService.ListByCursor(c.Request.Context(), userID, includePending, channelID, req) default: // 默认获取最新帖子 result, err = h.postService.ListByCursor(c.Request.Context(), userID, includePending, channelID, 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) chMap := h.channelMapForPosts(result.Items) postResponses := dto.BuildPostsWithInteractionResponse(result.Items, chMap, 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") if userID == "" { response.Unauthorized(c, "") return } id := c.Param("id") post, err := h.postService.GetByID(c.Request.Context(), id) if err != nil { response.NotFound(c, "post not found") return } if post.UserID != userID { response.Forbidden(c, "cannot update others' post") return } type UpdateRequest struct { Title string `json:"title"` Content string `json:"content"` Segments model.MessageSegments `json:"segments"` Images *[]string `json:"images"` } var req UpdateRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } if req.Title != "" { post.Title = req.Title } if req.Content != "" { post.Content = req.Content } if len(req.Segments) > 0 { post.Segments = req.Segments if req.Content == "" { post.Content = dto.ExtractTextContent(req.Segments) } } err = h.postService.UpdateWithImages(c.Request.Context(), post, req.Images) if err != nil { response.InternalServerError(c, "failed to update post") return } post, err = h.postService.GetByID(c.Request.Context(), post.ID) if err != nil { response.InternalServerError(c, "failed to get updated post") return } // 获取交互状态 currentUserID := c.GetString("user_id") isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), post.ID, currentUserID) chMap := h.channelMapForPosts([]*model.Post{post}) response.Success(c, dto.BuildPostResponse(post, chMap, isLiked, isFavorited)) } // Delete 删除帖子 func (h *PostHandler) Delete(c *gin.Context) { userID := c.GetString("user_id") if userID == "" { response.Unauthorized(c, "") return } id := c.Param("id") post, err := h.postService.GetByID(c.Request.Context(), id) if err != nil { response.NotFound(c, "post not found") return } if post.UserID != userID { response.Forbidden(c, "cannot delete others' post") return } err = h.postService.Delete(c.Request.Context(), id) if err != nil { response.InternalServerError(c, "failed to delete post") return } response.SuccessWithMessage(c, "post deleted", nil) } // Like 点赞帖子 func (h *PostHandler) Like(c *gin.Context) { userID := c.GetString("user_id") if userID == "" { response.Unauthorized(c, "") return } id := c.Param("id") err := h.postService.Like(c.Request.Context(), id, userID) if err != nil { response.InternalServerError(c, "failed to like post") return } // 获取更新后的帖子状态 post, err := h.postService.GetByID(c.Request.Context(), id) if err != nil { response.InternalServerError(c, "failed to get post") return } // 获取交互状态 isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, userID) chMap := h.channelMapForPosts([]*model.Post{post}) response.Success(c, dto.BuildPostResponse(post, chMap, isLiked, isFavorited)) } // Unlike 取消点赞 func (h *PostHandler) Unlike(c *gin.Context) { userID := c.GetString("user_id") if userID == "" { response.Unauthorized(c, "") return } id := c.Param("id") err := h.postService.Unlike(c.Request.Context(), id, userID) if err != nil { response.InternalServerError(c, "failed to unlike post") return } // 获取更新后的帖子状态 post, err := h.postService.GetByID(c.Request.Context(), id) if err != nil { response.InternalServerError(c, "failed to get post") return } // 获取交互状态 isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, userID) chMap := h.channelMapForPosts([]*model.Post{post}) response.Success(c, dto.BuildPostResponse(post, chMap, isLiked, isFavorited)) } // Favorite 收藏帖子 func (h *PostHandler) Favorite(c *gin.Context) { userID := c.GetString("user_id") if userID == "" { response.Unauthorized(c, "") return } id := c.Param("id") err := h.postService.Favorite(c.Request.Context(), id, userID) if err != nil { response.InternalServerError(c, "failed to favorite post") return } // 获取更新后的帖子状态 post, err := h.postService.GetByID(c.Request.Context(), id) if err != nil { response.InternalServerError(c, "failed to get post") return } // 获取交互状态 isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, userID) chMap := h.channelMapForPosts([]*model.Post{post}) response.Success(c, dto.BuildPostResponse(post, chMap, isLiked, isFavorited)) } // Unfavorite 取消收藏 func (h *PostHandler) Unfavorite(c *gin.Context) { userID := c.GetString("user_id") if userID == "" { response.Unauthorized(c, "") return } id := c.Param("id") err := h.postService.Unfavorite(c.Request.Context(), id, userID) if err != nil { response.InternalServerError(c, "failed to unfavorite post") return } // 获取更新后的帖子状态 post, err := h.postService.GetByID(c.Request.Context(), id) if err != nil { response.InternalServerError(c, "failed to get post") return } // 获取交互状态 isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, userID) chMap := h.channelMapForPosts([]*model.Post{post}) response.Success(c, dto.BuildPostResponse(post, chMap, isLiked, isFavorited)) } // GetUserPosts 获取用户帖子列表(支持游标分页和偏移分页) func (h *PostHandler) GetUserPosts(c *gin.Context) { userID := c.Param("id") currentUserID := c.GetString("user_id") canView, err := h.userService.CanViewPosts(c.Request.Context(), currentUserID, userID) if err != nil { response.InternalServerError(c, "failed to check privacy settings") return } if !canView { response.Forbidden(c, "该用户的帖子列表不对外公开") return } // 检查是否使用游标分页 cursorStr := c.Query("cursor") if cursorStr != "" { h.GetUserPostsByCursor(c) return } // 偏移分页(向后兼容) page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) page, pageSize = normalizePagination(page, pageSize) includePending := currentUserID != "" && currentUserID == userID posts, total, err := h.postService.GetUserPosts(c.Request.Context(), userID, page, pageSize, includePending) if err != nil { response.InternalServerError(c, "failed to get user posts") return } // 批量获取交互状态 postIDs := make([]string, len(posts)) for i, post := range posts { postIDs[i] = post.ID } isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID) chMap := h.channelMapForPosts(posts) postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap) 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) chMap := h.channelMapForPosts(result.Items) postResponses := dto.BuildPostsWithInteractionResponse(result.Items, chMap, 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") currentUserID := c.GetString("user_id") canView, err := h.userService.CanViewFavorites(c.Request.Context(), currentUserID, userID) if err != nil { response.InternalServerError(c, "failed to check privacy settings") return } if !canView { response.Forbidden(c, "该用户的收藏列表不对外公开") return } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) page, pageSize = normalizePagination(page, pageSize) posts, total, err := h.postService.GetFavorites(c.Request.Context(), userID, page, pageSize) if err != nil { response.InternalServerError(c, "failed to get favorites") return } postIDs := make([]string, len(posts)) for i, post := range posts { postIDs[i] = post.ID } isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID) chMap := h.channelMapForPosts(posts) postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap) response.Paginated(c, postResponses, total, page, pageSize) } // 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) posts, total, err := h.postService.Search(c.Request.Context(), keyword, page, pageSize) if err != nil { response.InternalServerError(c, "failed to search posts") return } // 批量获取交互状态 currentUserID := c.GetString("user_id") postIDs := make([]string, len(posts)) for i, post := range posts { postIDs[i] = post.ID } isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID) chMap := h.channelMapForPosts(posts) postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap) 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) chMap := h.channelMapForPosts(result.Items) postResponses := dto.BuildPostsWithInteractionResponse(result.Items, chMap, 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) }