feat:添加热榜功能并添加了增加热度的方法。

This commit is contained in:
Leon
2025-12-26 22:49:17 +08:00
parent 14a905e194
commit 328fae5f07
12 changed files with 327 additions and 10 deletions

View File

@@ -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"`
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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))