feat(posts): add post reference/internal linking functionality
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:
@@ -67,7 +67,9 @@ func InitializeApp() (*App, error) {
|
||||
postService := wire.ProvidePostService(postRepository, systemMessageService, postAIService, cache, transactionManager, manager, moderationHooks, builtinHooks, logService)
|
||||
channelRepository := repository.NewChannelRepository(db)
|
||||
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)
|
||||
commentHandler := handler.NewCommentHandler(commentService)
|
||||
s3Client, err := wire.ProvideS3Client(config)
|
||||
|
||||
@@ -250,8 +250,9 @@ const (
|
||||
SegmentTypeAt SegmentType = "at"
|
||||
SegmentTypeReply SegmentType = "reply"
|
||||
SegmentTypeFace SegmentType = "face"
|
||||
SegmentTypeLink SegmentType = "link"
|
||||
SegmentTypeVote SegmentType = "vote"
|
||||
SegmentTypeLink SegmentType = "link"
|
||||
SegmentTypeVote SegmentType = "vote"
|
||||
SegmentTypePostRef SegmentType = "post_ref"
|
||||
)
|
||||
|
||||
// TextSegmentData 文本数据
|
||||
@@ -325,6 +326,23 @@ type VoteOptionData struct {
|
||||
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数据
|
||||
type VoteSegmentData struct {
|
||||
Options []VoteOptionData `json:"options"`
|
||||
|
||||
@@ -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数据
|
||||
func ExtractVoteSegmentData(segments model.MessageSegments) *VoteSegmentData {
|
||||
for _, segment := range segments {
|
||||
@@ -227,6 +238,12 @@ func ExtractTextContentFromModel(segments model.MessageSegments) string {
|
||||
}
|
||||
case "vote":
|
||||
result += "[投票]"
|
||||
case "post_ref":
|
||||
if title, ok := segment.Data["title"].(string); ok && title != "" {
|
||||
result += fmt.Sprintf("[帖子:%s]", title)
|
||||
} else {
|
||||
result += "[帖子引用]"
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -256,6 +273,28 @@ func ExtractMentionedUsers(segments model.MessageSegments) []string {
|
||||
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 检查消息是否@了所有人
|
||||
func IsAtAll(segments model.MessageSegments) bool {
|
||||
return slices.ContainsFunc(segments, func(segment model.MessageSegment) bool {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -19,14 +20,16 @@ import (
|
||||
// PostHandler 帖子处理器
|
||||
type PostHandler struct {
|
||||
postService service.PostService
|
||||
postRefService service.PostRefService
|
||||
userService service.UserService
|
||||
channelService service.ChannelService
|
||||
}
|
||||
|
||||
// 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{
|
||||
postService: postService,
|
||||
postRefService: postRefService,
|
||||
userService: userService,
|
||||
channelService: channelService,
|
||||
}
|
||||
@@ -113,6 +116,15 @@ func (h *PostHandler) Create(c *gin.Context) {
|
||||
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})
|
||||
response.Success(c, dto.ConvertPostToResponse(post, chMap, false, false))
|
||||
}
|
||||
@@ -136,6 +148,14 @@ func (h *PostHandler) GetByID(c *gin.Context) {
|
||||
|
||||
// 注意:不再自动增加浏览量,浏览量通过 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)
|
||||
|
||||
@@ -160,7 +180,7 @@ func (h *PostHandler) GetByID(c *gin.Context) {
|
||||
ChannelID: post.ChannelID,
|
||||
Title: post.Title,
|
||||
Content: post.Content,
|
||||
Segments: dto.SegmentsOrDefault(post.Segments, post.Content),
|
||||
Segments: enrichedSegments,
|
||||
Images: dto.ConvertPostImagesToResponse(post.Images),
|
||||
Status: string(post.Status),
|
||||
LikesCount: post.LikesCount,
|
||||
@@ -438,6 +458,15 @@ func (h *PostHandler) Update(c *gin.Context) {
|
||||
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)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get updated post")
|
||||
@@ -479,6 +508,15 @@ func (h *PostHandler) Delete(c *gin.Context) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -828,6 +866,114 @@ func (h *PostHandler) SearchByCursor(c *gin.Context) {
|
||||
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 解析游标分页请求参数
|
||||
func (h *PostHandler) parseCursorRequest(c *gin.Context) *cursor.PageRequest {
|
||||
cursorStr := c.Query("cursor")
|
||||
|
||||
@@ -176,6 +176,9 @@ func autoMigrate(db *gorm.DB) error {
|
||||
|
||||
// 用户资料审核相关
|
||||
&UserProfileAudit{},
|
||||
|
||||
// 帖子内链引用关系
|
||||
&PostReference{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
40
internal/model/post_reference.go
Normal file
40
internal/model/post_reference.go
Normal 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"
|
||||
}
|
||||
97
internal/repository/post_ref_repo.go
Normal file
97
internal/repository/post_ref_repo.go
Normal 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
|
||||
}
|
||||
@@ -272,6 +272,7 @@ func (r *Router) setupRoutes() {
|
||||
// 使用 OptionalAuth 中间件来获取用户登录状态
|
||||
posts.GET("", middleware.OptionalAuth(r.jwtService), r.postHandler.List)
|
||||
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.POST("", authMiddleware, middleware.RequireVerified(r.userRepo), r.postHandler.Create)
|
||||
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.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.DELETE("/:id/like", authMiddleware, middleware.RequireVerified(r.userRepo), r.postHandler.Unlike)
|
||||
|
||||
222
internal/service/post_ref_service.go
Normal file
222
internal/service/post_ref_service.go
Normal 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
|
||||
}
|
||||
@@ -43,6 +43,9 @@ var RepositorySet = wire.NewSet(
|
||||
|
||||
// 用户资料审核相关仓储
|
||||
repository.NewUserProfileAuditRepository,
|
||||
|
||||
// 帖子内链引用关系
|
||||
repository.NewPostRefRepository,
|
||||
)
|
||||
|
||||
// ProvideUserActivityRepository 提供用户活跃数据仓储
|
||||
|
||||
@@ -65,6 +65,9 @@ var ServiceSet = wire.NewSet(
|
||||
ProvideUserProfileAuditService,
|
||||
ProvideSetupService,
|
||||
|
||||
// 帖子内链引用
|
||||
ProvidePostRefService,
|
||||
|
||||
// 日志服务
|
||||
ProvideAsyncLogManager,
|
||||
ProvideOperationLogService,
|
||||
@@ -486,3 +489,10 @@ func ProvideSetupService(
|
||||
) service.SetupService {
|
||||
return service.NewSetupService(userRepo, roleRepo, casbinSvc, cfg.SetupSecret)
|
||||
}
|
||||
|
||||
func ProvidePostRefService(
|
||||
refRepo repository.PostRefRepository,
|
||||
postRepo repository.PostRepository,
|
||||
) service.PostRefService {
|
||||
return service.NewPostRefService(refRepo, postRepo)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user