feat(posts): add post reference/internal linking functionality
All checks were successful
Build Backend / build (push) Successful in 2m19s
Build Backend / build-docker (push) Successful in 1m18s

Add support for referencing other posts within messages through a new post_ref segment type. Includes new PostReference model to track relationships, PostRefService for processing references, and new endpoints for post suggestions, related posts, and reference click tracking.
This commit is contained in:
lafay
2026-04-26 00:37:20 +08:00
parent 898c0e6d9c
commit 23d7f1151e
11 changed files with 592 additions and 5 deletions

View File

@@ -67,7 +67,9 @@ func InitializeApp() (*App, error) {
postService := wire.ProvidePostService(postRepository, systemMessageService, postAIService, cache, transactionManager, manager, moderationHooks, builtinHooks, logService) postService := wire.ProvidePostService(postRepository, systemMessageService, postAIService, cache, transactionManager, manager, moderationHooks, builtinHooks, logService)
channelRepository := repository.NewChannelRepository(db) channelRepository := repository.NewChannelRepository(db)
channelService := wire.ProvideChannelService(channelRepository, cache) channelService := wire.ProvideChannelService(channelRepository, cache)
postHandler := handler.NewPostHandler(postService, userService, channelService) postRefRepository := repository.NewPostRefRepository(db)
postRefService := wire.ProvidePostRefService(postRefRepository, postRepository)
postHandler := handler.NewPostHandler(postService, postRefService, userService, channelService)
commentService := wire.ProvideCommentService(commentRepository, postRepository, systemMessageService, postAIService, cache, manager, logService) commentService := wire.ProvideCommentService(commentRepository, postRepository, systemMessageService, postAIService, cache, manager, logService)
commentHandler := handler.NewCommentHandler(commentService) commentHandler := handler.NewCommentHandler(commentService)
s3Client, err := wire.ProvideS3Client(config) s3Client, err := wire.ProvideS3Client(config)

View File

@@ -250,8 +250,9 @@ const (
SegmentTypeAt SegmentType = "at" SegmentTypeAt SegmentType = "at"
SegmentTypeReply SegmentType = "reply" SegmentTypeReply SegmentType = "reply"
SegmentTypeFace SegmentType = "face" SegmentTypeFace SegmentType = "face"
SegmentTypeLink SegmentType = "link" SegmentTypeLink SegmentType = "link"
SegmentTypeVote SegmentType = "vote" SegmentTypeVote SegmentType = "vote"
SegmentTypePostRef SegmentType = "post_ref"
) )
// TextSegmentData 文本数据 // TextSegmentData 文本数据
@@ -325,6 +326,23 @@ type VoteOptionData struct {
Content string `json:"content"` Content string `json:"content"`
} }
// PostRefSegmentData 帖子内链Segment数据
type PostRefSegmentData struct {
PostID string `json:"post_id"`
Title string `json:"title,omitempty"`
Author *UserResponse `json:"author,omitempty"`
Status string `json:"status,omitempty"`
Accessible bool `json:"accessible"`
ClickCount int `json:"click_count,omitzero"`
}
// PostRefBrief 帖子内链简要信息 (用于搜索联想)
type PostRefBrief struct {
ID string `json:"id"`
Title string `json:"title"`
Channel string `json:"channel,omitempty"`
}
// VoteSegmentData 投票Segment数据 // VoteSegmentData 投票Segment数据
type VoteSegmentData struct { type VoteSegmentData struct {
Options []VoteOptionData `json:"options"` Options []VoteOptionData `json:"options"`

View File

@@ -145,6 +145,17 @@ func NewLinkSegment(url, title, description, image string) model.MessageSegment
} }
} }
// NewPostRefSegment 创建帖子内链Segment
func NewPostRefSegment(postID, title string) model.MessageSegment {
return model.MessageSegment{
Type: string(SegmentTypePostRef),
Data: map[string]any{
"post_id": postID,
"title": title,
},
}
}
// ExtractVoteSegmentData 从消息链中提取投票Segment数据 // ExtractVoteSegmentData 从消息链中提取投票Segment数据
func ExtractVoteSegmentData(segments model.MessageSegments) *VoteSegmentData { func ExtractVoteSegmentData(segments model.MessageSegments) *VoteSegmentData {
for _, segment := range segments { for _, segment := range segments {
@@ -227,6 +238,12 @@ func ExtractTextContentFromModel(segments model.MessageSegments) string {
} }
case "vote": case "vote":
result += "[投票]" result += "[投票]"
case "post_ref":
if title, ok := segment.Data["title"].(string); ok && title != "" {
result += fmt.Sprintf("[帖子:%s]", title)
} else {
result += "[帖子引用]"
}
} }
} }
return result return result
@@ -256,6 +273,28 @@ func ExtractMentionedUsers(segments model.MessageSegments) []string {
return userIDs return userIDs
} }
// ExtractReferencedPosts 从消息链中提取所有被引用的帖子ID
func ExtractReferencedPosts(segments model.MessageSegments) []string {
var postIDs []string
seen := make(map[string]bool)
for _, segment := range segments {
if segment.Type == string(SegmentTypePostRef) {
postID, _ := segment.Data["post_id"].(string)
if postID != "" && !seen[postID] {
postIDs = append(postIDs, postID)
seen[postID] = true
}
}
}
return postIDs
}
// HasPostRefSegment 检查消息链中是否包含帖子引用
func HasPostRefSegment(segments model.MessageSegments) bool {
return HasSegmentType(segments, SegmentTypePostRef)
}
// IsAtAll 检查消息是否@了所有人 // IsAtAll 检查消息是否@了所有人
func IsAtAll(segments model.MessageSegments) bool { func IsAtAll(segments model.MessageSegments) bool {
return slices.ContainsFunc(segments, func(segment model.MessageSegment) bool { return slices.ContainsFunc(segments, func(segment model.MessageSegment) bool {

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log"
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -19,14 +20,16 @@ import (
// PostHandler 帖子处理器 // PostHandler 帖子处理器
type PostHandler struct { type PostHandler struct {
postService service.PostService postService service.PostService
postRefService service.PostRefService
userService service.UserService userService service.UserService
channelService service.ChannelService channelService service.ChannelService
} }
// NewPostHandler 创建帖子处理器 // NewPostHandler 创建帖子处理器
func NewPostHandler(postService service.PostService, userService service.UserService, channelService service.ChannelService) *PostHandler { func NewPostHandler(postService service.PostService, postRefService service.PostRefService, userService service.UserService, channelService service.ChannelService) *PostHandler {
return &PostHandler{ return &PostHandler{
postService: postService, postService: postService,
postRefService: postRefService,
userService: userService, userService: userService,
channelService: channelService, channelService: channelService,
} }
@@ -113,6 +116,15 @@ func (h *PostHandler) Create(c *gin.Context) {
return return
} }
if h.postRefService != nil && len(segments) > 0 {
go func() {
bgCtx := context.Background()
if err := h.postRefService.ProcessPostReferences(bgCtx, post.ID, segments); err != nil {
log.Printf("[WARN] process post references failed for post %s: %v", post.ID, err)
}
}()
}
chMap := h.channelMapForPosts([]*model.Post{post}) chMap := h.channelMapForPosts([]*model.Post{post})
response.Success(c, dto.ConvertPostToResponse(post, chMap, false, false)) response.Success(c, dto.ConvertPostToResponse(post, chMap, false, false))
} }
@@ -136,6 +148,14 @@ func (h *PostHandler) GetByID(c *gin.Context) {
// 注意:不再自动增加浏览量,浏览量通过 RecordView 端点单独记录 // 注意:不再自动增加浏览量,浏览量通过 RecordView 端点单独记录
// 增强 post_ref segments
enrichedSegments := dto.SegmentsOrDefault(post.Segments, post.Content)
if h.postRefService != nil && len(enrichedSegments) > 0 {
if enriched, err := h.postRefService.EnrichPostRefSegments(c.Request.Context(), enrichedSegments, currentUserID); err == nil {
enrichedSegments = enriched
}
}
// 获取交互状态 // 获取交互状态
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, currentUserID) isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, currentUserID)
@@ -160,7 +180,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), Segments: enrichedSegments,
Images: dto.ConvertPostImagesToResponse(post.Images), Images: dto.ConvertPostImagesToResponse(post.Images),
Status: string(post.Status), Status: string(post.Status),
LikesCount: post.LikesCount, LikesCount: post.LikesCount,
@@ -438,6 +458,15 @@ func (h *PostHandler) Update(c *gin.Context) {
return return
} }
if h.postRefService != nil && len(req.Segments) > 0 {
go func() {
bgCtx := context.Background()
if err := h.postRefService.ProcessPostReferences(bgCtx, post.ID, post.Segments); err != nil {
log.Printf("[WARN] process post references failed for post %s: %v", post.ID, err)
}
}()
}
post, err = h.postService.GetByID(c.Request.Context(), post.ID) post, err = h.postService.GetByID(c.Request.Context(), post.ID)
if err != nil { if err != nil {
response.InternalServerError(c, "failed to get updated post") response.InternalServerError(c, "failed to get updated post")
@@ -479,6 +508,15 @@ func (h *PostHandler) Delete(c *gin.Context) {
return return
} }
if h.postRefService != nil {
go func() {
bgCtx := context.Background()
if err := h.postRefService.HandlePostDeleted(bgCtx, id); err != nil {
log.Printf("[WARN] mark dead post references failed for post %s: %v", id, err)
}
}()
}
response.SuccessWithMessage(c, "post deleted", nil) response.SuccessWithMessage(c, "post deleted", nil)
} }
@@ -828,6 +866,114 @@ func (h *PostHandler) SearchByCursor(c *gin.Context) {
response.Success(c, cursorResp) response.Success(c, cursorResp)
} }
// RecordRefClick 记录内链点击
func (h *PostHandler) RecordRefClick(c *gin.Context) {
id := c.Param("id")
type RefClickRequest struct {
TargetPostID string `json:"target_post_id" binding:"required"`
}
var req RefClickRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "target_post_id is required")
return
}
if h.postRefService != nil {
if err := h.postRefService.RecordClick(c.Request.Context(), id, req.TargetPostID); err != nil {
log.Printf("[WARN] record ref click failed: %v", err)
}
}
response.Success(c, gin.H{"success": true})
}
// GetRelatedPosts 获取引用当前帖子的相关帖子
func (h *PostHandler) GetRelatedPosts(c *gin.Context) {
id := c.Param("id")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
page, pageSize = normalizePagination(page, pageSize)
if h.postRefService == nil {
response.Paginated(c, []*dto.PostResponse{}, 0, page, pageSize)
return
}
posts, total, err := h.postRefService.GetRelatedPosts(c.Request.Context(), id, page, pageSize)
if err != nil {
response.InternalServerError(c, "failed to get related posts")
return
}
currentUserID := c.GetString("user_id")
posts = h.filterBlockedPosts(c.Request.Context(), posts, currentUserID)
postIDs := make([]string, len(posts))
for i, p := range posts {
postIDs[i] = p.ID
}
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
chMap := h.channelMapForPosts(posts)
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
response.Paginated(c, postResponses, total, page, pageSize)
}
// GetReferencedPosts 获取当前帖子引用的目标帖子
func (h *PostHandler) GetReferencedPosts(c *gin.Context) {
id := c.Param("id")
if h.postRefService == nil {
response.Success(c, []*dto.PostResponse{})
return
}
posts, err := h.postRefService.GetReferencedPosts(c.Request.Context(), id)
if err != nil {
response.InternalServerError(c, "failed to get referenced posts")
return
}
currentUserID := c.GetString("user_id")
posts = h.filterBlockedPosts(c.Request.Context(), posts, currentUserID)
postIDs := make([]string, len(posts))
for i, p := range posts {
postIDs[i] = p.ID
}
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
chMap := h.channelMapForPosts(posts)
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
response.Success(c, postResponses)
}
// Suggest 帖子搜索联想
func (h *PostHandler) Suggest(c *gin.Context) {
q := c.Query("q")
if q == "" {
response.Success(c, []dto.PostRefBrief{})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if limit <= 0 || limit > 50 {
limit = 10
}
briefs, err := h.postRefService.SuggestPosts(c.Request.Context(), q, limit)
if err != nil {
response.InternalServerError(c, "failed to search posts")
return
}
response.Success(c, briefs)
}
// parseCursorRequest 解析游标分页请求参数 // parseCursorRequest 解析游标分页请求参数
func (h *PostHandler) parseCursorRequest(c *gin.Context) *cursor.PageRequest { func (h *PostHandler) parseCursorRequest(c *gin.Context) *cursor.PageRequest {
cursorStr := c.Query("cursor") cursorStr := c.Query("cursor")

View File

@@ -176,6 +176,9 @@ func autoMigrate(db *gorm.DB) error {
// 用户资料审核相关 // 用户资料审核相关
&UserProfileAudit{}, &UserProfileAudit{},
// 帖子内链引用关系
&PostReference{},
) )
if err != nil { if err != nil {
return err return err

View File

@@ -0,0 +1,40 @@
package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type PostReferenceStatus string
const (
PostRefActive PostReferenceStatus = "active"
PostRefDead PostReferenceStatus = "dead"
PostRefHidden PostReferenceStatus = "hidden"
)
type PostReference struct {
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
SourcePostID string `json:"source_post_id" gorm:"type:varchar(36);not null;uniqueIndex:uq_source_target;index:idx_source_post"`
TargetPostID string `json:"target_post_id" gorm:"type:varchar(36);not null;uniqueIndex:uq_source_target;index:idx_target_post;index:idx_target_status"`
Status PostReferenceStatus `json:"status" gorm:"type:varchar(20);default:active;index:idx_target_status"`
ClickCount int `json:"click_count" gorm:"default:0"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime:false"`
SourcePost *Post `json:"source_post,omitempty" gorm:"foreignKey:SourcePostID"`
TargetPost *Post `json:"target_post,omitempty" gorm:"foreignKey:TargetPostID"`
}
func (pr *PostReference) BeforeCreate(tx *gorm.DB) error {
if pr.ID == "" {
pr.ID = uuid.New().String()
}
return nil
}
func (PostReference) TableName() string {
return "post_references"
}

View File

@@ -0,0 +1,97 @@
package repository
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"with_you/internal/model"
)
type PostRefRepository interface {
BatchUpsert(ctx context.Context, sourcePostID string, targetPostIDs []string) error
DeleteBySourcePostID(ctx context.Context, sourcePostID string) error
GetSourcesByTargetPostID(ctx context.Context, targetPostID string, page, pageSize int) ([]model.PostReference, int64, error)
GetTargetsBySourcePostID(ctx context.Context, sourcePostID string) ([]model.PostReference, error)
MarkStatusByTargetPostID(ctx context.Context, targetPostID string, status model.PostReferenceStatus) error
IncrementClickCount(ctx context.Context, sourcePostID, targetPostID string) error
}
type postRefRepository struct {
db *gorm.DB
}
func NewPostRefRepository(db *gorm.DB) PostRefRepository {
return &postRefRepository{db: db}
}
func (r *postRefRepository) BatchUpsert(ctx context.Context, sourcePostID string, targetPostIDs []string) error {
if len(targetPostIDs) == 0 {
return nil
}
refs := make([]model.PostReference, 0, len(targetPostIDs))
for _, targetID := range targetPostIDs {
refs = append(refs, model.PostReference{
SourcePostID: sourcePostID,
TargetPostID: targetID,
Status: model.PostRefActive,
})
}
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source_post_id"}, {Name: "target_post_id"}},
DoUpdates: clause.AssignmentColumns([]string{"status", "updated_at"}),
}).Create(&refs).Error
}
func (r *postRefRepository) DeleteBySourcePostID(ctx context.Context, sourcePostID string) error {
return r.db.WithContext(ctx).Where("source_post_id = ?", sourcePostID).Delete(&model.PostReference{}).Error
}
func (r *postRefRepository) GetSourcesByTargetPostID(ctx context.Context, targetPostID string, page, pageSize int) ([]model.PostReference, int64, error) {
var refs []model.PostReference
var total int64
if err := r.db.WithContext(ctx).Model(&model.PostReference{}).
Where("target_post_id = ? AND status = ?", targetPostID, model.PostRefActive).
Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
if err := r.db.WithContext(ctx).
Where("target_post_id = ? AND status = ?", targetPostID, model.PostRefActive).
Preload("SourcePost").
Preload("SourcePost.User").
Order("created_at DESC").
Offset(offset).Limit(pageSize).
Find(&refs).Error; err != nil {
return nil, 0, err
}
return refs, total, nil
}
func (r *postRefRepository) GetTargetsBySourcePostID(ctx context.Context, sourcePostID string) ([]model.PostReference, error) {
var refs []model.PostReference
if err := r.db.WithContext(ctx).
Where("source_post_id = ?", sourcePostID).
Preload("TargetPost").
Preload("TargetPost.User").
Find(&refs).Error; err != nil {
return nil, err
}
return refs, nil
}
func (r *postRefRepository) MarkStatusByTargetPostID(ctx context.Context, targetPostID string, status model.PostReferenceStatus) error {
return r.db.WithContext(ctx).Model(&model.PostReference{}).
Where("target_post_id = ?", targetPostID).
Update("status", status).Error
}
func (r *postRefRepository) IncrementClickCount(ctx context.Context, sourcePostID, targetPostID string) error {
return r.db.WithContext(ctx).Model(&model.PostReference{}).
Where("source_post_id = ? AND target_post_id = ?", sourcePostID, targetPostID).
UpdateColumn("click_count", gorm.Expr("click_count + 1")).Error
}

View File

@@ -272,6 +272,7 @@ func (r *Router) setupRoutes() {
// 使用 OptionalAuth 中间件来获取用户登录状态 // 使用 OptionalAuth 中间件来获取用户登录状态
posts.GET("", middleware.OptionalAuth(r.jwtService), r.postHandler.List) posts.GET("", middleware.OptionalAuth(r.jwtService), r.postHandler.List)
posts.GET("/search", middleware.OptionalAuth(r.jwtService), r.postHandler.Search) posts.GET("/search", middleware.OptionalAuth(r.jwtService), r.postHandler.Search)
posts.GET("/suggest", middleware.OptionalAuth(r.jwtService), r.postHandler.Suggest)
posts.GET("/:id", middleware.OptionalAuth(r.jwtService), r.postHandler.GetByID) posts.GET("/:id", middleware.OptionalAuth(r.jwtService), r.postHandler.GetByID)
posts.POST("", authMiddleware, middleware.RequireVerified(r.userRepo), r.postHandler.Create) posts.POST("", authMiddleware, middleware.RequireVerified(r.userRepo), r.postHandler.Create)
posts.PUT("/:id", authMiddleware, middleware.RequireVerified(r.userRepo), r.postHandler.Update) posts.PUT("/:id", authMiddleware, middleware.RequireVerified(r.userRepo), r.postHandler.Update)
@@ -282,6 +283,12 @@ func (r *Router) setupRoutes() {
// 分享计数(可选认证;仅已发布帖子生效) // 分享计数(可选认证;仅已发布帖子生效)
posts.POST("/:id/share", middleware.OptionalAuth(r.jwtService), r.postHandler.RecordShare) posts.POST("/:id/share", middleware.OptionalAuth(r.jwtService), r.postHandler.RecordShare)
// 内链相关路由
posts.GET("/:id/related", middleware.OptionalAuth(r.jwtService), r.postHandler.GetRelatedPosts)
posts.GET("/:id/refs", middleware.OptionalAuth(r.jwtService), r.postHandler.GetReferencedPosts)
posts.GET("/suggest", middleware.OptionalAuth(r.jwtService), r.postHandler.Suggest)
posts.POST("/:id/ref-click", middleware.OptionalAuth(r.jwtService), r.postHandler.RecordRefClick)
// 点赞 // 点赞
posts.POST("/:id/like", authMiddleware, middleware.RequireVerified(r.userRepo), r.postHandler.Like) posts.POST("/:id/like", authMiddleware, middleware.RequireVerified(r.userRepo), r.postHandler.Like)
posts.DELETE("/:id/like", authMiddleware, middleware.RequireVerified(r.userRepo), r.postHandler.Unlike) posts.DELETE("/:id/like", authMiddleware, middleware.RequireVerified(r.userRepo), r.postHandler.Unlike)

View File

@@ -0,0 +1,222 @@
package service
import (
"context"
"fmt"
"log"
"slices"
"with_you/internal/dto"
"with_you/internal/model"
"with_you/internal/repository"
)
type PostRefService interface {
ProcessPostReferences(ctx context.Context, sourcePostID string, segments model.MessageSegments) error
EnrichPostRefSegments(ctx context.Context, segments model.MessageSegments, currentUserID string) (model.MessageSegments, error)
RecordClick(ctx context.Context, sourcePostID, targetPostID string) error
HandlePostDeleted(ctx context.Context, postID string) error
HandlePostRecovered(ctx context.Context, postID string) error
HandlePostHidden(ctx context.Context, postID string) error
GetRelatedPosts(ctx context.Context, postID string, page, pageSize int) ([]*model.Post, int64, error)
GetReferencedPosts(ctx context.Context, postID string) ([]*model.Post, error)
SuggestPosts(ctx context.Context, keyword string, limit int) ([]dto.PostRefBrief, error)
}
type postRefServiceImpl struct {
refRepo repository.PostRefRepository
postRepo repository.PostRepository
}
func NewPostRefService(refRepo repository.PostRefRepository, postRepo repository.PostRepository) PostRefService {
return &postRefServiceImpl{
refRepo: refRepo,
postRepo: postRepo,
}
}
func (s *postRefServiceImpl) ProcessPostReferences(ctx context.Context, sourcePostID string, segments model.MessageSegments) error {
targetIDs := dto.ExtractReferencedPosts(segments)
if len(targetIDs) == 0 {
return nil
}
validIDs := make([]string, 0, len(targetIDs))
for _, tid := range targetIDs {
post, err := s.postRepo.GetByID(tid)
if err != nil {
log.Printf("[WARN] post_ref target %s not found for source %s: %v", tid, sourcePostID, err)
continue
}
if post.Status == model.PostStatusPublished && !post.IsDeleted {
validIDs = append(validIDs, tid)
}
}
if len(validIDs) == 0 {
return nil
}
return s.refRepo.BatchUpsert(ctx, sourcePostID, validIDs)
}
func (s *postRefServiceImpl) EnrichPostRefSegments(ctx context.Context, segments model.MessageSegments, currentUserID string) (model.MessageSegments, error) {
postIDs := dto.ExtractReferencedPosts(segments)
if len(postIDs) == 0 {
return segments, nil
}
posts, err := s.postRepo.GetByIDs(postIDs)
if err != nil {
log.Printf("[WARN] failed to get referenced posts: %v", err)
return segments, nil
}
postMap := make(map[string]*model.Post, len(posts))
for _, p := range posts {
postMap[p.ID] = p
}
enriched := make(model.MessageSegments, len(segments))
for i, seg := range segments {
enriched[i] = seg
if seg.Type == string(dto.SegmentTypePostRef) {
postID, _ := seg.Data["post_id"].(string)
enriched[i] = s.enrichSegment(seg, postID, postMap, currentUserID)
}
}
return enriched, nil
}
func (s *postRefServiceImpl) enrichSegment(seg model.MessageSegment, postID string, postMap map[string]*model.Post, currentUserID string) model.MessageSegment {
newSeg := model.MessageSegment{
Type: seg.Type,
Data: map[string]any{"post_id": postID},
}
post, ok := postMap[postID]
if !ok || post == nil {
newSeg.Data["status"] = "deleted"
newSeg.Data["title"] = "该帖子已删除"
newSeg.Data["accessible"] = false
return newSeg
}
if post.IsDeleted {
newSeg.Data["status"] = "deleted"
newSeg.Data["title"] = "该帖子已删除"
newSeg.Data["accessible"] = false
return newSeg
}
if post.Status != model.PostStatusPublished {
if currentUserID == post.UserID {
newSeg.Data["status"] = string(post.Status)
newSeg.Data["title"] = post.Title
newSeg.Data["accessible"] = true
} else {
newSeg.Data["status"] = "hidden"
newSeg.Data["title"] = "该帖子不可见"
newSeg.Data["accessible"] = false
}
return newSeg
}
newSeg.Data["status"] = "published"
newSeg.Data["title"] = post.Title
newSeg.Data["accessible"] = true
if post.User != nil {
newSeg.Data["author"] = map[string]any{
"id": post.User.ID,
"nickname": post.User.Nickname,
"avatar": post.User.Avatar,
}
}
return newSeg
}
func (s *postRefServiceImpl) RecordClick(ctx context.Context, sourcePostID, targetPostID string) error {
return s.refRepo.IncrementClickCount(ctx, sourcePostID, targetPostID)
}
func (s *postRefServiceImpl) HandlePostDeleted(ctx context.Context, postID string) error {
return s.refRepo.MarkStatusByTargetPostID(ctx, postID, model.PostRefDead)
}
func (s *postRefServiceImpl) HandlePostRecovered(ctx context.Context, postID string) error {
return s.refRepo.MarkStatusByTargetPostID(ctx, postID, model.PostRefActive)
}
func (s *postRefServiceImpl) HandlePostHidden(ctx context.Context, postID string) error {
return s.refRepo.MarkStatusByTargetPostID(ctx, postID, model.PostRefHidden)
}
func (s *postRefServiceImpl) GetRelatedPosts(ctx context.Context, postID string, page, pageSize int) ([]*model.Post, int64, error) {
refs, total, err := s.refRepo.GetSourcesByTargetPostID(ctx, postID, page, pageSize)
if err != nil {
return nil, 0, err
}
posts := make([]*model.Post, 0, len(refs))
for _, ref := range refs {
if ref.SourcePost != nil {
posts = append(posts, ref.SourcePost)
}
}
return posts, total, nil
}
func (s *postRefServiceImpl) GetReferencedPosts(ctx context.Context, postID string) ([]*model.Post, error) {
refs, err := s.refRepo.GetTargetsBySourcePostID(ctx, postID)
if err != nil {
return nil, err
}
posts := make([]*model.Post, 0, len(refs))
for _, ref := range refs {
if ref.TargetPost != nil && ref.TargetPost.Status == model.PostStatusPublished && !ref.TargetPost.IsDeleted {
posts = append(posts, ref.TargetPost)
}
}
return posts, nil
}
func (s *postRefServiceImpl) SuggestPosts(ctx context.Context, keyword string, limit int) ([]dto.PostRefBrief, error) {
if limit <= 0 {
limit = 10
}
posts, _, err := s.postRepo.Search(keyword, 1, limit)
if err != nil {
return nil, fmt.Errorf("search posts for suggest: %w", err)
}
result := make([]dto.PostRefBrief, 0, len(posts))
for _, p := range posts {
brief := dto.PostRefBrief{
ID: p.ID,
Title: p.Title,
}
if p.ChannelID != nil {
brief.Channel = *p.ChannelID
}
result = append(result, brief)
}
slices.SortFunc(result, func(a, b dto.PostRefBrief) int {
if len(a.Title) < len(b.Title) {
return -1
}
if len(a.Title) > len(b.Title) {
return 1
}
return 0
})
return result, nil
}

View File

@@ -43,6 +43,9 @@ var RepositorySet = wire.NewSet(
// 用户资料审核相关仓储 // 用户资料审核相关仓储
repository.NewUserProfileAuditRepository, repository.NewUserProfileAuditRepository,
// 帖子内链引用关系
repository.NewPostRefRepository,
) )
// ProvideUserActivityRepository 提供用户活跃数据仓储 // ProvideUserActivityRepository 提供用户活跃数据仓储

View File

@@ -65,6 +65,9 @@ var ServiceSet = wire.NewSet(
ProvideUserProfileAuditService, ProvideUserProfileAuditService,
ProvideSetupService, ProvideSetupService,
// 帖子内链引用
ProvidePostRefService,
// 日志服务 // 日志服务
ProvideAsyncLogManager, ProvideAsyncLogManager,
ProvideOperationLogService, ProvideOperationLogService,
@@ -486,3 +489,10 @@ func ProvideSetupService(
) service.SetupService { ) service.SetupService {
return service.NewSetupService(userRepo, roleRepo, casbinSvc, cfg.SetupSecret) return service.NewSetupService(userRepo, roleRepo, casbinSvc, cfg.SetupSecret)
} }
func ProvidePostRefService(
refRepo repository.PostRefRepository,
postRepo repository.PostRepository,
) service.PostRefService {
return service.NewPostRefService(refRepo, postRepo)
}