diff --git a/backend/internal/feed/entity.go b/backend/internal/feed/entity.go index 808c218..bd0f921 100644 --- a/backend/internal/feed/entity.go +++ b/backend/internal/feed/entity.go @@ -1,82 +1,82 @@ -package feed - -import "time" - -type FeedAuthor struct { - ID uint `json:"id"` - Username string `json:"username"` -} - -type FeedVideoItem struct { - ID uint `json:"id"` - Author FeedAuthor `json:"author"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - PlayURL string `json:"play_url"` - CoverURL string `json:"cover_url"` - CreateTime int64 `json:"create_time"` - LikesCount int64 `json:"likes_count"` - IsLiked bool `json:"is_liked"` -} - -type ListLatestRequest struct { - Limit int `json:"limit"` - LatestTime int64 `json:"latest_time"` -} - -type ListLatestResponse struct { - VideoList []FeedVideoItem `json:"video_list"` - NextTime int64 `json:"next_time"` - HasMore bool `json:"has_more"` -} - -type ListLikesCountRequest struct { - Limit int `json:"limit"` - LikesCountBefore *int64 `json:"likes_count_before,omitempty"` - IDBefore *uint `json:"id_before,omitempty"` -} - -type LikesCountCursor struct { - LikesCount int64 - ID uint -} - -type ListLikesCountResponse struct { - VideoList []FeedVideoItem `json:"video_list"` - NextLikesCountBefore *int64 `json:"next_likes_count_before,omitempty"` - NextIDBefore *uint `json:"next_id_before,omitempty"` - HasMore bool `json:"has_more"` -} - -type ListByFollowingRequest struct { - Limit int `json:"limit"` - LatestTime int64 `json:"latest_time"` -} - -type ListByFollowingResponse struct { - VideoList []FeedVideoItem `json:"video_list"` - NextTime int64 `json:"next_time"` - HasMore bool `json:"has_more"` -} - -type ListByPopularityRequest struct { - Limit int `json:"limit"` - AsOf int64 `json:"as_of"` // 服务器返回的分钟时间戳;第一页传0 - Offset int `json:"offset"` // 下一页从这里开始;第一页传0 - LatestIDBefore *uint `json:"latest_id_before,omitempty"` - - // DB fallback 用(可选) - LatestPopularity int64 `json:"latest_popularity"` - LatestBefore time.Time `json:"latest_before"` -} - -type ListByPopularityResponse struct { - VideoList []FeedVideoItem `json:"video_list"` - AsOf int64 `json:"as_of"` - NextOffset int `json:"next_offset"` - HasMore bool `json:"has_more"` - - NextLatestPopularity *int64 `json:"next_latest_popularity,omitempty"` - NextLatestBefore *time.Time `json:"next_latest_before,omitempty"` - NextLatestIDBefore *uint `json:"next_latest_id_before,omitempty"` -} +package feed + +import "time" + +type FeedAuthor struct { + ID uint `json:"id"` + Username string `json:"username"` +} + +type FeedVideoItem struct { + ID uint `json:"id"` + Author FeedAuthor `json:"author"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + PlayURL string `json:"play_url"` + CoverURL string `json:"cover_url"` + CreateTime int64 `json:"create_time"` + LikesCount int64 `json:"likes_count"` + IsLiked bool `json:"is_liked"` +} + +type ListLatestRequest struct { + Limit int `json:"limit"` + LatestTime int64 `json:"latest_time"` +} + +type ListLatestResponse struct { + VideoList []FeedVideoItem `json:"video_list"` + NextTime int64 `json:"next_time"` + HasMore bool `json:"has_more"` +} + +type ListLikesCountRequest struct { + Limit int `json:"limit"` + LikesCountBefore *int64 `json:"likes_count_before,omitempty"` + IDBefore *uint `json:"id_before,omitempty"` +} + +type LikesCountCursor struct { + LikesCount int64 + ID uint +} + +type ListLikesCountResponse struct { + VideoList []FeedVideoItem `json:"video_list"` + NextLikesCountBefore *int64 `json:"next_likes_count_before,omitempty"` + NextIDBefore *uint `json:"next_id_before,omitempty"` + HasMore bool `json:"has_more"` +} + +type ListByFollowingRequest struct { + Limit int `json:"limit"` + LatestTime int64 `json:"latest_time"` +} + +type ListByFollowingResponse struct { + VideoList []FeedVideoItem `json:"video_list"` + NextTime int64 `json:"next_time"` + HasMore bool `json:"has_more"` +} + +type ListByPopularityRequest struct { + Limit int `json:"limit"` + AsOf int64 `json:"as_of"` // 服务器返回的分钟时间戳;第一页传0 + Offset int `json:"offset"` // 下一页从这里开始;第一页传0 + LatestIDBefore *uint `json:"latest_id_before,omitempty"` + + // DB fallback 用(可选) + LatestPopularity int64 `json:"latest_popularity"` + LatestBefore time.Time `json:"latest_before"` +} + +type ListByPopularityResponse struct { + VideoList []FeedVideoItem `json:"video_list"` + AsOf int64 `json:"as_of"` + NextOffset int `json:"next_offset"` + HasMore bool `json:"has_more"` + + NextLatestPopularity *int64 `json:"next_latest_popularity,omitempty"` + NextLatestBefore *time.Time `json:"next_latest_before,omitempty"` + NextLatestIDBefore *uint `json:"next_latest_id_before,omitempty"` +} diff --git a/backend/internal/feed/handler.go b/backend/internal/feed/handler.go index dd84402..efb2789 100644 --- a/backend/internal/feed/handler.go +++ b/backend/internal/feed/handler.go @@ -174,3 +174,28 @@ func nonNilFeedVideoItems(items []FeedVideoItem) []FeedVideoItem { } 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)}) +} diff --git a/backend/internal/feed/repo.go b/backend/internal/feed/repo.go index 1143f3c..129cc0e 100644 --- a/backend/internal/feed/repo.go +++ b/backend/internal/feed/repo.go @@ -1,103 +1,115 @@ -package feed - -import ( - "context" - "feedsystem_video_go/internal/social" - "feedsystem_video_go/internal/video" - "time" - - "gorm.io/gorm" -) - -type FeedRepository struct { - db *gorm.DB -} - -func NewFeedRepository(db *gorm.DB) *FeedRepository { - return &FeedRepository{db: db} -} - -func (repo *FeedRepository) ListLatest(ctx context.Context, limit int, latestBefore time.Time) ([]*video.Video, error) { - var videos []*video.Video - query := repo.db.WithContext(ctx).Model(&video.Video{}). - Order("create_time DESC") - if !latestBefore.IsZero() { - query = query.Where("create_time < ?", latestBefore) - } - if err := query.Limit(limit).Find(&videos).Error; err != nil { - return nil, err - } - return videos, nil -} - -func (repo *FeedRepository) ListLikesCountWithCursor(ctx context.Context, limit int, cursor *LikesCountCursor) ([]*video.Video, error) { - var videos []*video.Video - query := repo.db.WithContext(ctx).Model(&video.Video{}). - Order("likes_count DESC, id DESC") - - if cursor != nil { - query = query.Where( - "(likes_count < ?) OR (likes_count = ? AND id < ?)", - cursor.LikesCount, - cursor.LikesCount, cursor.ID, - ) - } - - if err := query.Limit(limit).Find(&videos).Error; err != nil { - return nil, err - } - return videos, nil -} - -func (repo *FeedRepository) ListByFollowing(ctx context.Context, limit int, viewerAccountID uint, latestBefore time.Time) ([]*video.Video, error) { - var videos []*video.Video - query := repo.db.WithContext(ctx).Model(&video.Video{}). - Order("create_time DESC") - if viewerAccountID > 0 { - followingSubQuery := repo.db.WithContext(ctx). - Model(&social.Social{}). - Select("vlogger_id"). - Where("follower_id = ?", viewerAccountID) - query = query.Where("author_id IN (?)", followingSubQuery) - } - if !latestBefore.IsZero() { - query = query.Where("create_time < ?", latestBefore) - } - if err := query.Limit(limit).Find(&videos).Error; err != nil { - return nil, err - } - return videos, nil -} - -func (repo *FeedRepository) ListByPopularity(ctx context.Context, limit int, popularityBefore int64, timeBefore time.Time, idBefore uint) ([]*video.Video, error) { - var videos []*video.Video - query := repo.db.WithContext(ctx).Model(&video.Video{}). - Order("popularity DESC, create_time DESC, id DESC") - - // 只有当游标完整提供时才加过滤(popularity 允许为 0) - if !timeBefore.IsZero() && idBefore > 0 { - query = query.Where( - "(popularity < ?) OR (popularity = ? AND create_time < ?) OR (popularity = ? AND create_time = ? AND id < ?)", - popularityBefore, - popularityBefore, timeBefore, - popularityBefore, timeBefore, idBefore, - ) - } - - if err := query.Limit(limit).Find(&videos).Error; err != nil { - return nil, err - } - return videos, nil -} - -func (repo *FeedRepository) GetByIDs(ctx context.Context, ids []uint) ([]*video.Video, error) { - var videos []*video.Video - if len(ids) == 0 { - return videos, nil - } - if err := repo.db.WithContext(ctx).Model(&video.Video{}). - Where("id IN ?", ids).Find(&videos).Error; err != nil { - return nil, err - } - return videos, nil +package feed + +import ( + "context" + "feedsystem_video_go/internal/social" + "feedsystem_video_go/internal/video" + "time" + + "gorm.io/gorm" +) + +type FeedRepository struct { + db *gorm.DB +} + +func NewFeedRepository(db *gorm.DB) *FeedRepository { + return &FeedRepository{db: db} +} + +func (repo *FeedRepository) ListLatest(ctx context.Context, limit int, latestBefore time.Time) ([]*video.Video, error) { + var videos []*video.Video + query := repo.db.WithContext(ctx).Model(&video.Video{}). + Order("create_time DESC") + if !latestBefore.IsZero() { + query = query.Where("create_time < ?", latestBefore) + } + if err := query.Limit(limit).Find(&videos).Error; err != nil { + return nil, err + } + return videos, nil +} + +func (repo *FeedRepository) ListLikesCountWithCursor(ctx context.Context, limit int, cursor *LikesCountCursor) ([]*video.Video, error) { + var videos []*video.Video + query := repo.db.WithContext(ctx).Model(&video.Video{}). + Order("likes_count DESC, id DESC") + + if cursor != nil { + query = query.Where( + "(likes_count < ?) OR (likes_count = ? AND id < ?)", + cursor.LikesCount, + cursor.LikesCount, cursor.ID, + ) + } + + if err := query.Limit(limit).Find(&videos).Error; err != nil { + return nil, err + } + return videos, nil +} + +func (repo *FeedRepository) ListByFollowing(ctx context.Context, limit int, viewerAccountID uint, latestBefore time.Time) ([]*video.Video, error) { + var videos []*video.Video + query := repo.db.WithContext(ctx).Model(&video.Video{}). + Order("create_time DESC") + if viewerAccountID > 0 { + followingSubQuery := repo.db.WithContext(ctx). + Model(&social.Social{}). + Select("vlogger_id"). + Where("follower_id = ?", viewerAccountID) + query = query.Where("author_id IN (?)", followingSubQuery) + } + if !latestBefore.IsZero() { + query = query.Where("create_time < ?", latestBefore) + } + if err := query.Limit(limit).Find(&videos).Error; err != nil { + return nil, err + } + return videos, nil +} + +func (repo *FeedRepository) ListByPopularity(ctx context.Context, limit int, popularityBefore int64, timeBefore time.Time, idBefore uint) ([]*video.Video, error) { + var videos []*video.Video + query := repo.db.WithContext(ctx).Model(&video.Video{}). + Order("popularity DESC, create_time DESC, id DESC") + + // 只有当游标完整提供时才加过滤(popularity 允许为 0) + if !timeBefore.IsZero() && idBefore > 0 { + query = query.Where( + "(popularity < ?) OR (popularity = ? AND create_time < ?) OR (popularity = ? AND create_time = ? AND id < ?)", + popularityBefore, + popularityBefore, timeBefore, + popularityBefore, timeBefore, idBefore, + ) + } + + if err := query.Limit(limit).Find(&videos).Error; err != nil { + return nil, err + } + return videos, nil +} + +func (repo *FeedRepository) GetByIDs(ctx context.Context, ids []uint) ([]*video.Video, error) { + var videos []*video.Video + if len(ids) == 0 { + return videos, nil + } + if err := repo.db.WithContext(ctx).Model(&video.Video{}). + Where("id IN ?", ids).Find(&videos).Error; err != nil { + return nil, err + } + 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 } diff --git a/backend/internal/feed/service.go b/backend/internal/feed/service.go index 4c62032..f539c45 100644 --- a/backend/internal/feed/service.go +++ b/backend/internal/feed/service.go @@ -545,3 +545,11 @@ func buildOrderedResult(orderedIDs []uint, dataMap map[uint]*video.Video) []*vid } 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) +} diff --git a/backend/internal/http/router.go b/backend/internal/http/router.go index f982706..8decf12 100644 --- a/backend/internal/http/router.go +++ b/backend/internal/http/router.go @@ -4,6 +4,7 @@ import ( "context" "feedsystem_video_go/internal/account" "feedsystem_video_go/internal/feed" + "feedsystem_video_go/internal/message" "feedsystem_video_go/internal/middleware/jwt" "feedsystem_video_go/internal/middleware/ratelimit" "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("/listLikesCount", feedHandler.ListLikesCount) feedGroup.POST("/listByPopularity", feedHandler.ListByPopularity) + feedGroup.POST("/listByTag", feedHandler.ListByTag) } protectedFeedGroup := feedGroup.Group("") protectedFeedGroup.Use(jwt.JWTAuth(accountRepository, cache)) { 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 timelineMQ, err := rabbitmq.NewTimelineMQ(rmq) if err != nil { diff --git a/backend/internal/message/entity.go b/backend/internal/message/entity.go new file mode 100644 index 0000000..1d90bcb --- /dev/null +++ b/backend/internal/message/entity.go @@ -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"` +} diff --git a/backend/internal/message/handler.go b/backend/internal/message/handler.go new file mode 100644 index 0000000..c7bb291 --- /dev/null +++ b/backend/internal/message/handler.go @@ -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}) +} diff --git a/backend/internal/video/comment_service.go b/backend/internal/video/comment_service.go index f09a6c8..d55587a 100644 --- a/backend/internal/video/comment_service.go +++ b/backend/internal/video/comment_service.go @@ -6,6 +6,7 @@ import ( "feedsystem_video_go/internal/middleware/rabbitmq" rediscache "feedsystem_video_go/internal/middleware/redis" "feedsystem_video_go/internal/apierror" + "regexp" "strings" "gorm.io/gorm" @@ -57,6 +58,7 @@ func (s *CommentService) Publish(ctx context.Context, comment *Comment) error { } } if mysqlEnqueued && redisEnqueued { + s.notifyMentions(ctx, comment) return nil } @@ -83,6 +85,7 @@ func (s *CommentService) Publish(ctx context.Context, comment *Comment) error { if !redisEnqueued { UpdatePopularityCache(ctx, s.cache, comment.VideoID, 1) } + s.notifyMentions(ctx, comment) return nil } @@ -115,3 +118,38 @@ func (s *CommentService) GetAll(ctx context.Context, videoID uint) ([]Comment, e } 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(¬if) + } +} diff --git a/backend/internal/video/tag_entity.go b/backend/internal/video/tag_entity.go new file mode 100644 index 0000000..0dc0a54 --- /dev/null +++ b/backend/internal/video/tag_entity.go @@ -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 +} diff --git a/backend/internal/video/video_service.go b/backend/internal/video/video_service.go index 85df436..ea35550 100644 --- a/backend/internal/video/video_service.go +++ b/backend/internal/video/video_service.go @@ -60,8 +60,14 @@ func (vs *VideoService) Publish(ctx context.Context, video *Video) error { if err := tx.Create(&msg).Error; err != nil { 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