Files
backend/internal/handler/post_handler.go
lafay 7c14cf5bab feat(post): enhance post handling with channel integration
- Updated PostHandler to include channel information in post responses.
- Introduced PostChannelBrief DTO to represent channel details associated with posts.
- Modified post conversion functions to support channel data, ensuring proper mapping of channel IDs to channel names.
- Enhanced ChannelRepository and ChannelService to facilitate batch retrieval of channels by IDs.
- Updated wire generation to inject channel service into post handler for improved functionality.
2026-03-25 00:58:41 +08:00

736 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"errors"
"fmt"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"carrot_bbs/internal/dto"
"carrot_bbs/internal/model"
"carrot_bbs/internal/pkg/cursor"
"carrot_bbs/internal/pkg/response"
"carrot_bbs/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
}
type CreateRequest struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
Images []string `json:"images"`
ChannelID *string `json:"channel_id,omitempty"`
}
var req CreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
post, err := h.postService.Create(c.Request.Context(), userID, req.Title, req.Content, 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 currentUserID != "" && post.User != nil {
_, isFollowing, isFollowingMe, err := h.userService.GetUserByIDWithMutualFollowStatus(c.Request.Context(), post.UserID, currentUserID)
if err == nil {
authorWithFollowStatus = dto.ConvertUserToResponseWithMutualFollow(post.User, isFollowing, isFollowingMe)
} else {
// 如果出错使用默认的author
authorWithFollowStatus = dto.ConvertUserToResponse(post.User)
}
}
// 构建响应
responseData := &dto.PostResponse{
ID: post.ID,
UserID: post.UserID,
ChannelID: post.ChannelID,
Title: post.Title,
Content: 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"`
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
}
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) {
// 检查是否使用游标分页
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"))
page, pageSize = normalizePagination(page, pageSize)
currentUserID := c.GetString("user_id")
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")
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
}
// 批量获取交互状态
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)
}
// 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)
}