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 ( import (
"with_you/internal/model"
"time" "time"
"with_you/internal/model"
) )
// ==================== User DTOs ==================== // ==================== User DTOs ====================
@@ -143,54 +143,56 @@ type PostImageResponse struct {
// PostResponse 帖子响应(列表用) // PostResponse 帖子响应(列表用)
type PostResponse struct { type PostResponse struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
ChannelID *string `json:"channel_id,omitempty"` ChannelID *string `json:"channel_id,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Content string `json:"content"` Content string `json:"content"`
Images []PostImageResponse `json:"images"` Segments model.MessageSegments `json:"segments,omitempty"`
Status string `json:"status,omitempty"` Images []PostImageResponse `json:"images"`
LikesCount int `json:"likes_count"` Status string `json:"status,omitempty"`
CommentsCount int `json:"comments_count"` LikesCount int `json:"likes_count"`
FavoritesCount int `json:"favorites_count"` CommentsCount int `json:"comments_count"`
SharesCount int `json:"shares_count"` FavoritesCount int `json:"favorites_count"`
ViewsCount int `json:"views_count"` SharesCount int `json:"shares_count"`
IsPinned bool `json:"is_pinned"` ViewsCount int `json:"views_count"`
IsLocked bool `json:"is_locked"` IsPinned bool `json:"is_pinned"`
IsVote bool `json:"is_vote"` IsLocked bool `json:"is_locked"`
CreatedAt string `json:"created_at"` IsVote bool `json:"is_vote"`
UpdatedAt string `json:"updated_at"` CreatedAt string `json:"created_at"`
ContentEditedAt string `json:"content_edited_at,omitempty"` UpdatedAt string `json:"updated_at"`
Author *UserResponse `json:"author"` ContentEditedAt string `json:"content_edited_at,omitempty"`
IsLiked bool `json:"is_liked"` Author *UserResponse `json:"author"`
IsFavorited bool `json:"is_favorited"` IsLiked bool `json:"is_liked"`
Channel *PostChannelBrief `json:"channel,omitempty"` IsFavorited bool `json:"is_favorited"`
Channel *PostChannelBrief `json:"channel,omitempty"`
} }
// PostDetailResponse 帖子详情响应 // PostDetailResponse 帖子详情响应
type PostDetailResponse struct { type PostDetailResponse struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
ChannelID *string `json:"channel_id,omitempty"` ChannelID *string `json:"channel_id,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Content string `json:"content"` Content string `json:"content"`
Images []PostImageResponse `json:"images"` Segments model.MessageSegments `json:"segments,omitempty"`
Status string `json:"status"` Images []PostImageResponse `json:"images"`
LikesCount int `json:"likes_count"` Status string `json:"status"`
CommentsCount int `json:"comments_count"` LikesCount int `json:"likes_count"`
FavoritesCount int `json:"favorites_count"` CommentsCount int `json:"comments_count"`
SharesCount int `json:"shares_count"` FavoritesCount int `json:"favorites_count"`
ViewsCount int `json:"views_count"` SharesCount int `json:"shares_count"`
IsPinned bool `json:"is_pinned"` ViewsCount int `json:"views_count"`
IsLocked bool `json:"is_locked"` IsPinned bool `json:"is_pinned"`
IsVote bool `json:"is_vote"` IsLocked bool `json:"is_locked"`
CreatedAt string `json:"created_at"` IsVote bool `json:"is_vote"`
UpdatedAt string `json:"updated_at"` CreatedAt string `json:"created_at"`
ContentEditedAt string `json:"content_edited_at,omitempty"` UpdatedAt string `json:"updated_at"`
Author *UserResponse `json:"author"` ContentEditedAt string `json:"content_edited_at,omitempty"`
IsLiked bool `json:"is_liked"` Author *UserResponse `json:"author"`
IsFavorited bool `json:"is_favorited"` IsLiked bool `json:"is_liked"`
Channel *PostChannelBrief `json:"channel,omitempty"` IsFavorited bool `json:"is_favorited"`
Channel *PostChannelBrief `json:"channel,omitempty"`
} }
// ==================== Comment DTOs ==================== // ==================== Comment DTOs ====================
@@ -209,6 +211,7 @@ type CommentResponse struct {
ParentID *string `json:"parent_id"` ParentID *string `json:"parent_id"`
RootID *string `json:"root_id"` RootID *string `json:"root_id"`
Content string `json:"content"` Content string `json:"content"`
Segments model.MessageSegments `json:"segments,omitempty"`
Images []CommentImageResponse `json:"images"` Images []CommentImageResponse `json:"images"`
LikesCount int `json:"likes_count"` LikesCount int `json:"likes_count"`
RepliesCount int `json:"replies_count"` RepliesCount int `json:"replies_count"`
@@ -248,6 +251,7 @@ const (
SegmentTypeReply SegmentType = "reply" SegmentTypeReply SegmentType = "reply"
SegmentTypeFace SegmentType = "face" SegmentTypeFace SegmentType = "face"
SegmentTypeLink SegmentType = "link" SegmentTypeLink SegmentType = "link"
SegmentTypeVote SegmentType = "vote"
) )
// TextSegmentData 文本数据 // TextSegmentData 文本数据
@@ -315,6 +319,19 @@ type LinkSegmentData struct {
Image string `json:"image,omitempty"` 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 ==================== // ==================== Message DTOs ====================
// MessageResponse 消息响应 // MessageResponse 消息响应
@@ -887,7 +904,16 @@ type CreateVotePostRequest struct {
Content string `json:"content" binding:"max=2000"` Content string `json:"content" binding:"max=2000"`
ChannelID *string `json:"channel_id,omitempty"` ChannelID *string `json:"channel_id,omitempty"`
Images []string `json:"images"` 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 // VoteOptionDTO 投票选项DTO
@@ -943,29 +969,30 @@ type AdminPostListResponse struct {
// AdminPostDetailResponse 管理端帖子详情响应 // AdminPostDetailResponse 管理端帖子详情响应
type AdminPostDetailResponse struct { type AdminPostDetailResponse struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
ChannelID *string `json:"channel_id,omitempty"` ChannelID *string `json:"channel_id,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Content string `json:"content"` Content string `json:"content"`
Images []PostImageResponse `json:"images"` Segments model.MessageSegments `json:"segments,omitempty"`
Status string `json:"status"` Images []PostImageResponse `json:"images"`
LikesCount int `json:"likes_count,omitzero"` Status string `json:"status"`
CommentsCount int `json:"comments_count,omitzero"` LikesCount int `json:"likes_count,omitzero"`
FavoritesCount int `json:"favorites_count,omitzero"` CommentsCount int `json:"comments_count,omitzero"`
SharesCount int `json:"shares_count,omitzero"` FavoritesCount int `json:"favorites_count,omitzero"`
ViewsCount int `json:"views_count,omitzero"` SharesCount int `json:"shares_count,omitzero"`
IsPinned bool `json:"is_pinned"` ViewsCount int `json:"views_count,omitzero"`
IsFeatured bool `json:"is_featured"` IsPinned bool `json:"is_pinned"`
IsLocked bool `json:"is_locked"` IsFeatured bool `json:"is_featured"`
IsVote bool `json:"is_vote"` IsLocked bool `json:"is_locked"`
RejectReason string `json:"reject_reason,omitempty"` IsVote bool `json:"is_vote"`
ReviewedAt string `json:"reviewed_at,omitempty"` RejectReason string `json:"reject_reason,omitempty"`
ReviewedBy string `json:"reviewed_by,omitempty"` ReviewedAt string `json:"reviewed_at,omitempty"`
CreatedAt string `json:"created_at"` ReviewedBy string `json:"reviewed_by,omitempty"`
UpdatedAt string `json:"updated_at"` CreatedAt string `json:"created_at"`
ContentEditedAt string `json:"content_edited_at,omitempty"` UpdatedAt string `json:"updated_at"`
Author *UserResponse `json:"author"` ContentEditedAt string `json:"content_edited_at,omitempty"`
Author *UserResponse `json:"author"`
} }
// AdminModeratePostRequest 审核帖子请求 // AdminModeratePostRequest 审核帖子请求

View File

@@ -1,9 +1,9 @@
package dto package dto
import ( import (
"with_you/internal/model"
"context" "context"
"encoding/json" "encoding/json"
"with_you/internal/model"
) )
// ==================== Post 转换 ==================== // ==================== Post 转换 ====================
@@ -67,6 +67,20 @@ func PostChannelBriefForPost(post *model.Post, channelByID map[string]*model.Cha
return postChannelBrief(post, channelByID) 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 // ConvertPostToResponse 将Post转换为PostResponse列表用channelByID 可为 nil
func ConvertPostToResponse(post *model.Post, channelByID map[string]*model.Channel, isLiked, isFavorited bool) *PostResponse { func ConvertPostToResponse(post *model.Post, channelByID map[string]*model.Channel, isLiked, isFavorited bool) *PostResponse {
if post == nil { if post == nil {
@@ -89,6 +103,7 @@ func ConvertPostToResponse(post *model.Post, channelByID map[string]*model.Chann
ChannelID: post.ChannelID, ChannelID: post.ChannelID,
Title: post.Title, Title: post.Title,
Content: post.Content, Content: post.Content,
Segments: SegmentsOrDefault(post.Segments, post.Content),
Images: images, Images: images,
Status: string(post.Status), Status: string(post.Status),
LikesCount: post.LikesCount, LikesCount: post.LikesCount,
@@ -131,6 +146,7 @@ func ConvertPostToDetailResponse(post *model.Post, channelByID map[string]*model
ChannelID: post.ChannelID, ChannelID: post.ChannelID,
Title: post.Title, Title: post.Title,
Content: post.Content, Content: post.Content,
Segments: SegmentsOrDefault(post.Segments, post.Content),
Images: images, Images: images,
Status: string(post.Status), Status: string(post.Status),
LikesCount: post.LikesCount, LikesCount: post.LikesCount,
@@ -227,6 +243,7 @@ func ConvertCommentToResponse(comment *model.Comment, isLiked bool) *CommentResp
ParentID: comment.ParentID, ParentID: comment.ParentID,
RootID: comment.RootID, RootID: comment.RootID,
Content: comment.Content, Content: comment.Content,
Segments: SegmentsOrDefault(comment.Segments, comment.Content),
Images: images, Images: images,
LikesCount: comment.LikesCount, LikesCount: comment.LikesCount,
RepliesCount: comment.RepliesCount, RepliesCount: comment.RepliesCount,
@@ -303,6 +320,7 @@ func ConvertCommentToResponseWithUser(comment *model.Comment, userID string, che
ParentID: comment.ParentID, ParentID: comment.ParentID,
RootID: comment.RootID, RootID: comment.RootID,
Content: comment.Content, Content: comment.Content,
Segments: SegmentsOrDefault(comment.Segments, comment.Content),
Images: images, Images: images,
LikesCount: comment.LikesCount, LikesCount: comment.LikesCount,
RepliesCount: comment.RepliesCount, RepliesCount: comment.RepliesCount,

View File

@@ -1,4 +1,4 @@
package dto package dto
import ( import (
"encoding/json" "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中提取纯文本内容 // ExtractTextContentFromJSON 从JSON格式的segments中提取纯文本内容
// 用于从数据库读取的 []byte 格式的 segments // 用于从数据库读取的 []byte 格式的 segments
// 已废弃:现在数据库直接存储 model.MessageSegments 类型 // 已废弃:现在数据库直接存储 model.MessageSegments 类型
@@ -202,6 +225,8 @@ func ExtractTextContentFromModel(segments model.MessageSegments) string {
} else { } else {
result += "[链接]" result += "[链接]"
} }
case "vote":
result += "[投票]"
} }
} }
return result return result

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"encoding/json" "encoding/json"
@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"with_you/internal/dto" "with_you/internal/dto"
"with_you/internal/model"
"with_you/internal/pkg/cursor" "with_you/internal/pkg/cursor"
"with_you/internal/pkg/response" "with_you/internal/pkg/response"
"with_you/internal/service" "with_you/internal/service"
@@ -33,10 +34,11 @@ func (h *CommentHandler) Create(c *gin.Context) {
} }
type CreateRequest struct { type CreateRequest struct {
PostID string `json:"post_id" binding:"required"` PostID string `json:"post_id" binding:"required"`
Content string `json:"content"` // 内容可选,允许只发图片 Content string `json:"content"`
ParentID *string `json:"parent_id"` Segments model.MessageSegments `json:"segments"`
Images []string `json:"images"` // 图片URL列表 ParentID *string `json:"parent_id"`
Images []string `json:"images"`
} }
var req CreateRequest var req CreateRequest
@@ -45,12 +47,18 @@ func (h *CommentHandler) Create(c *gin.Context) {
return return
} }
// 验证:评论必须有内容或图片 // 验证:评论必须有内容或图片或segments
if req.Content == "" && len(req.Images) == 0 { if req.Content == "" && len(req.Images) == 0 && len(req.Segments) == 0 {
response.BadRequest(c, "评论内容或图片不能同时为空") response.BadRequest(c, "评论内容或图片不能同时为空")
return return
} }
// 如果没有content但有segments从segments提取文本
content := req.Content
if content == "" && len(req.Segments) > 0 {
content = dto.ExtractTextContent(req.Segments)
}
// 将图片列表转换为JSON字符串 // 将图片列表转换为JSON字符串
var imagesJSON string var imagesJSON string
if len(req.Images) > 0 { if len(req.Images) > 0 {
@@ -58,7 +66,7 @@ func (h *CommentHandler) Create(c *gin.Context) {
imagesJSON = string(imagesBytes) 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 { if err != nil {
response.InternalServerError(c, "failed to create comment") response.InternalServerError(c, "failed to create comment")
return return

View File

@@ -1,4 +1,4 @@
package handler package handler
import ( import (
"errors" "errors"
@@ -52,20 +52,21 @@ func (h *PostHandler) Create(c *gin.Context) {
return return
} }
type CreateRequest struct { var req dto.CreatePostWithSegmentsRequest
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 { if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return 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 { if err != nil {
var moderationErr *service.PostModerationRejectedError var moderationErr *service.PostModerationRejectedError
if errors.As(err, &moderationErr) { if errors.As(err, &moderationErr) {
@@ -123,6 +124,7 @@ func (h *PostHandler) GetByID(c *gin.Context) {
ChannelID: post.ChannelID, ChannelID: post.ChannelID,
Title: post.Title, Title: post.Title,
Content: post.Content, Content: post.Content,
Segments: dto.SegmentsOrDefault(post.Segments, post.Content),
Images: dto.ConvertPostImagesToResponse(post.Images), Images: dto.ConvertPostImagesToResponse(post.Images),
Status: string(post.Status), Status: string(post.Status),
LikesCount: post.LikesCount, LikesCount: post.LikesCount,
@@ -365,9 +367,10 @@ func (h *PostHandler) Update(c *gin.Context) {
} }
type UpdateRequest struct { type UpdateRequest struct {
Title string `json:"title"` Title string `json:"title"`
Content string `json:"content"` Content string `json:"content"`
Images *[]string `json:"images"` Segments model.MessageSegments `json:"segments"`
Images *[]string `json:"images"`
} }
var req UpdateRequest var req UpdateRequest
@@ -382,6 +385,12 @@ func (h *PostHandler) Update(c *gin.Context) {
if req.Content != "" { if req.Content != "" {
post.Content = 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) err = h.postService.UpdateWithImages(c.Request.Context(), post, req.Images)
if err != nil { if err != nil {

View File

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

View File

@@ -20,13 +20,14 @@ const (
// Comment 评论实体 // Comment 评论实体
type Comment struct { type Comment struct {
ID string `json:"id" gorm:"type:varchar(36);primaryKey"` 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"` 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"` 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支持嵌套 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用于高效查询 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"` Content string `json:"content" gorm:"type:text;not null"`
Images string `json:"images" gorm:"type:text"` // 图片URL列表JSON数组格式 Segments MessageSegments `json:"segments,omitempty" gorm:"type:text"`
Images string `json:"images" gorm:"type:text"` // 图片URL列表JSON数组格式
// 关联 // 关联
User *User `json:"-" gorm:"foreignKey:UserID"` User *User `json:"-" gorm:"foreignKey:UserID"`

View File

@@ -20,11 +20,12 @@ const (
// Post 帖子实体 // Post 帖子实体
type Post struct { type Post struct {
ID string `json:"id" gorm:"type:varchar(36);primaryKey"` 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"` 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"` ChannelID *string `json:"channel_id,omitempty" gorm:"type:varchar(36);index"`
Title string `json:"title" gorm:"type:varchar(200);not null"` Title string `json:"title" gorm:"type:varchar(200);not null"`
Content string `json:"content" gorm:"type:text;not null"` Content string `json:"content" gorm:"type:text;not null"`
Segments MessageSegments `json:"segments,omitempty" gorm:"type:text"`
// 关联 // 关联
// User 需要参与缓存序列化;否则列表命中缓存后会丢失作者信息,前端退化为“匿名用户” // User 需要参与缓存序列化;否则列表命中缓存后会丢失作者信息,前端退化为“匿名用户”

View File

@@ -1,9 +1,9 @@
package repository package repository
import ( import (
"with_you/internal/model"
"strings" "strings"
"time" "time"
"with_you/internal/model"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"

View File

@@ -1,8 +1,8 @@
package repository package repository
import ( import (
"with_you/internal/model"
"errors" "errors"
"with_you/internal/model"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -16,6 +16,7 @@ type VoteRepository interface {
GetUserVote(postID, userID string) (*model.UserVote, error) GetUserVote(postID, userID string) (*model.UserVote, error)
UpdateOption(optionID, content string) error UpdateOption(optionID, content string) error
DeleteOptionsByPostID(postID string) error DeleteOptionsByPostID(postID string) error
CountVotesByOption(postID, optionID string) (int, error)
} }
// voteRepository 投票仓储实现 // voteRepository 投票仓储实现
@@ -72,9 +73,9 @@ func (r *voteRepository) Vote(postID, userID, optionID string) error {
// 验证选项是否属于该帖子 // 验证选项是否属于该帖子
var option model.VoteOption var option model.VoteOption
if err := tx.Where("id = ? AND post_id = ?", optionID, postID).First(&option).Error; err != nil { if err := tx.Where("id = ? AND post_id = ?", optionID, postID).First(&option).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("invalid option") return errors.New("invalid option")
} }
return err return err
} }
@@ -100,9 +101,9 @@ func (r *voteRepository) Unvote(postID, userID string) error {
var userVote model.UserVote var userVote model.UserVote
err := tx.Where("post_id = ? AND user_id = ?", postID, userID).First(&userVote).Error err := tx.Where("post_id = ? AND user_id = ?", postID, userID).First(&userVote).Error
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil // 没有投票记录,直接返回 return nil // 没有投票记录,直接返回
} }
return err return err
} }
@@ -144,12 +145,16 @@ func (r *voteRepository) UpdateOption(optionID, content string) error {
// DeleteOptionsByPostID 删除帖子的所有投票选项 // DeleteOptionsByPostID 删除帖子的所有投票选项
func (r *voteRepository) DeleteOptionsByPostID(postID string) error { func (r *voteRepository) DeleteOptionsByPostID(postID string) error {
return r.db.Transaction(func(tx *gorm.DB) error { return r.db.Transaction(func(tx *gorm.DB) error {
// 删除关联的用户投票记录
if err := tx.Where("post_id = ?", postID).Delete(&model.UserVote{}).Error; err != nil { if err := tx.Where("post_id = ?", postID).Delete(&model.UserVote{}).Error; err != nil {
return err return err
} }
// 删除投票选项
return tx.Where("post_id = ?", postID).Delete(&model.VoteOption{}).Error 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 ( import (
"context" "context"
"fmt" "fmt"
"log"
"strings" "strings"
"with_you/internal/cache" "with_you/internal/cache"
"with_you/internal/dto"
"with_you/internal/model" "with_you/internal/model"
"with_you/internal/pkg/cursor" "with_you/internal/pkg/cursor"
"with_you/internal/pkg/hook" "with_you/internal/pkg/hook"
@@ -37,7 +39,7 @@ func NewCommentService(commentRepo repository.CommentRepository, postRepo reposi
} }
// Create 创建评论 // 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 { if s.postAIService != nil {
// 采用异步审核,前端先立即返回 // 采用异步审核,前端先立即返回
} }
@@ -52,6 +54,7 @@ func (s *CommentService) Create(ctx context.Context, postID, userID, content str
PostID: postID, PostID: postID,
UserID: userID, UserID: userID,
Content: content, Content: content,
Segments: segments,
ParentID: parentID, ParentID: parentID,
Images: images, Images: images,
Status: model.CommentStatusPending, Status: model.CommentStatusPending,
@@ -94,11 +97,38 @@ func (s *CommentService) Create(ctx context.Context, postID, userID, content str
return nil, err 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) go s.reviewCommentAsync(comment.ID, userID, postID, content, imageURLs, parentID, parentUserID, post.UserID)
return comment, nil 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( func (s *CommentService) reviewCommentAsync(
commentID, userID, postID, content string, commentID, userID, postID, content string,
imageURLs []string, imageURLs []string,

View File

@@ -1,4 +1,4 @@
package service package service
import ( import (
"context" "context"
@@ -9,6 +9,7 @@ import (
"time" "time"
"with_you/internal/cache" "with_you/internal/cache"
"with_you/internal/dto"
"with_you/internal/model" "with_you/internal/model"
"with_you/internal/pkg/cursor" "with_you/internal/pkg/cursor"
"with_you/internal/pkg/hook" "with_you/internal/pkg/hook"
@@ -25,7 +26,7 @@ const (
// PostService 帖子服务接口 // PostService 帖子服务接口
type PostService interface { type PostService interface {
// 帖子CRUD // 帖子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) GetByID(ctx context.Context, id string) (*model.Post, error)
Update(ctx context.Context, post *model.Post) error Update(ctx context.Context, post *model.Post) error
UpdateWithImages(ctx context.Context, post *model.Post, images *[]string) error UpdateWithImages(ctx context.Context, post *model.Post, images *[]string) error
@@ -98,13 +99,27 @@ type PostListResult struct {
} }
// Create 创建帖子 // 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{ post := &model.Post{
UserID: userID, UserID: userID,
ChannelID: channelID, ChannelID: channelID,
Title: title, Title: title,
Content: content, Content: content,
Segments: segments,
Status: model.PostStatusPending, Status: model.PostStatusPending,
IsVote: isVote,
} }
err := s.postRepo.Create(post, images) 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) 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) 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获取帖子 // GetByID 根据ID获取帖子
func (s *postServiceImpl) GetByID(ctx context.Context, id string) (*model.Post, error) { func (s *postServiceImpl) GetByID(ctx context.Context, id string) (*model.Post, error) {
return s.postRepo.GetByID(id) return s.postRepo.GetByID(id)

View File

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

View File

@@ -1,4 +1,4 @@
package service package service
import ( import (
"context" "context"
@@ -48,7 +48,6 @@ func NewVoteService(
// CreateVotePost 创建投票帖子 // CreateVotePost 创建投票帖子
func (s *VoteService) CreateVotePost(ctx context.Context, userID string, req *dto.CreateVotePostRequest) (*dto.PostResponse, error) { func (s *VoteService) CreateVotePost(ctx context.Context, userID string, req *dto.CreateVotePostRequest) (*dto.PostResponse, error) {
// 验证投票选项数量
if len(req.VoteOptions) < 2 { if len(req.VoteOptions) < 2 {
return nil, errors.New("投票选项至少需要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个") 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{ post := &model.Post{
UserID: userID, UserID: userID,
ChannelID: req.ChannelID, ChannelID: req.ChannelID,
Title: req.Title, Title: req.Title,
Content: req.Content, Content: content,
Segments: segments,
Status: model.PostStatusPending, Status: model.PostStatusPending,
IsVote: true, IsVote: true,
} }
@@ -71,14 +95,8 @@ func (s *VoteService) CreateVotePost(ctx context.Context, userID string, req *dt
return nil, err 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 // 重新查询以获取关联的User和Images
createdPost, err := s.postRepo.GetByID(post.ID) 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 return nil, err
} }
// 转换为响应DTO
return s.convertToPostResponse(createdPost, userID), nil return s.convertToPostResponse(createdPost, userID), nil
} }
@@ -196,8 +213,27 @@ func (s *VoteService) notifyModerationRejected(userID, reason string) {
}() }()
} }
// GetVoteOptions 获取投票选项 // resolveVoteOptions 解析投票选项(优先从 segments 读取,兼容旧数据从 vote_options 表读取)
func (s *VoteService) GetVoteOptions(postID string) ([]dto.VoteOptionDTO, error) { 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) options, err := s.voteRepo.GetOptionsByPostID(postID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -215,23 +251,35 @@ func (s *VoteService) GetVoteOptions(postID string) ([]dto.VoteOptionDTO, error)
return result, nil return result, nil
} }
// GetVoteResult 获取投票结果(包含用户投票状态 // GetVoteOptions 获取投票选项(优先从 segments 读取,兼容旧数据从 vote_options 表读取
func (s *VoteService) GetVoteResult(postID, userID string) (*dto.VoteResultDTO, error) { func (s *VoteService) GetVoteOptions(postID string) ([]dto.VoteOptionDTO, error) {
// 获取所有投票选项 post, err := s.postRepo.GetByID(postID)
options, err := s.voteRepo.GetOptionsByPostID(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 { if err != nil {
return nil, err return nil, err
} }
// 获取用户的投票记录
userVote, err := s.voteRepo.GetUserVote(postID, userID) userVote, err := s.voteRepo.GetUserVote(postID, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 构建结果
result := &dto.VoteResultDTO{ result := &dto.VoteResultDTO{
Options: make([]dto.VoteOptionDTO, 0, len(options)), Options: optionDTOs,
TotalVotes: 0, TotalVotes: 0,
HasVoted: userVote != nil, HasVoted: userVote != nil,
} }
@@ -240,13 +288,8 @@ func (s *VoteService) GetVoteResult(postID, userID string) (*dto.VoteResultDTO,
result.VotedOptionID = userVote.OptionID result.VotedOptionID = userVote.OptionID
} }
for _, option := range options { for _, opt := range optionDTOs {
result.Options = append(result.Options, dto.VoteOptionDTO{ result.TotalVotes += opt.VotesCount
ID: option.ID,
Content: option.Content,
VotesCount: option.VotesCount,
})
result.TotalVotes += option.VotesCount
} }
return result, nil return result, nil
@@ -308,6 +351,7 @@ func (s *VoteService) convertToPostResponse(post *model.Post, currentUserID stri
UserID: post.UserID, UserID: post.UserID,
Title: post.Title, Title: post.Title,
Content: post.Content, Content: post.Content,
Segments: dto.SegmentsOrDefault(post.Segments, post.Content),
LikesCount: post.LikesCount, LikesCount: post.LikesCount,
CommentsCount: post.CommentsCount, CommentsCount: post.CommentsCount,
FavoritesCount: post.FavoritesCount, FavoritesCount: post.FavoritesCount,