feat(P3): 私信 + #话题标签 + @提及 完成

This commit is contained in:
Sisyphus
2026-04-25 20:10:50 +08:00
parent 41454de49f
commit 18245c06bf
10 changed files with 438 additions and 185 deletions

View File

@@ -1,82 +1,82 @@
package feed package feed
import "time" import "time"
type FeedAuthor struct { type FeedAuthor struct {
ID uint `json:"id"` ID uint `json:"id"`
Username string `json:"username"` Username string `json:"username"`
} }
type FeedVideoItem struct { type FeedVideoItem struct {
ID uint `json:"id"` ID uint `json:"id"`
Author FeedAuthor `json:"author"` Author FeedAuthor `json:"author"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
PlayURL string `json:"play_url"` PlayURL string `json:"play_url"`
CoverURL string `json:"cover_url"` CoverURL string `json:"cover_url"`
CreateTime int64 `json:"create_time"` CreateTime int64 `json:"create_time"`
LikesCount int64 `json:"likes_count"` LikesCount int64 `json:"likes_count"`
IsLiked bool `json:"is_liked"` IsLiked bool `json:"is_liked"`
} }
type ListLatestRequest struct { type ListLatestRequest struct {
Limit int `json:"limit"` Limit int `json:"limit"`
LatestTime int64 `json:"latest_time"` LatestTime int64 `json:"latest_time"`
} }
type ListLatestResponse struct { type ListLatestResponse struct {
VideoList []FeedVideoItem `json:"video_list"` VideoList []FeedVideoItem `json:"video_list"`
NextTime int64 `json:"next_time"` NextTime int64 `json:"next_time"`
HasMore bool `json:"has_more"` HasMore bool `json:"has_more"`
} }
type ListLikesCountRequest struct { type ListLikesCountRequest struct {
Limit int `json:"limit"` Limit int `json:"limit"`
LikesCountBefore *int64 `json:"likes_count_before,omitempty"` LikesCountBefore *int64 `json:"likes_count_before,omitempty"`
IDBefore *uint `json:"id_before,omitempty"` IDBefore *uint `json:"id_before,omitempty"`
} }
type LikesCountCursor struct { type LikesCountCursor struct {
LikesCount int64 LikesCount int64
ID uint ID uint
} }
type ListLikesCountResponse struct { type ListLikesCountResponse struct {
VideoList []FeedVideoItem `json:"video_list"` VideoList []FeedVideoItem `json:"video_list"`
NextLikesCountBefore *int64 `json:"next_likes_count_before,omitempty"` NextLikesCountBefore *int64 `json:"next_likes_count_before,omitempty"`
NextIDBefore *uint `json:"next_id_before,omitempty"` NextIDBefore *uint `json:"next_id_before,omitempty"`
HasMore bool `json:"has_more"` HasMore bool `json:"has_more"`
} }
type ListByFollowingRequest struct { type ListByFollowingRequest struct {
Limit int `json:"limit"` Limit int `json:"limit"`
LatestTime int64 `json:"latest_time"` LatestTime int64 `json:"latest_time"`
} }
type ListByFollowingResponse struct { type ListByFollowingResponse struct {
VideoList []FeedVideoItem `json:"video_list"` VideoList []FeedVideoItem `json:"video_list"`
NextTime int64 `json:"next_time"` NextTime int64 `json:"next_time"`
HasMore bool `json:"has_more"` HasMore bool `json:"has_more"`
} }
type ListByPopularityRequest struct { type ListByPopularityRequest struct {
Limit int `json:"limit"` Limit int `json:"limit"`
AsOf int64 `json:"as_of"` // 服务器返回的分钟时间戳第一页传0 AsOf int64 `json:"as_of"` // 服务器返回的分钟时间戳第一页传0
Offset int `json:"offset"` // 下一页从这里开始第一页传0 Offset int `json:"offset"` // 下一页从这里开始第一页传0
LatestIDBefore *uint `json:"latest_id_before,omitempty"` LatestIDBefore *uint `json:"latest_id_before,omitempty"`
// DB fallback 用(可选) // DB fallback 用(可选)
LatestPopularity int64 `json:"latest_popularity"` LatestPopularity int64 `json:"latest_popularity"`
LatestBefore time.Time `json:"latest_before"` LatestBefore time.Time `json:"latest_before"`
} }
type ListByPopularityResponse struct { type ListByPopularityResponse struct {
VideoList []FeedVideoItem `json:"video_list"` VideoList []FeedVideoItem `json:"video_list"`
AsOf int64 `json:"as_of"` AsOf int64 `json:"as_of"`
NextOffset int `json:"next_offset"` NextOffset int `json:"next_offset"`
HasMore bool `json:"has_more"` HasMore bool `json:"has_more"`
NextLatestPopularity *int64 `json:"next_latest_popularity,omitempty"` NextLatestPopularity *int64 `json:"next_latest_popularity,omitempty"`
NextLatestBefore *time.Time `json:"next_latest_before,omitempty"` NextLatestBefore *time.Time `json:"next_latest_before,omitempty"`
NextLatestIDBefore *uint `json:"next_latest_id_before,omitempty"` NextLatestIDBefore *uint `json:"next_latest_id_before,omitempty"`
} }

View File

@@ -174,3 +174,28 @@ func nonNilFeedVideoItems(items []FeedVideoItem) []FeedVideoItem {
} }
return items return items
} }
func (h *FeedHandler) ListByTag(c *gin.Context) {
var req struct {
TagName string `json:"tag_name"`
Limit int `json:"limit"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
if req.TagName == "" {
c.JSON(400, gin.H{"error": "tag_name is required"})
return
}
if req.Limit <= 0 || req.Limit > 50 {
req.Limit = 10
}
viewerAccountID, _ := jwt.GetAccountID(c)
items, err := h.service.ListByTag(c.Request.Context(), req.TagName, req.Limit, viewerAccountID)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"video_list": nonNilFeedVideoItems(items)})
}

