292 lines
9.0 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"
"fmt"
"log/slog"
"math"
"strings"
"time"
"github.com/redis/go-redis/v9"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
)
const (
roomGiftLeaderboardPeriodToday = "today"
roomGiftLeaderboardPeriodWeek = "week"
roomGiftLeaderboardPeriodMonth = "month"
defaultRoomGiftLeaderboardPageSize = 20
maxRoomGiftLeaderboardPageSize = 100
)
// RoomGiftLeaderboardStore 是跨房间贡献榜的轻量读模型边界;生产实现使用 Redis zset。
type RoomGiftLeaderboardStore interface {
IncrementRoomGift(ctx context.Context, input RoomGiftLeaderboardIncrement) error
ListRoomGiftLeaderboard(ctx context.Context, query RoomGiftLeaderboardQuery) (RoomGiftLeaderboardPage, error)
}
type RoomGiftLeaderboardIncrement struct {
AppCode string
RoomID string
// CoinSpent 沿用 proto 字段名,运行值实际是 wallet 折算后的房间贡献值。
CoinSpent int64
OccurredAtMS int64
}
type RoomGiftLeaderboardQuery struct {
AppCode string
Period string
Page int
PageSize int
NowMS int64
}
type RoomGiftLeaderboardPage struct {
Items []RoomGiftLeaderboardEntry
Total int64
Period string
StartAtMS int64
EndAtMS int64
ServerTimeMS int64
}
type RoomGiftLeaderboardEntry struct {
Rank int64
RoomID string
CoinSpent int64
}
// RedisRoomGiftLeaderboardStore 只在 zset 中保存 room_id -> 周期房间贡献分。
type RedisRoomGiftLeaderboardStore struct {
client *redis.Client
}
func NewRedisRoomGiftLeaderboardStore(client *redis.Client) *RedisRoomGiftLeaderboardStore {
return &RedisRoomGiftLeaderboardStore{client: client}
}
func (s *RedisRoomGiftLeaderboardStore) IncrementRoomGift(ctx context.Context, input RoomGiftLeaderboardIncrement) error {
if s == nil || s.client == nil {
return xerr.New(xerr.Unavailable, "room gift leaderboard store is not configured")
}
app := appcode.Normalize(input.AppCode)
roomID := strings.TrimSpace(input.RoomID)
if roomID == "" || input.CoinSpent <= 0 {
return nil
}
occurredAt := time.UnixMilli(input.OccurredAtMS).UTC()
if input.OccurredAtMS <= 0 {
occurredAt = time.Now().UTC()
}
pipe := s.client.Pipeline()
for _, period := range []string{roomGiftLeaderboardPeriodToday, roomGiftLeaderboardPeriodWeek, roomGiftLeaderboardPeriodMonth} {
start, end := roomGiftLeaderboardWindow(period, occurredAt)
key := roomGiftLeaderboardKey(app, period, start)
pipe.ZIncrBy(ctx, key, float64(input.CoinSpent), roomID)
// Redis 榜单是可重建读模型TTL 只需要覆盖当前周期和排查窗口。
pipe.Expire(ctx, key, roomGiftLeaderboardTTL(period, start, end))
}
_, err := pipe.Exec(ctx)
return err
}
func (s *RedisRoomGiftLeaderboardStore) ListRoomGiftLeaderboard(ctx context.Context, query RoomGiftLeaderboardQuery) (RoomGiftLeaderboardPage, error) {
if s == nil || s.client == nil {
return RoomGiftLeaderboardPage{}, xerr.New(xerr.Unavailable, "room gift leaderboard store is not configured")
}
query = normalizeRoomGiftLeaderboardQuery(query)
now := time.UnixMilli(query.NowMS).UTC()
start, end := roomGiftLeaderboardWindow(query.Period, now)
key := roomGiftLeaderboardKey(appcode.Normalize(query.AppCode), 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 RoomGiftLeaderboardPage{}, err
}
rows, err := s.client.ZRevRangeWithScores(ctx, key, offset, stop).Result()
if err != nil {
return RoomGiftLeaderboardPage{}, err
}
items := make([]RoomGiftLeaderboardEntry, 0, len(rows))
for i, row := range rows {
roomID, ok := row.Member.(string)
if !ok || strings.TrimSpace(roomID) == "" {
continue
}
items = append(items, RoomGiftLeaderboardEntry{
Rank: offset + int64(i) + 1,
RoomID: roomID,
CoinSpent: int64(math.Round(row.Score)),
})
}
return RoomGiftLeaderboardPage{
Items: items,
Total: total,
Period: query.Period,
StartAtMS: start.UnixMilli(),
EndAtMS: end.UnixMilli(),
ServerTimeMS: now.UnixMilli(),
}, nil
}
func (s *Service) ListRoomGiftLeaderboard(ctx context.Context, req *roomv1.ListRoomGiftLeaderboardRequest) (*roomv1.ListRoomGiftLeaderboardResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
if s.roomGiftLeaderboard == nil {
return nil, xerr.New(xerr.Unavailable, "room gift leaderboard store is not configured")
}
period := normalizeRoomGiftLeaderboardPeriod(req.GetPeriod())
if period == "" {
return nil, xerr.New(xerr.InvalidArgument, "period is invalid")
}
page := int(req.GetPage())
if page <= 0 {
page = 1
}
pageSize := int(req.GetPageSize())
if pageSize <= 0 {
pageSize = defaultRoomGiftLeaderboardPageSize
}
if pageSize > maxRoomGiftLeaderboardPageSize {
pageSize = maxRoomGiftLeaderboardPageSize
}
result, err := s.roomGiftLeaderboard.ListRoomGiftLeaderboard(ctx, RoomGiftLeaderboardQuery{
AppCode: appcode.FromContext(ctx),
Period: period,
Page: page,
PageSize: pageSize,
NowMS: s.clock.Now().UnixMilli(),
})
if err != nil {
return nil, err
}
roomIDs := make([]string, 0, len(result.Items))
for _, item := range result.Items {
roomIDs = append(roomIDs, item.RoomID)
}
cards, err := s.repository.ListRoomListEntriesByIDs(ctx, RoomListEntriesByIDQuery{
AppCode: appcode.FromContext(ctx),
RoomIDs: roomIDs,
})
if err != nil {
return nil, err
}
items := make([]*roomv1.RoomGiftLeaderboardItem, 0, len(result.Items))
for _, item := range result.Items {
protoItem := &roomv1.RoomGiftLeaderboardItem{
Rank: item.Rank,
RoomId: item.RoomID,
CoinSpent: item.CoinSpent,
}
if card, ok := cards[item.RoomID]; ok {
protoItem.Room = roomListItemToProto(card)
}
items = append(items, protoItem)
}
return &roomv1.ListRoomGiftLeaderboardResponse{
Items: items,
Total: result.Total,
Period: result.Period,
StartAtMs: result.StartAtMS,
EndAtMs: result.EndAtMS,
ServerTimeMs: result.ServerTimeMS,
}, nil
}
func (s *Service) recordRoomGiftLeaderboardBestEffort(ctx context.Context, input *RoomGiftLeaderboardIncrement) {
if s == nil || s.roomGiftLeaderboard == nil || input == nil {
return
}
if err := s.roomGiftLeaderboard.IncrementRoomGift(ctx, *input); err != nil {
// 榜单 zset 是可重建读模型,失败不能回滚已提交房间命令和账务事实。
logx.Warn(ctx, "room_gift_leaderboard_update_failed", slog.String("room_id", input.RoomID), slog.Int64("coin_spent", input.CoinSpent), slog.String("error", err.Error()))
return
}
}
func normalizeRoomGiftLeaderboardQuery(query RoomGiftLeaderboardQuery) RoomGiftLeaderboardQuery {
query.AppCode = appcode.Normalize(query.AppCode)
query.Period = normalizeRoomGiftLeaderboardPeriod(query.Period)
if query.Period == "" {
query.Period = roomGiftLeaderboardPeriodToday
}
if query.Page <= 0 {
query.Page = 1
}
if query.PageSize <= 0 {
query.PageSize = defaultRoomGiftLeaderboardPageSize
}
if query.PageSize > maxRoomGiftLeaderboardPageSize {
query.PageSize = maxRoomGiftLeaderboardPageSize
}
if query.NowMS <= 0 {
query.NowMS = time.Now().UTC().UnixMilli()
}
return query
}
func normalizeRoomGiftLeaderboardPeriod(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", roomGiftLeaderboardPeriodToday, "day", "daily":
return roomGiftLeaderboardPeriodToday
case roomGiftLeaderboardPeriodWeek, "weekly":
return roomGiftLeaderboardPeriodWeek
case roomGiftLeaderboardPeriodMonth, "monthly":
return roomGiftLeaderboardPeriodMonth
default:
return ""
}
}
func roomGiftLeaderboardWindow(period string, now time.Time) (time.Time, time.Time) {
dayStart := time.Date(now.UTC().Year(), now.UTC().Month(), now.UTC().Day(), 0, 0, 0, 0, time.UTC)
switch period {
case roomGiftLeaderboardPeriodWeek:
weekday := int(dayStart.Weekday())
if weekday == 0 {
weekday = 7
}
start := dayStart.AddDate(0, 0, 1-weekday)
return start, start.AddDate(0, 0, 7)
case roomGiftLeaderboardPeriodMonth:
start := time.Date(dayStart.Year(), dayStart.Month(), 1, 0, 0, 0, 0, time.UTC)
return start, start.AddDate(0, 1, 0)
default:
return dayStart, dayStart.AddDate(0, 0, 1)
}
}
func roomGiftLeaderboardKey(app string, period string, start time.Time) string {
switch period {
case roomGiftLeaderboardPeriodWeek:
year, week := start.ISOWeek()
return fmt.Sprintf("room:gift_leaderboard:%s:week:%04d%02d", appcode.Normalize(app), year, week)
case roomGiftLeaderboardPeriodMonth:
return fmt.Sprintf("room:gift_leaderboard:%s:month:%04d%02d", appcode.Normalize(app), start.Year(), int(start.Month()))
default:
return fmt.Sprintf("room:gift_leaderboard:%s:day:%s", appcode.Normalize(app), start.Format("20060102"))
}
}
func roomGiftLeaderboardTTL(period string, start time.Time, end time.Time) time.Duration {
switch period {
case roomGiftLeaderboardPeriodMonth:
return end.Sub(start) + 35*24*time.Hour
case roomGiftLeaderboardPeriodWeek:
return end.Sub(start) + 21*24*time.Hour
default:
return end.Sub(start) + 7*24*time.Hour
}
}