292 lines
9.0 KiB
Go
292 lines
9.0 KiB
Go
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
|
||
}
|
||
}
|