View File

@@ -1,103 +1,115 @@
package feed package feed
import ( import (
"context" "context"
"feedsystem_video_go/internal/social" "feedsystem_video_go/internal/social"
"feedsystem_video_go/internal/video" "feedsystem_video_go/internal/video"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
) )
type FeedRepository struct { type FeedRepository struct {
db *gorm.DB db *gorm.DB
} }
func NewFeedRepository(db *gorm.DB) *FeedRepository { func NewFeedRepository(db *gorm.DB) *FeedRepository {
return &FeedRepository{db: db} return &FeedRepository{db: db}
} }
func (repo *FeedRepository) ListLatest(ctx context.Context, limit int, latestBefore time.Time) ([]*video.Video, error) { func (repo *FeedRepository) ListLatest(ctx context.Context, limit int, latestBefore time.Time) ([]*video.Video, error) {
var videos []*video.Video var videos []*video.Video
query := repo.db.WithContext(ctx).Model(&video.Video{}). query := repo.db.WithContext(ctx).Model(&video.Video{}).
Order("create_time DESC") Order("create_time DESC")
if !latestBefore.IsZero() { if !latestBefore.IsZero() {
query = query.Where("create_time < ?", latestBefore) query = query.Where("create_time < ?", latestBefore)
} }
if err := query.Limit(limit).Find(&videos).Error; err != nil { if err := query.Limit(limit).Find(&videos).Error; err != nil {
return nil, err return nil, err
} }
return videos, nil return videos, nil
} }
func (repo *FeedRepository) ListLikesCountWithCursor(ctx context.Context, limit int, cursor *LikesCountCursor) ([]*video.Video, error) { func (repo *FeedRepository) ListLikesCountWithCursor(ctx context.Context, limit int, cursor *LikesCountCursor) ([]*video.Video, error) {
var videos []*video.Video var videos []*video.Video
query := repo.db.WithContext(ctx).Model(&video.Video{}). query := repo.db.WithContext(ctx).Model(&video.Video{}).
Order("likes_count DESC, id DESC") Order("likes_count DESC, id DESC")
if cursor != nil { if cursor != nil {
query = query.Where( query = query.Where(
"(likes_count < ?) OR (likes_count = ? AND id < ?)", "(likes_count < ?) OR (likes_count = ? AND id < ?)",
cursor.LikesCount, cursor.LikesCount,
cursor.LikesCount, cursor.ID, cursor.LikesCount, cursor.ID,
) )
} }
if err := query.Limit(limit).Find(&videos).Error; err != nil { if err := query.Limit(limit).Find(&videos).Error; err != nil {
return nil, err return nil, err
} }
return videos, nil return videos, nil
} }
func (repo *FeedRepository) ListByFollowing(ctx context.Context, limit int, viewerAccountID uint, latestBefore time.Time) ([]*video.Video, error) { func (repo *FeedRepository) ListByFollowing(ctx context.Context, limit int, viewerAccountID uint, latestBefore time.Time) ([]*video.Video, error) {
var videos []*video.Video var videos []*video.Video
query := repo.db.WithContext(ctx).Model(&video.Video{}). query := repo.db.WithContext(ctx).Model(&video.Video{}).
Order("create_time DESC") Order("create_time DESC")
if viewerAccountID > 0 { if viewerAccountID > 0 {
followingSubQuery := repo.db.WithContext(ctx). followingSubQuery := repo.db.WithContext(ctx).
Model(&social.Social{}). Model(&social.Social{}).
Select("vlogger_id"). Select("vlogger_id").
Where("follower_id = ?", viewerAccountID) Where("follower_id = ?", viewerAccountID)
query = query.Where("author_id IN (?)", followingSubQuery) query = query.Where("author_id IN (?)", followingSubQuery)
} }
if !latestBefore.IsZero() { if !latestBefore.IsZero() {
query = query.Where("create_time < ?", latestBefore) query = query.Where("create_time < ?", latestBefore)
} }
if err := query.Limit(limit).Find(&videos).Error; err != nil { if err := query.Limit(limit).Find(&videos).Error; err != nil {
return nil, err return nil, err
} }
return videos, nil return videos, nil
} }
func (repo *FeedRepository) ListByPopularity(ctx context.Context, limit int, popularityBefore int64, timeBefore time.Time, idBefore uint) ([]*video.Video, error) { func (repo *FeedRepository) ListByPopularity(ctx context.Context, limit int, popularityBefore int64, timeBefore time.Time, idBefore uint) ([]*video.Video, error) {
var videos []*video.Video var videos []*video.Video
query := repo.db.WithContext(ctx).Model(&video.Video{}). query := repo.db.WithContext(ctx).Model(&video.Video{}).
Order("popularity DESC, create_time DESC, id DESC") Order("popularity DESC, create_time DESC, id DESC")
// 只有当游标完整提供时才加过滤popularity 允许为 0 // 只有当游标完整提供时才加过滤popularity 允许为 0
if !timeBefore.IsZero() && idBefore > 0 { if !timeBefore.IsZero() && idBefore > 0 {
query = query.Where( query = query.Where(
"(popularity < ?) OR (popularity = ? AND create_time < ?) OR (popularity = ? AND create_time = ? AND id < ?)", "(popularity < ?) OR (popularity = ? AND create_time < ?) OR (popularity = ? AND create_time = ? AND id < ?)",
popularityBefore, popularityBefore,
popularityBefore, timeBefore, popularityBefore, timeBefore,
popularityBefore, timeBefore, idBefore, popularityBefore, timeBefore, idBefore,
) )
} }
if err := query.Limit(limit).Find(&videos).Error; err != nil { if err := query.Limit(limit).Find(&videos).Error; err != nil {
return nil, err return nil, err
} }
return videos, nil return videos, nil
} }
func (repo *FeedRepository) GetByIDs(ctx context.Context, ids []uint) ([]*video.Video, error) { func (repo *FeedRepository) GetByIDs(ctx context.Context, ids []uint) ([]*video.Video, error) {
var videos []*video.Video var videos []*video.Video
if len(ids) == 0 { if len(ids) == 0 {
return videos, nil return videos, nil
} }
if err := repo.db.WithContext(ctx).Model(&video.Video{}). if err := repo.db.WithContext(ctx).Model(&video.Video{}).
Where("id IN ?", ids).Find(&videos).Error; err != nil { Where("id IN ?", ids).Find(&videos).Error; err != nil {
return nil, err return nil, err
} }
return videos, nil return videos, nil
}
func (repo *FeedRepository) ListByTag(ctx context.Context, tagName string, limit int) ([]*video.Video, error) {
var videos []*video.Video
err := repo.db.WithContext(ctx).Model(&video.Video{}).Table("videos").
Joins("JOIN video_tags ON video_tags.video_id = videos.id").
Joins("JOIN tags ON tags.id = video_tags.tag_id").
Where("tags.name = ?", tagName).
Order("videos.create_time desc").
Limit(limit).
Find(&videos).Error
return videos, err
} }

