From 328fae5f07fc97949775b1a2901016591d8bf12c Mon Sep 17 00:00:00 2001 From: Leon <147289645+LeoninCS@users.noreply.github.com> Date: Fri, 26 Dec 2025 22:49:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=B7=BB=E5=8A=A0=E7=83=AD=E6=A6=9C?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=B9=B6=E6=B7=BB=E5=8A=A0=E4=BA=86=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E7=83=AD=E5=BA=A6=E7=9A=84=E6=96=B9=E6=B3=95=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/feed/entity.go | 24 ++++ backend/internal/feed/handler.go | 50 ++++++++ backend/internal/feed/repo.go | 33 ++++++ backend/internal/feed/service.go | 109 ++++++++++++++++++ backend/internal/http/router.go | 5 +- backend/internal/redis/{cache.go => redis.go} | 51 ++++++++ backend/internal/video/comment_handler.go | 10 +- backend/internal/video/like_entity.go | 4 - backend/internal/video/like_handler.go | 17 ++- backend/internal/video/video_entity.go | 1 + backend/internal/video/video_repo.go | 9 ++ backend/internal/video/video_service.go | 24 ++++ 12 files changed, 327 insertions(+), 10 deletions(-) rename backend/internal/redis/{cache.go => redis.go} (60%) diff --git a/backend/internal/feed/entity.go b/backend/internal/feed/entity.go index 56f0855..808c218 100644 --- a/backend/internal/feed/entity.go +++ b/backend/internal/feed/entity.go @@ -1,5 +1,7 @@ package feed +import "time" + type FeedAuthor struct { ID uint `json:"id"` Username string `json:"username"` @@ -56,3 +58,25 @@ type ListByFollowingResponse struct { 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 d440891..f081fb2 100644 --- a/backend/internal/feed/handler.go +++ b/backend/internal/feed/handler.go @@ -112,3 +112,53 @@ func (f *FeedHandler) ListByFollowing(c *gin.Context) { } c.JSON(200, feedItems) } + +func (f *FeedHandler) ListByPopularity(c *gin.Context) { + var req ListByPopularityRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + if req.Limit <= 0 || req.Limit > 50 { + req.Limit = 10 + } + viewerAccountID, err := middleware.GetAccountID(c) + if err != nil { + viewerAccountID = 0 + } + + var latestPopularity int64 + var latestBefore time.Time + var latestIDBefore uint + + if req.LatestPopularity < 0 { + c.JSON(400, gin.H{"error": "latest_popularity must be >= 0"}) + return + } + + anyCursor := !req.LatestBefore.IsZero() || req.LatestIDBefore != nil + if anyCursor { + if req.LatestBefore.IsZero() || req.LatestIDBefore == nil || *req.LatestIDBefore == 0 { + c.JSON(400, gin.H{"error": "latest_before and latest_id_before must be provided together"}) + return + } + latestPopularity = req.LatestPopularity + latestBefore = req.LatestBefore + latestIDBefore = *req.LatestIDBefore + } + resp, err := f.service.ListByPopularity( + c.Request.Context(), + req.Limit, + req.AsOf, + req.Offset, + viewerAccountID, + latestPopularity, + latestBefore, + latestIDBefore, + ) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, resp) +} diff --git a/backend/internal/feed/repo.go b/backend/internal/feed/repo.go index 10f81ee..1143f3c 100644 --- a/backend/internal/feed/repo.go +++ b/backend/internal/feed/repo.go @@ -68,3 +68,36 @@ func (repo *FeedRepository) ListByFollowing(ctx context.Context, limit int, view } 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 +} diff --git a/backend/internal/feed/service.go b/backend/internal/feed/service.go index 4408a0f..349a617 100644 --- a/backend/internal/feed/service.go +++ b/backend/internal/feed/service.go @@ -6,6 +6,7 @@ import ( rediscache "feedsystem_video_go/internal/redis" "feedsystem_video_go/internal/video" "fmt" + "strconv" "time" ) @@ -229,6 +230,114 @@ func (f *FeedService) ListByFollowing(ctx context.Context, limit int, latestBefo return resp, nil } +func (f *FeedService) ListByPopularity(ctx context.Context, limit int, reqAsOf int64, offset int, viewerAccountID uint, latestPopularity int64, latestBefore time.Time, latestIDBefore uint) (ListByPopularityResponse, error) { + // Redis 热榜(稳定分页:as_of + offset) + if f.cache != nil { + asOf := time.Now().UTC().Truncate(time.Minute) + if reqAsOf > 0 { + asOf = time.Unix(reqAsOf, 0).UTC().Truncate(time.Minute) + } + + const win = 60 + keys := make([]string, 0, win) + for i := 0; i < win; i++ { + keys = append(keys, "hot:video:1m:"+asOf.Add(-time.Duration(i)*time.Minute).Format("200601021504")) + } + + dest := "hot:video:merge:1m:" + asOf.Format("200601021504") // 快照key:同一个as_of页内复用 + opCtx, cancel := context.WithTimeout(ctx, 80*time.Millisecond) + defer cancel() + + exists, _ := f.cache.Exists(opCtx, dest) + if !exists { + _ = f.cache.ZUnionStore(opCtx, dest, keys, "SUM") + _ = f.cache.Expire(opCtx, dest, 2*time.Minute) // 给翻页留时间 + } + + start := int64(offset) + stop := start + int64(limit) - 1 + members, err := f.cache.ZRevRange(opCtx, dest, start, stop) + if err == nil && len(members) == 0 { + if offset > 0 { + return ListByPopularityResponse{ + VideoList: []FeedVideoItem{}, + AsOf: asOf.Unix(), + NextOffset: offset, + HasMore: false, + }, nil + } + } + if err == nil && len(members) > 0 { + ids := make([]uint, 0, len(members)) + for _, m := range members { + u, err := strconv.ParseUint(m, 10, 64) + if err == nil && u > 0 { + ids = append(ids, uint(u)) + } + } + + videos, err := f.repo.GetByIDs(ctx, ids) + if err == nil { + byID := make(map[uint]*video.Video, len(videos)) + for _, v := range videos { + byID[v.ID] = v + } + ordered := make([]*video.Video, 0, len(ids)) + for _, id := range ids { + if v := byID[id]; v != nil { + ordered = append(ordered, v) + } + } + items, err := f.buildFeedVideos(ctx, ordered, viewerAccountID) + if err != nil { + return ListByPopularityResponse{}, err + } + resp := ListByPopularityResponse{ + VideoList: items, + AsOf: asOf.Unix(), + NextOffset: offset + len(items), + HasMore: len(items) == limit, + } + if len(ordered) > 0 { + last := ordered[len(ordered)-1] + nextPopularity := last.Popularity + nextBefore := last.CreateTime + nextID := last.ID + resp.NextLatestPopularity = &nextPopularity + resp.NextLatestBefore = &nextBefore + resp.NextLatestIDBefore = &nextID + } + return resp, nil + } + } + } + + videos, err := f.repo.ListByPopularity(ctx, limit, latestPopularity, latestBefore, latestIDBefore) + if err != nil { + return ListByPopularityResponse{}, err + } + items, err := f.buildFeedVideos(ctx, videos, viewerAccountID) + if err != nil { + return ListByPopularityResponse{}, err + } + resp := ListByPopularityResponse{ + VideoList: items, + AsOf: 0, + NextOffset: 0, + HasMore: len(items) == limit, + } + if len(videos) > 0 { + last := videos[len(videos)-1] + nextPopularity := last.Popularity + nextBefore := last.CreateTime + nextID := last.ID + resp.NextLatestPopularity = &nextPopularity + resp.NextLatestBefore = &nextBefore + resp.NextLatestIDBefore = &nextID + } + return resp, nil +} + func (f *FeedService) buildFeedVideos(ctx context.Context, videos []*video.Video, viewerAccountID uint) ([]FeedVideoItem, error) { feedVideos := make([]FeedVideoItem, 0, len(videos)) videoIDs := make([]uint, len(videos)) diff --git a/backend/internal/http/router.go b/backend/internal/http/router.go index baf8449..7c71fc8 100644 --- a/backend/internal/http/router.go +++ b/backend/internal/http/router.go @@ -52,7 +52,7 @@ func SetRouter(db *gorm.DB, cache *rediscache.Client) *gin.Engine { // like likeRepository := video.NewLikeRepository(db) likeService := video.NewLikeService(likeRepository, videoRepository) - likeHandler := video.NewLikeHandler(likeService) + likeHandler := video.NewLikeHandler(likeService, videoService) likeGroup := r.Group("/like") protectedLikeGroup := likeGroup.Group("") protectedLikeGroup.Use(middleware.JWTAuth(accountRepository, cache)) @@ -65,7 +65,7 @@ func SetRouter(db *gorm.DB, cache *rediscache.Client) *gin.Engine { // comment commentRepository := video.NewCommentRepository(db) commentService := video.NewCommentService(commentRepository, videoRepository) - commentHandler := video.NewCommentHandler(commentService, accountService) + commentHandler := video.NewCommentHandler(commentService, accountService, videoService) commentGroup := r.Group("/comment") { commentGroup.POST("/listAll", commentHandler.GetAllComments) @@ -98,6 +98,7 @@ func SetRouter(db *gorm.DB, cache *rediscache.Client) *gin.Engine { { feedGroup.POST("/listLatest", feedHandler.ListLatest) feedGroup.POST("/listLikesCount", feedHandler.ListLikesCount) + feedGroup.POST("/listByPopularity", feedHandler.ListByPopularity) } protectedFeedGroup := feedGroup.Group("") protectedFeedGroup.Use(middleware.JWTAuth(accountRepository, cache)) diff --git a/backend/internal/redis/cache.go b/backend/internal/redis/redis.go similarity index 60% rename from backend/internal/redis/cache.go rename to backend/internal/redis/redis.go index c9f6190..01d941b 100644 --- a/backend/internal/redis/cache.go +++ b/backend/internal/redis/redis.go @@ -103,3 +103,54 @@ func (c *Client) Unlock(ctx context.Context, key string, token string) error { _, err := unlockScript.Run(ctx, c.rdb, []string{key}, token).Result() return err } + +func (c *Client) ZincrBy(ctx context.Context, key string, member string, score float64) error { + if c == nil || c.rdb == nil { + return nil + } + return c.rdb.ZIncrBy(ctx, key, score, member).Err() +} + +func (c *Client) Expire(ctx context.Context, key string, ttl time.Duration) error { + if c == nil || c.rdb == nil { + return nil + } + return c.rdb.Expire(ctx, key, ttl).Err() +} + +func (c *Client) ZUnionStore(ctx context.Context, dst string, keys []string, aggregate string) error { + if c == nil || c.rdb == nil { + return nil + } + return c.rdb.ZUnionStore(ctx, dst, &redis.ZStore{ + Keys: keys, + Aggregate: aggregate, + }).Err() +} + +func (c *Client) Exists(ctx context.Context, key string) (bool, error) { + if c == nil || c.rdb == nil { + return false, nil + } + n, err := c.rdb.Exists(ctx, key).Result() + return n > 0, err +} + +func (c *Client) ZRevRange(ctx context.Context, key string, start, stop int64) ([]string, error) { + if c == nil || c.rdb == nil { + return nil, nil + } + return c.rdb.ZRevRange(ctx, key, start, stop).Result() +} + +func (c *Client) ZRevRangeByScore(ctx context.Context, key string, max, min string, offset, count int64) ([]string, error) { + if c == nil || c.rdb == nil { + return nil, nil + } + return c.rdb.ZRevRangeByScore(ctx, key, &redis.ZRangeBy{ + Max: max, + Min: min, + Offset: offset, + Count: count, + }).Result() +} diff --git a/backend/internal/video/comment_handler.go b/backend/internal/video/comment_handler.go index 2bf872a..7801856 100644 --- a/backend/internal/video/comment_handler.go +++ b/backend/internal/video/comment_handler.go @@ -10,10 +10,11 @@ import ( type CommentHandler struct { service *CommentService accountService *account.AccountService + videoService *VideoService } -func NewCommentHandler(service *CommentService, accountService *account.AccountService) *CommentHandler { - return &CommentHandler{service: service, accountService: accountService} +func NewCommentHandler(service *CommentService, accountService *account.AccountService, videoService *VideoService) *CommentHandler { + return &CommentHandler{service: service, accountService: accountService, videoService: videoService} } func (h *CommentHandler) PublishComment(c *gin.Context) { var req PublishCommentRequest @@ -49,6 +50,10 @@ func (h *CommentHandler) PublishComment(c *gin.Context) { c.JSON(400, gin.H{"error": err.Error()}) return } + if err := h.videoService.UpdatePopularity(c.Request.Context(), req.VideoID, 1); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } c.JSON(200, gin.H{"message": "comment published successfully"}) } @@ -71,6 +76,7 @@ func (h *CommentHandler) DeleteComment(c *gin.Context) { c.JSON(400, gin.H{"error": err.Error()}) return } + c.JSON(200, gin.H{"message": "comment deleted successfully"}) } diff --git a/backend/internal/video/like_entity.go b/backend/internal/video/like_entity.go index 9e2c3f6..c1d1ff2 100644 --- a/backend/internal/video/like_entity.go +++ b/backend/internal/video/like_entity.go @@ -12,7 +12,3 @@ type Like struct { type LikeRequest struct { VideoID uint `json:"video_id"` } - -type LikeHandler struct { - service *LikeService -} diff --git a/backend/internal/video/like_handler.go b/backend/internal/video/like_handler.go index e32b328..cb9f2d1 100644 --- a/backend/internal/video/like_handler.go +++ b/backend/internal/video/like_handler.go @@ -6,8 +6,13 @@ import ( "github.com/gin-gonic/gin" ) -func NewLikeHandler(service *LikeService) *LikeHandler { - return &LikeHandler{service: service} +type LikeHandler struct { + service *LikeService + videoService *VideoService +} + +func NewLikeHandler(service *LikeService, videoService *VideoService) *LikeHandler { + return &LikeHandler{service: service, videoService: videoService} } func (lh *LikeHandler) Like(c *gin.Context) { @@ -35,6 +40,10 @@ func (lh *LikeHandler) Like(c *gin.Context) { c.JSON(500, gin.H{"error": err.Error()}) return } + if err := lh.videoService.UpdatePopularity(c.Request.Context(), req.VideoID, 1); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } c.JSON(200, gin.H{"message": "like success"}) } @@ -63,6 +72,10 @@ func (lh *LikeHandler) Unlike(c *gin.Context) { c.JSON(500, gin.H{"error": err.Error()}) return } + if err := lh.videoService.UpdatePopularity(c.Request.Context(), req.VideoID, -1); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } c.JSON(200, gin.H{"message": "unlike success"}) } diff --git a/backend/internal/video/video_entity.go b/backend/internal/video/video_entity.go index d3b19b0..9259540 100644 --- a/backend/internal/video/video_entity.go +++ b/backend/internal/video/video_entity.go @@ -12,6 +12,7 @@ type Video struct { CoverURL string `gorm:"type:varchar(255);not null" json:"cover_url"` CreateTime time.Time `gorm:"autoCreateTime" json:"create_time"` LikesCount int64 `gorm:"column:likes_count;not null;default:0" json:"likes_count"` + Popularity int64 `gorm:"column:popularity;not null;default:0" json:"popularity"` } type PublishVideoRequest struct { diff --git a/backend/internal/video/video_repo.go b/backend/internal/video/video_repo.go index 6c1bac9..fa0046f 100644 --- a/backend/internal/video/video_repo.go +++ b/backend/internal/video/video_repo.go @@ -68,3 +68,12 @@ func (vr *VideoRepository) IsExist(ctx context.Context, id uint) (bool, error) { } return true, nil } + +func (vr *VideoRepository) UpdatePopularity(ctx context.Context, id uint, change int64) error { + if err := vr.db.WithContext(ctx).Model(&Video{}). + Where("id = ?", id). + Update("popularity", gorm.Expr("popularity + ?", change)).Error; err != nil { + return err + } + return nil +} diff --git a/backend/internal/video/video_service.go b/backend/internal/video/video_service.go index c8f0b06..de74dc7 100644 --- a/backend/internal/video/video_service.go +++ b/backend/internal/video/video_service.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strconv" "strings" "time" @@ -166,3 +167,26 @@ func (vs *VideoService) UpdateLikesCount(ctx context.Context, id uint, likesCoun } return nil } + +func (vs *VideoService) UpdatePopularity(ctx context.Context, id uint, change int64) error { + if err := vs.repo.UpdatePopularity(ctx, id, change); err != nil { + return err + } + + if vs.cache != nil { + // 1) 详情缓存:直接失效(最简单靠谱) + _ = vs.cache.Del(context.Background(), fmt.Sprintf("video:detail:id=%d", id)) + + // 2) 热榜:写到“时间窗ZSET”,不要用 detail key + now := time.Now().UTC().Truncate(time.Minute) + windowKey := "hot:video:1m:" + now.Format("200601021504") + member := strconv.FormatUint(uint64(id), 10) + + opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancel() + + _ = vs.cache.ZincrBy(opCtx, windowKey, member, float64(change)) + _ = vs.cache.Expire(opCtx, windowKey, 2*time.Hour) + } + return nil +}