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