feat(api): add message segments support for posts and comments
Add rich message segment support enabling structured content (text, images, votes, mentions) in posts and comments. Includes segments field in models, DTOs, handlers, and services. Also adds @mention notification processing and migrates vote data to embedded segments.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package service
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user