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,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 审核帖子请求
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package handler
|
||||
package handler
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 需要参与缓存序列化;否则列表命中缓存后会丢失作者信息,前端退化为“匿名用户”
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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