2026-06-26 17:56:30 +08:00

417 lines
13 KiB
Go

// Package userleaderboard owns the Redis read model for App user gift rankings.
package userleaderboard
import (
"context"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
"hyapp/pkg/appcode"
)
const (
BoardSent = "sent"
BoardReceived = "received"
PeriodToday = "today"
PeriodWeek = "week"
PeriodMonth = "month"
defaultKeyPrefix = "activity:user_leaderboard"
defaultPageSize = 20
maxPageSize = 100
defaultEventDedupeTTL = 90 * 24 * time.Hour
dailyLeaderboardKeep = 7 * 24 * time.Hour
weeklyLeaderboardKeep = 21 * 24 * time.Hour
monthlyLeaderboardKeep = 35 * 24 * time.Hour
)
var ErrNotConfigured = errors.New("user leaderboard redis is not configured")
// Store keeps sent/received gift leaderboard buckets in Redis zsets plus per-user hashes.
// The write side consumes wallet committed facts; the read side is used by gateway only.
type Store struct {
client *redis.Client
keyPrefix string
}
// GiftEvent is the minimal WalletGiftDebited fact needed to update the user leaderboard.
type GiftEvent struct {
AppCode string
EventID string
SenderUserID int64
TargetUserID int64
GiftValue int64
GiftCount int64
OccurredAtMS int64
DirectGift bool
}
// Query describes one App leaderboard page. Now must be UTC-compatible; zero means current UTC time.
type Query struct {
AppCode string
BoardType string
Period string
Page int
PageSize int
Now time.Time
}
// Page is the Redis aggregate result returned to gateway before profile enrichment.
type Page struct {
Items []Entry
Total int64
BoardType string
Period string
StartAtMS int64
EndAtMS int64
ServerTimeMS int64
}
// Entry contains only aggregate facts. gateway enriches user profile data separately.
type Entry struct {
Rank int64
UserID string
GiftValue int64
GiftCount int64
TransactionCount int64
LastGiftAtMS int64
}
// NewRedisClient creates and verifies the Redis dependency used by the leaderboard read model.
func NewRedisClient(ctx context.Context, addr string, password string, db int) (*redis.Client, error) {
client := redis.NewClient(&redis.Options{
Addr: strings.TrimSpace(addr),
Password: password,
DB: db,
})
// 榜单 Redis 是 gateway 查询的唯一读模型;启动时探测失败比运行中慢查钱包库更容易定位。
if err := client.Ping(ctx).Err(); err != nil {
_ = client.Close()
return nil, err
}
return client, nil
}
// NewStore binds a verified Redis client to a stable key namespace.
func NewStore(client *redis.Client, keyPrefix string) *Store {
keyPrefix = strings.Trim(strings.TrimSpace(keyPrefix), ":")
if keyPrefix == "" {
keyPrefix = defaultKeyPrefix
}
return &Store{client: client, keyPrefix: keyPrefix}
}
// ApplyGiftEvent increments sent and received buckets for day/week/month.
// It returns false when the fact is outside the old SQL leaderboard scope, such as direct gifts.
func (s *Store) ApplyGiftEvent(ctx context.Context, event GiftEvent) (bool, error) {
if s == nil || s.client == nil {
return false, ErrNotConfigured
}
event = normalizeGiftEvent(event)
if event.AppCode == "" || event.EventID == "" || event.SenderUserID <= 0 || event.TargetUserID <= 0 || event.GiftValue <= 0 || event.DirectGift {
return false, nil
}
occurredAt := time.UnixMilli(event.OccurredAtMS).UTC()
keys := []string{s.dedupeKey(event.AppCode, event.EventID)}
args := []any{
strconv.FormatInt(int64(defaultEventDedupeTTL/time.Millisecond), 10),
strconv.FormatInt(event.GiftValue, 10),
strconv.FormatInt(event.GiftCount, 10),
strconv.FormatInt(event.OccurredAtMS, 10),
}
updates := make([]leaderboardUpdate, 0, 6)
for _, period := range []string{PeriodToday, PeriodWeek, PeriodMonth} {
updates = append(updates,
s.updateFor(event.AppCode, BoardSent, period, event.SenderUserID, occurredAt),
s.updateFor(event.AppCode, BoardReceived, period, event.TargetUserID, occurredAt),
)
}
args = append(args, strconv.Itoa(len(updates)))
for _, update := range updates {
keys = append(keys, update.scoreKey, update.itemKey)
args = append(args, update.member, strconv.FormatInt(int64(update.ttl/time.Second), 10))
}
result, err := applyGiftEventScript.Run(ctx, s.client, keys, args...).Int64()
if err != nil {
return false, err
}
return result == 1, nil
}
// List reads a leaderboard page from the current Redis bucket. Empty keys are valid empty rankings.
func (s *Store) List(ctx context.Context, query Query) (Page, error) {
if s == nil || s.client == nil {
return Page{}, ErrNotConfigured
}
query = normalizeQuery(query)
start, end := QueryWindow(query.Period, query.Now)
key := s.scoreKey(query.AppCode, query.BoardType, query.Period, start)
offset := int64((query.Page - 1) * query.PageSize)
stop := offset + int64(query.PageSize) - 1
total, err := s.client.ZCard(ctx, key).Result()
if err != nil {
return Page{}, err
}
rows, err := s.client.ZRevRangeWithScores(ctx, key, offset, stop).Result()
if err != nil {
return Page{}, err
}
items := make([]Entry, 0, len(rows))
for index, row := range rows {
userID, ok := row.Member.(string)
if !ok || strings.TrimSpace(userID) == "" {
continue
}
entry, err := s.entryFromHash(ctx, query.AppCode, query.BoardType, query.Period, start, userID, int64(math.Round(row.Score)))
if err != nil {
return Page{}, err
}
entry.Rank = offset + int64(index) + 1
items = append(items, entry)
}
return Page{
Items: items,
Total: total,
BoardType: query.BoardType,
Period: query.Period,
StartAtMS: start.UnixMilli(),
EndAtMS: end.UnixMilli(),
ServerTimeMS: query.Now.UTC().UnixMilli(),
}, nil
}
// RankForUser returns the caller's current aggregate rank for the same bucket used by List.
func (s *Store) RankForUser(ctx context.Context, query Query, userID int64) (Entry, bool, error) {
if s == nil || s.client == nil {
return Entry{}, false, ErrNotConfigured
}
if userID <= 0 {
return Entry{}, false, nil
}
query = normalizeQuery(query)
start, _ := QueryWindow(query.Period, query.Now)
key := s.scoreKey(query.AppCode, query.BoardType, query.Period, start)
member := strconv.FormatInt(userID, 10)
rank, err := s.client.ZRevRank(ctx, key, member).Result()
if errors.Is(err, redis.Nil) {
return Entry{}, false, nil
}
if err != nil {
return Entry{}, false, err
}
entry, err := s.entryFromHash(ctx, query.AppCode, query.BoardType, query.Period, start, member, 0)
if err != nil {
return Entry{}, false, err
}
entry.Rank = rank + 1
return entry, true, nil
}
// NormalizeBoardType keeps gateway query aliases out of the Redis key layer.
func NormalizeBoardType(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", BoardSent, "send", "sender", "gift_sent", "user_sent":
return BoardSent
case BoardReceived, "receive", "receiver", "gift_received", "user_received":
return BoardReceived
default:
return ""
}
}
// NormalizePeriod maps public aliases to the fixed UTC buckets used by activity and gateway.
func NormalizePeriod(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", PeriodToday, "day", "daily":
return PeriodToday
case PeriodWeek, "weekly":
return PeriodWeek
case PeriodMonth, "monthly":
return PeriodMonth
default:
return ""
}
}
// QueryWindow preserves the existing gateway response contract: start is the UTC bucket boundary, end is request time.
func QueryWindow(period string, now time.Time) (time.Time, time.Time) {
now = normalizedNow(now)
start, _ := periodBounds(period, now)
return start, now
}
type leaderboardUpdate struct {
scoreKey string
itemKey string
member string
ttl time.Duration
}
func (s *Store) updateFor(app string, board string, period string, userID int64, occurredAt time.Time) leaderboardUpdate {
start, periodEnd := periodBounds(period, occurredAt)
member := strconv.FormatInt(userID, 10)
return leaderboardUpdate{
scoreKey: s.scoreKey(app, board, period, start),
itemKey: s.itemKey(app, board, period, start, member),
member: member,
ttl: retentionTTL(period, start, periodEnd),
}
}
func (s *Store) entryFromHash(ctx context.Context, app string, board string, period string, start time.Time, userID string, scoreFallback int64) (Entry, error) {
values, err := s.client.HGetAll(ctx, s.itemKey(app, board, period, start, userID)).Result()
if err != nil {
return Entry{}, err
}
entry := Entry{
UserID: userID,
GiftValue: scoreFallback,
}
if value := int64FromHash(values, "gift_value"); value > 0 {
entry.GiftValue = value
}
entry.GiftCount = int64FromHash(values, "gift_count")
entry.TransactionCount = int64FromHash(values, "transaction_count")
entry.LastGiftAtMS = int64FromHash(values, "last_gift_at_ms")
return entry, nil
}
func (s *Store) scoreKey(app string, board string, period string, start time.Time) string {
return fmt.Sprintf("%s:%s:%s:%s:%s:scores", s.keyPrefix, appcode.Normalize(app), board, period, bucketID(period, start))
}
func (s *Store) itemKey(app string, board string, period string, start time.Time, userID string) string {
return fmt.Sprintf("%s:%s:%s:%s:%s:users:%s", s.keyPrefix, appcode.Normalize(app), board, period, bucketID(period, start), userID)
}
func (s *Store) dedupeKey(app string, eventID string) string {
return fmt.Sprintf("%s:%s:dedupe:%s", s.keyPrefix, appcode.Normalize(app), strings.TrimSpace(eventID))
}
func normalizeGiftEvent(event GiftEvent) GiftEvent {
event.AppCode = appcode.Normalize(event.AppCode)
event.EventID = strings.TrimSpace(event.EventID)
if event.OccurredAtMS <= 0 {
event.OccurredAtMS = time.Now().UTC().UnixMilli()
}
return event
}
func normalizeQuery(query Query) Query {
query.AppCode = appcode.Normalize(query.AppCode)
query.BoardType = NormalizeBoardType(query.BoardType)
if query.BoardType == "" {
query.BoardType = BoardSent
}
query.Period = NormalizePeriod(query.Period)
if query.Period == "" {
query.Period = PeriodToday
}
if query.Page <= 0 {
query.Page = 1
}
if query.PageSize <= 0 {
query.PageSize = defaultPageSize
}
if query.PageSize > maxPageSize {
query.PageSize = maxPageSize
}
query.Now = normalizedNow(query.Now)
return query
}
func normalizedNow(now time.Time) time.Time {
if now.IsZero() {
return time.Now().UTC()
}
return now.UTC()
}
func periodBounds(period string, at time.Time) (time.Time, time.Time) {
at = normalizedNow(at)
dayStart := time.Date(at.Year(), at.Month(), at.Day(), 0, 0, 0, 0, time.UTC)
switch NormalizePeriod(period) {
case PeriodWeek:
weekday := int(dayStart.Weekday())
if weekday == 0 {
weekday = 7
}
start := dayStart.AddDate(0, 0, 1-weekday)
return start, start.AddDate(0, 0, 7)
case PeriodMonth:
start := time.Date(at.Year(), at.Month(), 1, 0, 0, 0, 0, time.UTC)
return start, start.AddDate(0, 1, 0)
default:
return dayStart, dayStart.AddDate(0, 0, 1)
}
}
func retentionTTL(period string, start time.Time, periodEnd time.Time) time.Duration {
switch NormalizePeriod(period) {
case PeriodMonth:
return periodEnd.Sub(start) + monthlyLeaderboardKeep
case PeriodWeek:
return periodEnd.Sub(start) + weeklyLeaderboardKeep
default:
return periodEnd.Sub(start) + dailyLeaderboardKeep
}
}
func bucketID(period string, start time.Time) string {
switch NormalizePeriod(period) {
case PeriodWeek:
year, week := start.ISOWeek()
return fmt.Sprintf("%04d%02d", year, week)
case PeriodMonth:
return fmt.Sprintf("%04d%02d", start.Year(), int(start.Month()))
default:
return start.Format("20060102")
}
}
func int64FromHash(values map[string]string, key string) int64 {
value, _ := strconv.ParseInt(values[key], 10, 64)
return value
}
var applyGiftEventScript = redis.NewScript(`
if redis.call("EXISTS", KEYS[1]) == 1 then
return 0
end
redis.call("PSETEX", KEYS[1], ARGV[1], "1")
local gift_value = tonumber(ARGV[2])
local gift_count = tonumber(ARGV[3])
local occurred_at_ms = tonumber(ARGV[4])
local updates = tonumber(ARGV[5])
for i = 0, updates - 1 do
local score_key = KEYS[2 + i * 2]
local item_key = KEYS[3 + i * 2]
local member = ARGV[6 + i * 2]
local ttl_seconds = tonumber(ARGV[7 + i * 2])
local current_value = redis.call("HINCRBY", item_key, "gift_value", gift_value)
redis.call("HINCRBY", item_key, "gift_count", gift_count)
redis.call("HINCRBY", item_key, "transaction_count", 1)
local previous_last = tonumber(redis.call("HGET", item_key, "last_gift_at_ms") or "0")
local last_gift_at_ms = previous_last
if occurred_at_ms > previous_last then
redis.call("HSET", item_key, "last_gift_at_ms", occurred_at_ms)
last_gift_at_ms = occurred_at_ms
end
redis.call("ZADD", score_key, tonumber(current_value) + last_gift_at_ms / 10000000000000000, member)
redis.call("EXPIRE", score_key, ttl_seconds)
redis.call("EXPIRE", item_key, ttl_seconds)
end
return 1
`)