diff --git a/internal/dto/dto.go b/internal/dto/dto.go index cfdd6c1..6d5c219 100644 --- a/internal/dto/dto.go +++ b/internal/dto/dto.go @@ -1,8 +1,8 @@ -package dto +package dto import ( - "with_you/internal/model" "time" + "with_you/internal/model" ) // ==================== User DTOs ==================== @@ -143,54 +143,56 @@ type PostImageResponse struct { // PostResponse 帖子响应(列表用) type PostResponse struct { - ID string `json:"id"` - UserID string `json:"user_id"` - ChannelID *string `json:"channel_id,omitempty"` - Title string `json:"title"` - Content string `json:"content"` - Images []PostImageResponse `json:"images"` - Status string `json:"status,omitempty"` - LikesCount int `json:"likes_count"` - CommentsCount int `json:"comments_count"` - FavoritesCount int `json:"favorites_count"` - SharesCount int `json:"shares_count"` - ViewsCount int `json:"views_count"` - IsPinned bool `json:"is_pinned"` - IsLocked bool `json:"is_locked"` - IsVote bool `json:"is_vote"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - ContentEditedAt string `json:"content_edited_at,omitempty"` - Author *UserResponse `json:"author"` - IsLiked bool `json:"is_liked"` - IsFavorited bool `json:"is_favorited"` - Channel *PostChannelBrief `json:"channel,omitempty"` + ID string `json:"id"` + UserID string `json:"user_id"` + ChannelID *string `json:"channel_id,omitempty"` + Title string `json:"title"` + Content string `json:"content"` + Segments model.MessageSegments `json:"segments,omitempty"` + Images []PostImageResponse `json:"images"` + Status string `json:"status,omitempty"` + LikesCount int `json:"likes_count"` + CommentsCount int `json:"comments_count"` + FavoritesCount int `json:"favorites_count"` + SharesCount int `json:"shares_count"` + ViewsCount int `json:"views_count"` + IsPinned bool `json:"is_pinned"` + IsLocked bool `json:"is_locked"` + IsVote bool `json:"is_vote"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + ContentEditedAt string `json:"content_edited_at,omitempty"` + Author *UserResponse `json:"author"` + IsLiked bool `json:"is_liked"` + IsFavorited bool `json:"is_favorited"` + Channel *PostChannelBrief `json:"channel,omitempty"` } // PostDetailResponse 帖子详情响应 type PostDetailResponse struct { - ID string `json:"id"` - UserID string `json:"user_id"` - ChannelID *string `json:"channel_id,omitempty"` - Title string `json:"title"` - Content string `json:"content"` - Images []PostImageResponse `json:"images"` - Status string `json:"status"` - LikesCount int `json:"likes_count"` - CommentsCount int `json:"comments_count"` - FavoritesCount int `json:"favorites_count"` - SharesCount int `json:"shares_count"` - ViewsCount int `json:"views_count"` - IsPinned bool `json:"is_pinned"` - IsLocked bool `json:"is_locked"` - IsVote bool `json:"is_vote"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - ContentEditedAt string `json:"content_edited_at,omitempty"` - Author *UserResponse `json:"author"` - IsLiked bool `json:"is_liked"` - IsFavorited bool `json:"is_favorited"` - Channel *PostChannelBrief `json:"channel,omitempty"` + ID string `json:"id"` + UserID string `json:"user_id"` + ChannelID *string `json:"channel_id,omitempty"` + Title string `json:"title"` + Content string `json:"content"` + Segments model.MessageSegments `json:"segments,omitempty"` + Images []PostImageResponse `json:"images"` + Status string `json:"status"` + LikesCount int `json:"likes_count"` + CommentsCount int `json:"comments_count"` + FavoritesCount int `json:"favorites_count"` + SharesCount int `json:"shares_count"` + ViewsCount int `json:"views_count"` + IsPinned bool `json:"is_pinned"` + IsLocked bool `json:"is_locked"` + IsVote bool `json:"is_vote"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + ContentEditedAt string `json:"content_edited_at,omitempty"` + Author *UserResponse `json:"author"` + IsLiked bool `json:"is_liked"` + IsFavorited bool `json:"is_favorited"` + Channel *PostChannelBrief `json:"channel,omitempty"` } // ==================== Comment DTOs ==================== @@ -209,6 +211,7 @@ type CommentResponse struct { ParentID *string `json:"parent_id"` RootID *string `json:"root_id"` Content string `json:"content"` + Segments model.MessageSegments `json:"segments,omitempty"` Images []CommentImageResponse `json:"images"` LikesCount int `json:"likes_count"` RepliesCount int `json:"replies_count"` @@ -248,6 +251,7 @@ const ( SegmentTypeReply SegmentType = "reply" SegmentTypeFace SegmentType = "face" SegmentTypeLink SegmentType = "link" + SegmentTypeVote SegmentType = "vote" ) // TextSegmentData 文本数据 @@ -315,6 +319,19 @@ type LinkSegmentData struct { Image string `json:"image,omitempty"` } +// VoteOptionData 投票选项数据 +type VoteOptionData struct { + ID string `json:"id"` + Content string `json:"content"` +} + +// VoteSegmentData 投票Segment数据 +type VoteSegmentData struct { + Options []VoteOptionData `json:"options"` + MaxChoices int `json:"max_choices,omitempty"` + IsMultiChoice bool `json:"is_multi_choice,omitempty"` +} + // ==================== Message DTOs ==================== // MessageResponse 消息响应 @@ -887,7 +904,16 @@ type CreateVotePostRequest struct { Content string `json:"content" binding:"max=2000"` ChannelID *string `json:"channel_id,omitempty"` Images []string `json:"images"` - VoteOptions []string `json:"vote_options" binding:"required,min=2,max=10"` // 投票选项,至少2个最多10个 + VoteOptions []string `json:"vote_options" binding:"required,min=2,max=10"` +} + +// CreatePostWithSegmentsRequest 创建帖子请求(含 segments) +type CreatePostWithSegmentsRequest struct { + Title string `json:"title" binding:"required,max=200"` + Content string `json:"content"` + Segments model.MessageSegments `json:"segments"` + Images []string `json:"images"` + ChannelID *string `json:"channel_id,omitempty"` } // VoteOptionDTO 投票选项DTO @@ -943,29 +969,30 @@ type AdminPostListResponse struct { // AdminPostDetailResponse 管理端帖子详情响应 type AdminPostDetailResponse struct { - ID string `json:"id"` - UserID string `json:"user_id"` - ChannelID *string `json:"channel_id,omitempty"` - Title string `json:"title"` - Content string `json:"content"` - Images []PostImageResponse `json:"images"` - Status string `json:"status"` - LikesCount int `json:"likes_count,omitzero"` - CommentsCount int `json:"comments_count,omitzero"` - FavoritesCount int `json:"favorites_count,omitzero"` - SharesCount int `json:"shares_count,omitzero"` - ViewsCount int `json:"views_count,omitzero"` - IsPinned bool `json:"is_pinned"` - IsFeatured bool `json:"is_featured"` - IsLocked bool `json:"is_locked"` - IsVote bool `json:"is_vote"` - RejectReason string `json:"reject_reason,omitempty"` - ReviewedAt string `json:"reviewed_at,omitempty"` - ReviewedBy string `json:"reviewed_by,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - ContentEditedAt string `json:"content_edited_at,omitempty"` - Author *UserResponse `json:"author"` + ID string `json:"id"` + UserID string `json:"user_id"` + ChannelID *string `json:"channel_id,omitempty"` + Title string `json:"title"` + Content string `json:"content"` + Segments model.MessageSegments `json:"segments,omitempty"` + Images []PostImageResponse `json:"images"` + Status string `json:"status"` + LikesCount int `json:"likes_count,omitzero"` + CommentsCount int `json:"comments_count,omitzero"` + FavoritesCount int `json:"favorites_count,omitzero"` + SharesCount int `json:"shares_count,omitzero"` + ViewsCount int `json:"views_count,omitzero"` + IsPinned bool `json:"is_pinned"` + IsFeatured bool `json:"is_featured"` + IsLocked bool `json:"is_locked"` + IsVote bool `json:"is_vote"` + RejectReason string `json:"reject_reason,omitempty"` + ReviewedAt string `json:"reviewed_at,omitempty"` + ReviewedBy string `json:"reviewed_by,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + ContentEditedAt string `json:"content_edited_at,omitempty"` + Author *UserResponse `json:"author"` } // AdminModeratePostRequest 审核帖子请求 diff --git a/internal/dto/post_converter.go b/internal/dto/post_converter.go index 7513543..4391c0d 100644 --- a/internal/dto/post_converter.go +++ b/internal/dto/post_converter.go @@ -1,9 +1,9 @@ -package dto +package dto import ( - "with_you/internal/model" "context" "encoding/json" + "with_you/internal/model" ) // ==================== Post 转换 ==================== @@ -67,6 +67,20 @@ func PostChannelBriefForPost(post *model.Post, channelByID map[string]*model.Cha return postChannelBrief(post, channelByID) } +// SegmentsOrDefault 如果 segments 为空但 content 非空, +// 返回降级的单 text segment;否则原样返回。 +func SegmentsOrDefault(segments model.MessageSegments, content string) model.MessageSegments { + if len(segments) > 0 { + return segments + } + if content != "" { + return model.MessageSegments{ + {Type: string(SegmentTypeText), Data: map[string]any{"text": content}}, + } + } + return nil +} + // ConvertPostToResponse 将Post转换为PostResponse(列表用);channelByID 可为 nil func ConvertPostToResponse(post *model.Post, channelByID map[string]*model.Channel, isLiked, isFavorited bool) *PostResponse { if post == nil { @@ -89,6 +103,7 @@ func ConvertPostToResponse(post *model.Post, channelByID map[string]*model.Chann ChannelID: post.ChannelID, Title: post.Title, Content: post.Content, + Segments: SegmentsOrDefault(post.Segments, post.Content), Images: images, Status: string(post.Status), LikesCount: post.LikesCount, @@ -131,6 +146,7 @@ func ConvertPostToDetailResponse(post *model.Post, channelByID map[string]*model ChannelID: post.ChannelID, Title: post.Title, Content: post.Content, + Segments: SegmentsOrDefault(post.Segments, post.Content), Images: images, Status: string(post.Status), LikesCount: post.LikesCount, @@ -227,6 +243,7 @@ func ConvertCommentToResponse(comment *model.Comment, isLiked bool) *CommentResp ParentID: comment.ParentID, RootID: comment.RootID, Content: comment.Content, + Segments: SegmentsOrDefault(comment.Segments, comment.Content), Images: images, LikesCount: comment.LikesCount, RepliesCount: comment.RepliesCount, @@ -303,6 +320,7 @@ func ConvertCommentToResponseWithUser(comment *model.Comment, userID string, che ParentID: comment.ParentID, RootID: comment.RootID, Content: comment.Content, + Segments: SegmentsOrDefault(comment.Segments, comment.Content), Images: images, LikesCount: comment.LikesCount, RepliesCount: comment.RepliesCount, diff --git a/internal/dto/segment.go b/internal/dto/segment.go index 80fc543..4a93194 100644 --- a/internal/dto/segment.go +++ b/internal/dto/segment.go @@ -1,4 +1,4 @@ -package dto +package dto import ( "encoding/json" @@ -145,6 +145,29 @@ func NewLinkSegment(url, title, description, image string) model.MessageSegment } } +// ExtractVoteSegmentData 从消息链中提取投票Segment数据 +func ExtractVoteSegmentData(segments model.MessageSegments) *VoteSegmentData { + for _, segment := range segments { + if segment.Type == string(SegmentTypeVote) { + dataBytes, err := json.Marshal(segment.Data) + if err != nil { + continue + } + var voteData VoteSegmentData + if err := json.Unmarshal(dataBytes, &voteData); err != nil { + continue + } + return &voteData + } + } + return nil +} + +// HasVoteSegment 检查消息链中是否包含投票Segment +func HasVoteSegment(segments model.MessageSegments) bool { + return HasSegmentType(segments, SegmentTypeVote) +} + // ExtractTextContentFromJSON 从JSON格式的segments中提取纯文本内容 // 用于从数据库读取的 []byte 格式的 segments // 已废弃:现在数据库直接存储 model.MessageSegments 类型 @@ -202,6 +225,8 @@ func ExtractTextContentFromModel(segments model.MessageSegments) string { } else { result += "[链接]" } + case "vote": + result += "[投票]" } } return result diff --git a/internal/handler/comment_handler.go b/internal/handler/comment_handler.go index 39aa40e..7718e30 100644 --- a/internal/handler/comment_handler.go +++ b/internal/handler/comment_handler.go @@ -1,4 +1,4 @@ -package handler +package handler import ( "encoding/json" @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" "with_you/internal/dto" + "with_you/internal/model" "with_you/internal/pkg/cursor" "with_you/internal/pkg/response" "with_you/internal/service" @@ -33,10 +34,11 @@ func (h *CommentHandler) Create(c *gin.Context) { } type CreateRequest struct { - PostID string `json:"post_id" binding:"required"` - Content string `json:"content"` // 内容可选,允许只发图片 - ParentID *string `json:"parent_id"` - Images []string `json:"images"` // 图片URL列表 + PostID string `json:"post_id" binding:"required"` + Content string `json:"content"` + Segments model.MessageSegments `json:"segments"` + ParentID *string `json:"parent_id"` + Images []string `json:"images"` } var req CreateRequest @@ -45,12 +47,18 @@ func (h *CommentHandler) Create(c *gin.Context) { return } - // 验证:评论必须有内容或图片 - if req.Content == "" && len(req.Images) == 0 { + // 验证:评论必须有内容或图片或segments + if req.Content == "" && len(req.Images) == 0 && len(req.Segments) == 0 { response.BadRequest(c, "评论内容或图片不能同时为空") return } + // 如果没有content但有segments,从segments提取文本 + content := req.Content + if content == "" && len(req.Segments) > 0 { + content = dto.ExtractTextContent(req.Segments) + } + // 将图片列表转换为JSON字符串 var imagesJSON string if len(req.Images) > 0 { @@ -58,7 +66,7 @@ func (h *CommentHandler) Create(c *gin.Context) { imagesJSON = string(imagesBytes) } - comment, err := h.commentService.Create(c.Request.Context(), req.PostID, userID, req.Content, req.ParentID, imagesJSON, req.Images) + comment, err := h.commentService.Create(c.Request.Context(), req.PostID, userID, content, req.Segments, req.ParentID, imagesJSON, req.Images) if err != nil { response.InternalServerError(c, "failed to create comment") return diff --git a/internal/handler/post_handler.go b/internal/handler/post_handler.go index 018c92f..8b8d427 100644 --- a/internal/handler/post_handler.go +++ b/internal/handler/post_handler.go @@ -1,4 +1,4 @@ -package handler +package handler import ( "errors" @@ -52,20 +52,21 @@ func (h *PostHandler) Create(c *gin.Context) { 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 + var req dto.CreatePostWithSegmentsRequest 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) + 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) { @@ -123,6 +124,7 @@ func (h *PostHandler) GetByID(c *gin.Context) { 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, @@ -365,9 +367,10 @@ func (h *PostHandler) Update(c *gin.Context) { } type UpdateRequest struct { - Title string `json:"title"` - Content string `json:"content"` - Images *[]string `json:"images"` + Title string `json:"title"` + Content string `json:"content"` + Segments model.MessageSegments `json:"segments"` + Images *[]string `json:"images"` } var req UpdateRequest @@ -382,6 +385,12 @@ func (h *PostHandler) Update(c *gin.Context) { 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 { diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go index b829f09..5a5aac4 100644 --- a/internal/handler/user_handler.go +++ b/internal/handler/user_handler.go @@ -1,4 +1,4 @@ -package handler +package handler import ( "cmp" diff --git a/internal/model/comment.go b/internal/model/comment.go index 38d5083..5083dc3 100644 --- a/internal/model/comment.go +++ b/internal/model/comment.go @@ -20,13 +20,14 @@ const ( // Comment 评论实体 type Comment struct { - ID string `json:"id" gorm:"type:varchar(36);primaryKey"` - PostID string `json:"post_id" gorm:"type:varchar(36);not null;index:idx_comments_post_parent_status_created,priority:1"` - UserID string `json:"user_id" gorm:"type:varchar(36);index;not null"` - ParentID *string `json:"parent_id" gorm:"type:varchar(36);index:idx_comments_post_parent_status_created,priority:2"` // 父评论 ID(支持嵌套) - RootID *string `json:"root_id" gorm:"type:varchar(36);index:idx_comments_root_status_created,priority:1"` // 根评论 ID(用于高效查询) - Content string `json:"content" gorm:"type:text;not null"` - Images string `json:"images" gorm:"type:text"` // 图片URL列表,JSON数组格式 + ID string `json:"id" gorm:"type:varchar(36);primaryKey"` + PostID string `json:"post_id" gorm:"type:varchar(36);not null;index:idx_comments_post_parent_status_created,priority:1"` + UserID string `json:"user_id" gorm:"type:varchar(36);index;not null"` + ParentID *string `json:"parent_id" gorm:"type:varchar(36);index:idx_comments_post_parent_status_created,priority:2"` // 父评论 ID(支持嵌套) + RootID *string `json:"root_id" gorm:"type:varchar(36);index:idx_comments_root_status_created,priority:1"` // 根评论 ID(用于高效查询) + Content string `json:"content" gorm:"type:text;not null"` + Segments MessageSegments `json:"segments,omitempty" gorm:"type:text"` + Images string `json:"images" gorm:"type:text"` // 图片URL列表,JSON数组格式 // 关联 User *User `json:"-" gorm:"foreignKey:UserID"` diff --git a/internal/model/post.go b/internal/model/post.go index d576aba..6f18124 100644 --- a/internal/model/post.go +++ b/internal/model/post.go @@ -20,11 +20,12 @@ const ( // Post 帖子实体 type Post struct { - ID string `json:"id" gorm:"type:varchar(36);primaryKey"` - UserID string `json:"user_id" gorm:"type:varchar(36);index;index:idx_posts_user_status_created,priority:1;not null"` - ChannelID *string `json:"channel_id,omitempty" gorm:"type:varchar(36);index"` - Title string `json:"title" gorm:"type:varchar(200);not null"` - Content string `json:"content" gorm:"type:text;not null"` + ID string `json:"id" gorm:"type:varchar(36);primaryKey"` + UserID string `json:"user_id" gorm:"type:varchar(36);index;index:idx_posts_user_status_created,priority:1;not null"` + ChannelID *string `json:"channel_id,omitempty" gorm:"type:varchar(36);index"` + Title string `json:"title" gorm:"type:varchar(200);not null"` + Content string `json:"content" gorm:"type:text;not null"` + Segments MessageSegments `json:"segments,omitempty" gorm:"type:text"` // 关联 // User 需要参与缓存序列化;否则列表命中缓存后会丢失作者信息,前端退化为“匿名用户” diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go index 52f8ce8..cc19694 100644 --- a/internal/repository/user_repo.go +++ b/internal/repository/user_repo.go @@ -1,9 +1,9 @@ -package repository +package repository import ( - "with_you/internal/model" "strings" "time" + "with_you/internal/model" "gorm.io/gorm" "gorm.io/gorm/clause" diff --git a/internal/repository/vote_repo.go b/internal/repository/vote_repo.go index ccee7bc..cbe682e 100644 --- a/internal/repository/vote_repo.go +++ b/internal/repository/vote_repo.go @@ -1,8 +1,8 @@ -package repository +package repository import ( - "with_you/internal/model" "errors" + "with_you/internal/model" "gorm.io/gorm" ) @@ -16,6 +16,7 @@ type VoteRepository interface { GetUserVote(postID, userID string) (*model.UserVote, error) UpdateOption(optionID, content string) error DeleteOptionsByPostID(postID string) error + CountVotesByOption(postID, optionID string) (int, error) } // voteRepository 投票仓储实现 @@ -72,9 +73,9 @@ func (r *voteRepository) Vote(postID, userID, optionID string) error { // 验证选项是否属于该帖子 var option model.VoteOption if err := tx.Where("id = ? AND post_id = ?", optionID, postID).First(&option).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errors.New("invalid option") - } + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("invalid option") + } return err } @@ -100,9 +101,9 @@ func (r *voteRepository) Unvote(postID, userID string) error { var userVote model.UserVote err := tx.Where("post_id = ? AND user_id = ?", postID, userID).First(&userVote).Error if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil // 没有投票记录,直接返回 - } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil // 没有投票记录,直接返回 + } return err } @@ -144,12 +145,16 @@ func (r *voteRepository) UpdateOption(optionID, content string) error { // DeleteOptionsByPostID 删除帖子的所有投票选项 func (r *voteRepository) DeleteOptionsByPostID(postID string) error { return r.db.Transaction(func(tx *gorm.DB) error { - // 删除关联的用户投票记录 if err := tx.Where("post_id = ?", postID).Delete(&model.UserVote{}).Error; err != nil { return err } - - // 删除投票选项 return tx.Where("post_id = ?", postID).Delete(&model.VoteOption{}).Error }) } + +// CountVotesByOption 统计指定选项的投票数 +func (r *voteRepository) CountVotesByOption(postID, optionID string) (int, error) { + var count int64 + err := r.db.Model(&model.UserVote{}).Where("post_id = ? AND option_id = ?", postID, optionID).Count(&count).Error + return int(count), err +} diff --git a/internal/service/comment_service.go b/internal/service/comment_service.go index 062f1ff..f6f7678 100644 --- a/internal/service/comment_service.go +++ b/internal/service/comment_service.go @@ -1,11 +1,13 @@ -package service +package service import ( "context" "fmt" + "log" "strings" "with_you/internal/cache" + "with_you/internal/dto" "with_you/internal/model" "with_you/internal/pkg/cursor" "with_you/internal/pkg/hook" @@ -37,7 +39,7 @@ func NewCommentService(commentRepo repository.CommentRepository, postRepo reposi } // Create 创建评论 -func (s *CommentService) Create(ctx context.Context, postID, userID, content string, parentID *string, images string, imageURLs []string) (*model.Comment, error) { +func (s *CommentService) Create(ctx context.Context, postID, userID, content string, segments model.MessageSegments, parentID *string, images string, imageURLs []string) (*model.Comment, error) { if s.postAIService != nil { // 采用异步审核,前端先立即返回 } @@ -52,6 +54,7 @@ func (s *CommentService) Create(ctx context.Context, postID, userID, content str PostID: postID, UserID: userID, Content: content, + Segments: segments, ParentID: parentID, Images: images, Status: model.CommentStatusPending, @@ -94,11 +97,38 @@ func (s *CommentService) Create(ctx context.Context, postID, userID, content str return nil, err } + // 异步处理 @提及通知 + if len(segments) > 0 { + bgCtx := context.WithoutCancel(ctx) + go s.processCommentMentionNotifications(bgCtx, userID, postID, post.Title, segments) + } + go s.reviewCommentAsync(comment.ID, userID, postID, content, imageURLs, parentID, parentUserID, post.UserID) return comment, nil } +// processCommentMentionNotifications 处理评论中 @提及的通知 +func (s *CommentService) processCommentMentionNotifications(ctx context.Context, authorID, postID, postTitle string, segments model.MessageSegments) { + if s.systemMessageService == nil { + return + } + + mentionedUserIDs := dto.ExtractMentionedUsers(segments) + if len(mentionedUserIDs) == 0 { + return + } + + for _, targetUserID := range mentionedUserIDs { + if targetUserID == authorID { + continue + } + if err := s.systemMessageService.SendMentionNotification(ctx, targetUserID, authorID, postID); err != nil { + log.Printf("[WARN] Failed to send comment mention notification to user %s: %v", targetUserID, err) + } + } +} + func (s *CommentService) reviewCommentAsync( commentID, userID, postID, content string, imageURLs []string, diff --git a/internal/service/post_service.go b/internal/service/post_service.go index 8ceb22f..cdfb789 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -1,4 +1,4 @@ -package service +package service import ( "context" @@ -9,6 +9,7 @@ import ( "time" "with_you/internal/cache" + "with_you/internal/dto" "with_you/internal/model" "with_you/internal/pkg/cursor" "with_you/internal/pkg/hook" @@ -25,7 +26,7 @@ const ( // PostService 帖子服务接口 type PostService interface { // 帖子CRUD - Create(ctx context.Context, userID, title, content string, images []string, channelID *string) (*model.Post, error) + Create(ctx context.Context, userID, title, content string, segments model.MessageSegments, images []string, channelID *string) (*model.Post, error) GetByID(ctx context.Context, id string) (*model.Post, error) Update(ctx context.Context, post *model.Post) error UpdateWithImages(ctx context.Context, post *model.Post, images *[]string) error @@ -98,13 +99,27 @@ type PostListResult struct { } // Create 创建帖子 -func (s *postServiceImpl) Create(ctx context.Context, userID, title, content string, images []string, channelID *string) (*model.Post, error) { +func (s *postServiceImpl) Create(ctx context.Context, userID, title, content string, segments model.MessageSegments, images []string, channelID *string) (*model.Post, error) { + // 如果没有提供 content 但有 segments,从 segments 提取文本摘要 + if content == "" && len(segments) > 0 { + content = dto.ExtractTextContent(segments) + } + // 如果有 segments 但 content 为空,将 content 设为空字符串(NOT NULL 约束) + if content == "" { + content = " " + } + + // 检查 segments 中是否包含投票 + isVote := dto.HasVoteSegment(segments) + post := &model.Post{ UserID: userID, ChannelID: channelID, Title: title, Content: content, + Segments: segments, Status: model.PostStatusPending, + IsVote: isVote, } err := s.postRepo.Create(post, images) @@ -127,6 +142,12 @@ func (s *postServiceImpl) Create(ctx context.Context, userID, title, content str // 失效帖子列表缓存 cache.InvalidatePostList(s.cache) + // 异步处理 @提及通知 + if len(segments) > 0 { + bgCtx := context.WithoutCancel(ctx) + go s.processMentionNotifications(bgCtx, userID, post.ID, post.Title, segments) + } + // 异步执行审核流 go s.reviewPostAsync(post.ID, userID, title, content, images) @@ -246,6 +267,27 @@ func (s *postServiceImpl) notifyModerationRejected(userID, reason string) { }() } +// processMentionNotifications 处理帖子中 @提及的通知 +func (s *postServiceImpl) processMentionNotifications(ctx context.Context, authorID, postID, postTitle string, segments model.MessageSegments) { + if s.systemMessageService == nil { + return + } + + mentionedUserIDs := dto.ExtractMentionedUsers(segments) + if len(mentionedUserIDs) == 0 { + return + } + + for _, targetUserID := range mentionedUserIDs { + if targetUserID == authorID { + continue + } + if err := s.systemMessageService.SendMentionNotification(ctx, targetUserID, authorID, postID); err != nil { + log.Printf("[WARN] Failed to send mention notification to user %s: %v", targetUserID, err) + } + } +} + // GetByID 根据ID获取帖子 func (s *postServiceImpl) GetByID(ctx context.Context, id string) (*model.Post, error) { return s.postRepo.GetByID(id) diff --git a/internal/service/user_service.go b/internal/service/user_service.go index f71a7a8..50aaae7 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -1,4 +1,4 @@ -package service +package service import ( "context" diff --git a/internal/service/vote_service.go b/internal/service/vote_service.go index 71cd532..3d604fb 100644 --- a/internal/service/vote_service.go +++ b/internal/service/vote_service.go @@ -1,4 +1,4 @@ -package service +package service import ( "context" @@ -48,7 +48,6 @@ func NewVoteService( // CreateVotePost 创建投票帖子 func (s *VoteService) CreateVotePost(ctx context.Context, userID string, req *dto.CreateVotePostRequest) (*dto.PostResponse, error) { - // 验证投票选项数量 if len(req.VoteOptions) < 2 { return nil, errors.New("投票选项至少需要2个") } @@ -56,12 +55,37 @@ func (s *VoteService) CreateVotePost(ctx context.Context, userID string, req *dt return nil, errors.New("投票选项最多10个") } - // 创建普通帖子(设置IsVote=true) + // 构建 vote segment + voteOptions := make([]dto.VoteOptionData, len(req.VoteOptions)) + for i, opt := range req.VoteOptions { + voteOptions[i] = dto.VoteOptionData{ + ID: fmt.Sprintf("opt_%d", i+1), + Content: opt, + } + } + segments := model.MessageSegments{ + dto.NewTextSegment(req.Content), + { + Type: string(dto.SegmentTypeVote), + Data: map[string]any{ + "options": voteOptions, + "max_choices": 1, + "is_multi_choice": false, + }, + }, + } + + content := req.Content + if content == "" { + content = " " + } + post := &model.Post{ UserID: userID, ChannelID: req.ChannelID, Title: req.Title, - Content: req.Content, + Content: content, + Segments: segments, Status: model.PostStatusPending, IsVote: true, } @@ -71,14 +95,8 @@ func (s *VoteService) CreateVotePost(ctx context.Context, userID string, req *dt return nil, err } - // 创建投票选项 - err = s.voteRepo.CreateOptions(post.ID, req.VoteOptions) - if err != nil { - return nil, err - } - // 异步审核 - go s.reviewVotePostAsync(post.ID, userID, req.Title, req.Content, req.Images) + go s.reviewVotePostAsync(post.ID, userID, req.Title, content, req.Images) // 重新查询以获取关联的User和Images createdPost, err := s.postRepo.GetByID(post.ID) @@ -86,7 +104,6 @@ func (s *VoteService) CreateVotePost(ctx context.Context, userID string, req *dt return nil, err } - // 转换为响应DTO return s.convertToPostResponse(createdPost, userID), nil } @@ -196,8 +213,27 @@ func (s *VoteService) notifyModerationRejected(userID, reason string) { }() } -// GetVoteOptions 获取投票选项 -func (s *VoteService) GetVoteOptions(postID string) ([]dto.VoteOptionDTO, error) { +// resolveVoteOptions 解析投票选项(优先从 segments 读取,兼容旧数据从 vote_options 表读取) +func (s *VoteService) resolveVoteOptions(postID string, segments model.MessageSegments) ([]dto.VoteOptionDTO, error) { + if len(segments) > 0 { + voteData := dto.ExtractVoteSegmentData(segments) + if voteData != nil { + result := make([]dto.VoteOptionDTO, 0, len(voteData.Options)) + for _, opt := range voteData.Options { + count, err := s.voteRepo.CountVotesByOption(postID, opt.ID) + if err != nil { + log.Printf("[WARN] Failed to count votes for option %s in post %s: %v", opt.ID, postID, err) + } + result = append(result, dto.VoteOptionDTO{ + ID: opt.ID, + Content: opt.Content, + VotesCount: count, + }) + } + return result, nil + } + } + options, err := s.voteRepo.GetOptionsByPostID(postID) if err != nil { return nil, err @@ -215,23 +251,35 @@ func (s *VoteService) GetVoteOptions(postID string) ([]dto.VoteOptionDTO, error) return result, nil } -// GetVoteResult 获取投票结果(包含用户投票状态) -func (s *VoteService) GetVoteResult(postID, userID string) (*dto.VoteResultDTO, error) { - // 获取所有投票选项 - options, err := s.voteRepo.GetOptionsByPostID(postID) +// GetVoteOptions 获取投票选项(优先从 segments 读取,兼容旧数据从 vote_options 表读取) +func (s *VoteService) GetVoteOptions(postID string) ([]dto.VoteOptionDTO, error) { + post, err := s.postRepo.GetByID(postID) + if err != nil { + return nil, err + } + + return s.resolveVoteOptions(postID, post.Segments) +} + +// GetVoteResult 获取投票结果(包含用户投票状态) +func (s *VoteService) GetVoteResult(postID, userID string) (*dto.VoteResultDTO, error) { + post, err := s.postRepo.GetByID(postID) + if err != nil { + return nil, err + } + + optionDTOs, err := s.resolveVoteOptions(postID, post.Segments) if err != nil { return nil, err } - // 获取用户的投票记录 userVote, err := s.voteRepo.GetUserVote(postID, userID) if err != nil { return nil, err } - // 构建结果 result := &dto.VoteResultDTO{ - Options: make([]dto.VoteOptionDTO, 0, len(options)), + Options: optionDTOs, TotalVotes: 0, HasVoted: userVote != nil, } @@ -240,13 +288,8 @@ func (s *VoteService) GetVoteResult(postID, userID string) (*dto.VoteResultDTO, result.VotedOptionID = userVote.OptionID } - for _, option := range options { - result.Options = append(result.Options, dto.VoteOptionDTO{ - ID: option.ID, - Content: option.Content, - VotesCount: option.VotesCount, - }) - result.TotalVotes += option.VotesCount + for _, opt := range optionDTOs { + result.TotalVotes += opt.VotesCount } return result, nil @@ -308,6 +351,7 @@ func (s *VoteService) convertToPostResponse(post *model.Post, currentUserID stri UserID: post.UserID, Title: post.Title, Content: post.Content, + Segments: dto.SegmentsOrDefault(post.Segments, post.Content), LikesCount: post.LikesCount, CommentsCount: post.CommentsCount, FavoritesCount: post.FavoritesCount,