Files
backend/internal/service/hot_rank_worker.go
lafay c6848aba06
All checks were successful
Build Backend / build (push) Successful in 12m45s
Build Backend / build-docker (push) Successful in 2m40s
refactor: update repository interfaces and improve dependency injection
- Refactored repository structures to use interfaces instead of concrete types, enhancing flexibility and testability.
- Updated various repository methods to accept interfaces, allowing for better dependency management.
- Modified wire generation to accommodate new repository interfaces, ensuring proper service injection throughout the application.
- Enhanced handler methods to utilize DTOs for filtering and data handling, improving code clarity and maintainability.
2026-03-26 18:14:16 +08:00

336 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"errors"
"math"
"sort"
"sync"
"time"
"carrot_bbs/internal/cache"
"carrot_bbs/internal/config"
"carrot_bbs/internal/repository"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// HotRankWorker 定时在候选池上重算热门分,写入 Redis ZSET仅 TopN及 top_idsRedis+本地缓存)
type HotRankWorker struct {
cfg *config.Config
postRepo repository.PostRepository
c cache.Cache
mu sync.Mutex
stopOnce sync.Once
stopCh chan struct{}
doneCh chan struct{}
}
// NewHotRankWorker 创建热门榜重算 workercache 需为 Redis 实现才有意义)
func NewHotRankWorker(cfg *config.Config, postRepo repository.PostRepository, c cache.Cache) *HotRankWorker {
return &HotRankWorker{
cfg: cfg,
postRepo: postRepo,
c: c,
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
}
// Start 启动定时任务(启动时立即执行一次)
func (w *HotRankWorker) Start(ctx context.Context) {
if w == nil || w.cfg == nil || !w.cfg.HotRank.Enabled || w.c == nil {
close(w.doneCh)
return
}
interval := time.Duration(w.cfg.HotRank.RefreshIntervalSeconds) * time.Second
if interval < 30*time.Second {
interval = 30 * time.Second
}
go func() {
defer close(w.doneCh)
if err := w.Refresh(ctx); err != nil {
if !errors.Is(err, cache.ErrKeyNotFound) {
zap.L().Warn("hot rank initial refresh failed", zap.Error(err))
}
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-w.stopCh:
return
case <-ticker.C:
if err := w.Refresh(ctx); err != nil {
if !errors.Is(err, cache.ErrKeyNotFound) {
zap.L().Warn("hot rank refresh failed", zap.Error(err))
}
}
}
}
}()
}
// Stop 停止 worker
func (w *HotRankWorker) Stop() {
if w == nil {
return
}
w.stopOnce.Do(func() {
close(w.stopCh)
})
<-w.doneCh
}
// Refresh 立即重算热门分(线程安全)
// 算法:候选 ID = 上一期 TopNRedis 时间窗内新发帖子 ID截断至 CandidateCap
// 仅对候选拉取统计字段并打分min-max 归一化;已发布置顶帖固定排在最前,其后接热门至 TopN
// 写入 ZSET置顶分数高于热门及 top_ids。
func (w *HotRankWorker) Refresh(ctx context.Context) error {
if w == nil || w.c == nil {
return cache.ErrKeyNotFound
}
w.mu.Lock()
defer w.mu.Unlock()
topN := w.cfg.HotRank.TopN
if topN <= 0 {
topN = 100
}
if topN > 500 {
topN = 500
}
recentHours := w.cfg.HotRank.RecentWindowHours
if recentHours <= 0 {
recentHours = 168
}
recentLimit := w.cfg.HotRank.RecentFetchLimit
if recentLimit <= 0 {
recentLimit = 500
}
candidateCap := w.cfg.HotRank.CandidateCap
if candidateCap <= 0 {
candidateCap = 800
}
zsetKey := cache.HotRankZSetKey()
topIDsKey := cache.HotRankTopIDsKey()
pinned, err := w.postRepo.ListPinnedPublishedPostIDs()
if err != nil {
return err
}
pinned = dedupeIDsPreserveOrder(pinned)
incumbent, _ := w.c.ZRevRange(ctx, zsetKey, 0, int64(topN-1))
since := time.Now().Add(-time.Duration(recentHours) * time.Hour)
recentIDs, err := w.postRepo.ListPublishedPostIDsSince(since, recentLimit)
if err != nil {
return err
}
candidateIDs := mergeHotRankCandidates(incumbent, recentIDs, candidateCap)
if len(candidateIDs) == 0 && len(pinned) == 0 {
if err := w.c.ZReplaceSortedSet(ctx, zsetKey, nil); err != nil {
return err
}
w.c.Delete(topIDsKey)
return nil
}
type scored struct {
idx int
norm float64
}
var snapshots []repository.PostHotSnapshot
var ranked []scored
if len(candidateIDs) > 0 {
snapshots, err = w.postRepo.GetPostHotSnapshotsByIDs(candidateIDs)
if err != nil {
return err
}
}
if len(snapshots) > 0 {
now := time.Now()
raw := make([]float64, len(snapshots))
for i := range snapshots {
p := &snapshots[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, maxR := raw[0], raw[0]
for _, v := range raw[1:] {
if v < minR {
minR = v
}
if v > maxR {
maxR = v
}
}
ranked = make([]scored, len(snapshots))
for i := range snapshots {
var norm float64
if maxR > minR {
norm = (raw[i] - minR) / (maxR - minR)
} else {
norm = 1
}
ranked[i] = scored{idx: i, norm: norm}
}
sort.Slice(ranked, func(a, b int) bool {
if ranked[a].norm != ranked[b].norm {
return ranked[a].norm > ranked[b].norm
}
return snapshots[ranked[a].idx].CreatedAt.After(snapshots[ranked[b].idx].CreatedAt)
})
}
pinnedSet := make(map[string]struct{}, len(pinned))
for _, id := range pinned {
pinnedSet[id] = struct{}{}
}
hotSlots := topN - len(pinned)
if hotSlots < 0 {
pinned = pinned[:topN]
pinnedSet = make(map[string]struct{}, len(pinned))
for _, id := range pinned {
pinnedSet[id] = struct{}{}
}
hotSlots = 0
}
type hotPick struct {
id string
norm float64
}
hotPicks := make([]hotPick, 0, hotSlots)
for _, r := range ranked {
id := snapshots[r.idx].ID
if _, isPin := pinnedSet[id]; isPin {
continue
}
hotPicks = append(hotPicks, hotPick{id: id, norm: r.norm})
if len(hotPicks) >= hotSlots {
break
}
}
orderedIDs := make([]string, 0, len(pinned)+len(hotPicks))
orderedIDs = append(orderedIDs, pinned...)
for _, h := range hotPicks {
orderedIDs = append(orderedIDs, h.id)
}
const pinnedScoreBase = 1000.0
zMembers := make([]redis.Z, 0, len(orderedIDs))
for i, id := range pinned {
zMembers = append(zMembers, redis.Z{
Score: pinnedScoreBase - float64(i)*1e-6,
Member: id,
})
}
for _, h := range hotPicks {
zMembers = append(zMembers, redis.Z{Score: h.norm * 0.99, Member: h.id})
}
if err := w.c.ZReplaceSortedSet(ctx, zsetKey, zMembers); err != nil {
return err
}
ttl := time.Duration(w.cfg.HotRank.RefreshIntervalSeconds) * time.Second * 4
if ttl < 10*time.Minute {
ttl = 10 * time.Minute
}
w.c.Set(topIDsKey, orderedIDs, ttl)
zap.L().Info("hot rank refreshed",
zap.Int("pinned", len(pinned)),
zap.Int("candidates", len(candidateIDs)),
zap.Int("scored", len(snapshots)),
zap.Int("top_n", len(zMembers)),
)
return nil
}
func dedupeIDsPreserveOrder(ids []string) []string {
if len(ids) <= 1 {
return ids
}
seen := make(map[string]struct{}, len(ids))
out := make([]string, 0, len(ids))
for _, id := range ids {
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func mergeHotRankCandidates(incumbent, recent []string, cap int) []string {
if cap <= 0 {
return nil
}
seen := make(map[string]struct{}, cap)
out := make([]string, 0, cap)
for _, id := range incumbent {
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
if len(out) >= cap {
return out
}
}
for _, id := range recent {
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
if len(out) >= cap {
break
}
}
return out
}
func hotRankBaseScore(likes, comments, favorites, shares, views int) float64 {
return float64(likes)*3 +
float64(comments)*5 +
float64(favorites)*4 +
float64(shares)*6 +
float64(views)*0.15
}