View File

@@ -545,3 +545,11 @@ func buildOrderedResult(orderedIDs []uint, dataMap map[uint]*video.Video) []*vid
} }
return res return res
} }
func (f *FeedService) ListByTag(ctx context.Context, tagName string, limit int, viewerAccountID uint) ([]FeedVideoItem, error) {
videos, err := f.repo.ListByTag(ctx, tagName, limit)
if err != nil {
return nil, err
}
return f.buildFeedVideos(ctx, videos, viewerAccountID)
}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"feedsystem_video_go/internal/account" "feedsystem_video_go/internal/account"
"feedsystem_video_go/internal/feed" "feedsystem_video_go/internal/feed"
"feedsystem_video_go/internal/message"
"feedsystem_video_go/internal/middleware/jwt" "feedsystem_video_go/internal/middleware/jwt"
"feedsystem_video_go/internal/middleware/ratelimit" "feedsystem_video_go/internal/middleware/ratelimit"
"feedsystem_video_go/internal/middleware/rabbitmq" "feedsystem_video_go/internal/middleware/rabbitmq"
@@ -166,12 +167,25 @@ func SetRouter(db *gorm.DB, cache *rediscache.Client, rmq *rabbitmq.RabbitMQ) *g
feedGroup.POST("/listLatest", feedHandler.ListLatest) feedGroup.POST("/listLatest", feedHandler.ListLatest)
feedGroup.POST("/listLikesCount", feedHandler.ListLikesCount) feedGroup.POST("/listLikesCount", feedHandler.ListLikesCount)
feedGroup.POST("/listByPopularity", feedHandler.ListByPopularity) feedGroup.POST("/listByPopularity", feedHandler.ListByPopularity)
feedGroup.POST("/listByTag", feedHandler.ListByTag)
} }
protectedFeedGroup := feedGroup.Group("") protectedFeedGroup := feedGroup.Group("")
protectedFeedGroup.Use(jwt.JWTAuth(accountRepository, cache)) protectedFeedGroup.Use(jwt.JWTAuth(accountRepository, cache))
{ {
protectedFeedGroup.POST("/listByFollowing", feedHandler.ListByFollowing) protectedFeedGroup.POST("/listByFollowing", feedHandler.ListByFollowing)
} }
// message
messageRepo := message.NewRepository(db)
_ = messageRepo.AutoMigrate(context.Background())
messageService := message.NewService(messageRepo)
messageHandler := message.NewHandler(messageService)
messageGroup := r.Group("/message")
protectedMessageGroup := messageGroup.Group("")
protectedMessageGroup.Use(jwt.JWTAuth(accountRepository, cache))
{
protectedMessageGroup.POST("/send", messageHandler.Send)
protectedMessageGroup.POST("/list", messageHandler.List)
}
//worker //worker
timelineMQ, err := rabbitmq.NewTimelineMQ(rmq) timelineMQ, err := rabbitmq.NewTimelineMQ(rmq)
if err != nil { if err != nil {

View File

@@ -0,0 +1,25 @@
package message
import "time"
type Message struct {
ID uint `gorm:"primaryKey" json:"id"`
FromID uint `gorm:"index:idx_message_from;not null" json:"from_id"`
ToID uint `gorm:"index:idx_message_to;not null" json:"to_id"`
Content string `gorm:"type:text;not null" json:"content"`
IsRead bool `gorm:"default:false" json:"is_read"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
type SendRequest struct {
ToID uint `json:"to_id"`
Content string `json:"content"`
}
type ListRequest struct {
PeerID uint `json:"peer_id"`
}
type ListResponse struct {
Messages []Message `json:"messages"`
}

View File

@@ -0,0 +1,95 @@
package message
import (
"context"
"errors"
"strings"
"time"
"feedsystem_video_go/internal/apierror"
"feedsystem_video_go/internal/middleware/jwt"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type Repository struct{ db *gorm.DB }
type Service struct{ repo *Repository }
type Handler struct{ service *Service }
func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} }
func NewService(repo *Repository) *Service { return &Service{repo: repo} }
func NewHandler(service *Service) *Handler { return &Handler{service: service} }
func (r *Repository) AutoMigrate(ctx context.Context) error {
return r.db.WithContext(ctx).AutoMigrate(&Message{})
}
func (r *Repository) Send(ctx context.Context, m *Message) error {
m.Content = strings.TrimSpace(m.Content)
if m.Content == "" {
return errors.New("content is required")
}
m.CreatedAt = time.Now()
return r.db.WithContext(ctx).Create(m).Error
}
func (r *Repository) List(ctx context.Context, userID, peerID uint, limit int) ([]Message, error) {
var msgs []Message
err := r.db.WithContext(ctx).
Where("(from_id = ? AND to_id = ?) OR (from_id = ? AND to_id = ?)", userID, peerID, peerID, userID).
Order("created_at desc").
Limit(limit).
Find(&msgs).Error
return msgs, err
}
func (h *Handler) Send(c *gin.Context) {
fromID, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
var req SendRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.ToID == 0 || strings.TrimSpace(req.Content) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "to_id and content are required"})
return
}
m := &Message{FromID: fromID, ToID: req.ToID, Content: req.Content}
if err := h.service.repo.Send(c.Request.Context(), m); err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, m)
}
func (h *Handler) List(c *gin.Context) {
userID, err := jwt.GetAccountID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
var req ListRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.PeerID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "peer_id is required"})
return
}
msgs, err := h.service.repo.List(c.Request.Context(), userID, req.PeerID, 50)
if err != nil {
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
return
}
if msgs == nil {
msgs = []Message{}
}
c.JSON(http.StatusOK, ListResponse{Messages: msgs})
}

View File

@@ -6,6 +6,7 @@ import (
"feedsystem_video_go/internal/middleware/rabbitmq" "feedsystem_video_go/internal/middleware/rabbitmq"
rediscache "feedsystem_video_go/internal/middleware/redis" rediscache "feedsystem_video_go/internal/middleware/redis"
"feedsystem_video_go/internal/apierror" "feedsystem_video_go/internal/apierror"
"regexp"
"strings" "strings"
"gorm.io/gorm" "gorm.io/gorm"
@@ -57,6 +58,7 @@ func (s *CommentService) Publish(ctx context.Context, comment *Comment) error {
} }
} }
if mysqlEnqueued && redisEnqueued { if mysqlEnqueued && redisEnqueued {
s.notifyMentions(ctx, comment)
return nil return nil
} }
@@ -83,6 +85,7 @@ func (s *CommentService) Publish(ctx context.Context, comment *Comment) error {
if !redisEnqueued { if !redisEnqueued {
UpdatePopularityCache(ctx, s.cache, comment.VideoID, 1) UpdatePopularityCache(ctx, s.cache, comment.VideoID, 1)
} }
s.notifyMentions(ctx, comment)
return nil return nil
} }
@@ -115,3 +118,38 @@ func (s *CommentService) GetAll(ctx context.Context, videoID uint) ([]Comment, e
} }
return s.repo.GetAllComments(ctx, videoID) return s.repo.GetAllComments(ctx, videoID)
} }
var mentionRegex = regexp.MustCompile(`@(\w+)`)
func (s *CommentService) notifyMentions(ctx context.Context, comment *Comment) {
matches := mentionRegex.FindAllStringSubmatch(comment.Content, -1)
if len(matches) == 0 {
return
}
seen := make(map[string]bool)
for _, m := range matches {
username := m[1]
if seen[username] || username == comment.Username {
continue
}
seen[username] = true
var accID uint
if err := s.repo.db.WithContext(ctx).Table("accounts").Where("username = ?", username).Select("id").Scan(&accID).Error; err != nil || accID == 0 {
continue
}
notif := struct {
RecipientID uint
SenderID uint
Type string
TargetID uint
Content string
}{
RecipientID: accID,
SenderID: comment.AuthorID,
Type: "mention",
TargetID: comment.VideoID,
Content: comment.Username + " 在评论中提到了你",
}
s.repo.db.WithContext(ctx).Table("notifications").Create(&notif)
}
}

View File

@@ -0,0 +1,30 @@
package video
import "regexp"
type Tag struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"uniqueIndex;type:varchar(100);not null" json:"name"`
}
type VideoTag struct {
ID uint `gorm:"primaryKey"`
VideoID uint `gorm:"index;not null"`
TagID uint `gorm:"index;not null"`
}
var tagRegex = regexp.MustCompile(`#([\p{L}\p{N}_]+)`)
func ExtractTags(text string) []string {
matches := tagRegex.FindAllStringSubmatch(text, -1)
seen := make(map[string]bool)
var tags []string
for _, m := range matches {
tag := m[1]
if !seen[tag] {
seen[tag] = true
tags = append(tags, tag)
}
}
return tags
}

View File

@@ -60,8 +60,14 @@ func (vs *VideoService) Publish(ctx context.Context, video *Video) error {
if err := tx.Create(&msg).Error; err != nil { if err := tx.Create(&msg).Error; err != nil {
return err return err
} }
return nil
tags := ExtractTags(video.Title + " " + video.Description)
for _, tagName := range tags {
var tag Tag
tx.Where("name = ?", tagName).FirstOrCreate(&tag, Tag{Name: tagName})
tx.Create(&VideoTag{VideoID: video.ID, TagID: tag.ID})
}
return nil
}) })
return err return err