feat(api): add message segments support for posts and comments
All checks were successful
Build Backend / build (push) Successful in 19m52s
Build Backend / build-docker (push) Successful in 1m29s

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:
lafay
2026-04-23 22:29:34 +08:00
parent 15e7c99353
commit 05c7f8a5eb
14 changed files with 364 additions and 154 deletions

View File

@@ -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 审核帖子请求

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -1,4 +1,4 @@
package handler
package handler
import (
"cmp"

View File

@@ -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"`

View File

@@ -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 需要参与缓存序列化;否则列表命中缓存后会丢失作者信息,前端退化为“匿名用户”

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -1,4 +1,4 @@
package service
package service
import (
"context"

View File

@@ -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,