feat(hot-rank): add per-channel hot ranking functionality
Add channel-specific hot post filtering and ranking across the posts listing flow. Introduce refreshChannelRanks to compute per-channel hot scores, new Redis cache keys for channel hot ranks, and update repository/service/handler layers to accept channelID filtering parameters. Update HotRankWorker dependency injection to include ChannelRepository.
This commit is contained in:
@@ -19,23 +19,24 @@ import (
|
||||
|
||||
// HotRankWorker 定时在候选池上重算热门分,写入 Redis ZSET(仅 TopN)及 top_ids(Redis+本地缓存)
|
||||
type HotRankWorker struct {
|
||||
cfg *config.Config
|
||||
postRepo repository.PostRepository
|
||||
c cache.Cache
|
||||
mu sync.Mutex
|
||||
stopOnce sync.Once
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
cfg *config.Config
|
||||
postRepo repository.PostRepository
|
||||
channelRepo repository.ChannelRepository
|
||||
c cache.Cache
|
||||
mu sync.Mutex
|
||||
stopOnce sync.Once
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
}
|
||||
|
||||
// NewHotRankWorker 创建热门榜重算 worker(cache 需为 Redis 实现才有意义)
|
||||
func NewHotRankWorker(cfg *config.Config, postRepo repository.PostRepository, c cache.Cache) *HotRankWorker {
|
||||
func NewHotRankWorker(cfg *config.Config, postRepo repository.PostRepository, channelRepo repository.ChannelRepository, c cache.Cache) *HotRankWorker {
|
||||
return &HotRankWorker{
|
||||
cfg: cfg,
|
||||
postRepo: postRepo,
|
||||
c: c,
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
cfg: cfg,
|
||||
postRepo: postRepo,
|
||||
channelRepo: channelRepo,
|
||||
c: c,
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +267,11 @@ func (w *HotRankWorker) Refresh(ctx context.Context) error {
|
||||
zap.Int("scored", len(snapshots)),
|
||||
zap.Int("top_n", len(zMembers)),
|
||||
)
|
||||
|
||||
if err := w.refreshChannelRanks(ctx, ttl); err != nil {
|
||||
zap.L().Warn("hot rank channel refresh failed", zap.Error(err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -330,3 +336,140 @@ func hotRankBaseScore(likes, comments, favorites, shares, views int) float64 {
|
||||
float64(shares)*6 +
|
||||
float64(views)*0.15
|
||||
}
|
||||
|
||||
func (w *HotRankWorker) refreshChannelRanks(
|
||||
ctx context.Context,
|
||||
ttl time.Duration,
|
||||
) error {
|
||||
if w.channelRepo == nil {
|
||||
return nil
|
||||
}
|
||||
channels, err := w.channelRepo.ListActive()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(channels) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
topN := w.cfg.HotRank.TopN
|
||||
if topN <= 0 {
|
||||
topN = 100
|
||||
}
|
||||
topN = min(topN, 500)
|
||||
|
||||
for _, ch := range channels {
|
||||
chCandidateIDs, cerr := w.postRepo.ListPublishedPostIDsByChannel(ch.ID, w.cfg.HotRank.RecentWindowHours, w.cfg.HotRank.CandidateCap)
|
||||
if cerr != nil || len(chCandidateIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var chSnapshots []repository.PostHotSnapshot
|
||||
var chRanked []scored
|
||||
filteredSnaps, ferr := w.postRepo.GetPostHotSnapshotsByIDs(chCandidateIDs)
|
||||
if ferr != nil || len(filteredSnaps) == 0 {
|
||||
continue
|
||||
}
|
||||
chSnapshots = filteredSnaps
|
||||
|
||||
now := time.Now()
|
||||
raw := make([]float64, len(chSnapshots))
|
||||
for i := range chSnapshots {
|
||||
p := &chSnapshots[i]
|
||||
base := hotRankBaseScore(p.LikesCount, p.CommentsCount, p.FavoritesCount, p.SharesCount, p.ViewsCount)
|
||||
tHours := now.Sub(p.CreatedAt).Hours()
|
||||
if tHours < 0 {
|
||||
tHours = 0
|
||||
}
|
||||
denom := math.Pow(tHours+2.0, 0.5)
|
||||
if denom < 1e-9 {
|
||||
denom = 1e-9
|
||||
}
|
||||
raw[i] = base / denom
|
||||
}
|
||||
|
||||
minR := slices.Min(raw)
|
||||
maxR := slices.Max(raw)
|
||||
|
||||
chRanked = make([]scored, len(chSnapshots))
|
||||
for i := range chSnapshots {
|
||||
var norm float64
|
||||
if maxR > minR {
|
||||
norm = (raw[i] - minR) / (maxR - minR)
|
||||
} else {
|
||||
norm = 1
|
||||
}
|
||||
chRanked[i] = scored{idx: i, norm: norm}
|
||||
}
|
||||
slices.SortFunc(chRanked, func(a, b scored) int {
|
||||
if a.norm != b.norm {
|
||||
return cmp.Compare(b.norm, a.norm)
|
||||
}
|
||||
if chSnapshots[a.idx].CreatedAt.After(chSnapshots[b.idx].CreatedAt) {
|
||||
return -1
|
||||
} else if chSnapshots[a.idx].CreatedAt.Before(chSnapshots[b.idx].CreatedAt) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
chPinned, _ := w.postRepo.ListPinnedPublishedPostIDsByChannel(ch.ID)
|
||||
chPinned = dedupeIDsPreserveOrder(chPinned)
|
||||
|
||||
chPinnedSet := make(map[string]struct{}, len(chPinned))
|
||||
for _, id := range chPinned {
|
||||
chPinnedSet[id] = struct{}{}
|
||||
}
|
||||
|
||||
hotSlots := topN - len(chPinned)
|
||||
if hotSlots < 0 {
|
||||
chPinned = chPinned[:topN]
|
||||
hotSlots = 0
|
||||
}
|
||||
|
||||
type hotPick struct {
|
||||
id string
|
||||
norm float64
|
||||
}
|
||||
hotPicks := make([]hotPick, 0, hotSlots)
|
||||
for _, r := range chRanked {
|
||||
id := chSnapshots[r.idx].ID
|
||||
if _, isPin := chPinnedSet[id]; isPin {
|
||||
continue
|
||||
}
|
||||
hotPicks = append(hotPicks, hotPick{id: id, norm: r.norm})
|
||||
if len(hotPicks) >= hotSlots {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
chOrderedIDs := make([]string, 0, len(chPinned)+len(hotPicks))
|
||||
chOrderedIDs = append(chOrderedIDs, chPinned...)
|
||||
for _, h := range hotPicks {
|
||||
chOrderedIDs = append(chOrderedIDs, h.id)
|
||||
}
|
||||
|
||||
const pinnedScoreBase = 1000.0
|
||||
chZMembers := make([]redis.Z, 0, len(chOrderedIDs))
|
||||
for i, id := range chPinned {
|
||||
chZMembers = append(chZMembers, redis.Z{
|
||||
Score: pinnedScoreBase - float64(i)*1e-6,
|
||||
Member: id,
|
||||
})
|
||||
}
|
||||
for _, h := range hotPicks {
|
||||
chZMembers = append(chZMembers, redis.Z{Score: h.norm * 0.99, Member: h.id})
|
||||
}
|
||||
|
||||
chZSetKey := cache.HotRankZSetChannelKey(ch.ID)
|
||||
chTopIDsKey := cache.HotRankTopIDsChannelKey(ch.ID)
|
||||
|
||||
if err := w.c.ZReplaceSortedSet(ctx, chZSetKey, chZMembers); err != nil {
|
||||
zap.L().Warn("hot rank channel ZReplaceSortedSet failed", zap.String("channel", ch.ID), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
w.c.Set(chTopIDsKey, chOrderedIDs, ttl)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user