From 175f4ac8e67fabbe9ebcb358e98694a8005a707f Mon Sep 17 00:00:00 2001 From: hy001 Date: Wed, 22 Jul 2026 18:54:53 +0800 Subject: [PATCH] feat: add dual game king rankings --- internal/model/yumi_game_king_models.go | 27 ++-- internal/service/gameking/config.go | 10 +- internal/service/gameking/draw.go | 44 ++++++- internal/service/gameking/event.go | 62 ++++++++-- internal/service/gameking/normalize.go | 66 ++++++---- internal/service/gameking/page.go | 115 ++++++++++++++---- internal/service/gameking/settlement.go | 104 +++++++++------- internal/service/gameking/types.go | 13 +- internal/service/taskcenter/admin.go | 2 +- .../service/taskcenter/archive_gc_work.go | 7 +- internal/service/taskcenter/event.go | 4 +- internal/service/taskcenter/types.go | 5 +- ...umi_game_king_dual_ranking_daily_limit.sql | 67 ++++++++++ 13 files changed, 392 insertions(+), 134 deletions(-) create mode 100644 migrations/062_yumi_game_king_dual_ranking_daily_limit.sql diff --git a/internal/model/yumi_game_king_models.go b/internal/model/yumi_game_king_models.go index bd4c999..640f052 100644 --- a/internal/model/yumi_game_king_models.go +++ b/internal/model/yumi_game_king_models.go @@ -15,6 +15,7 @@ type YumiGameKingActivity struct { SettlementDelayMinutes int `gorm:"column:settlement_delay_minutes"` SettlementTime time.Time `gorm:"column:settlement_time;index:idx_yumi_gk_activity_settle,priority:2"` CoinPerDraw int64 `gorm:"column:coin_per_draw"` + DailyChanceLimit int `gorm:"column:daily_chance_limit"` RankingType string `gorm:"column:ranking_type;size:32"` RankingPeriod string `gorm:"column:ranking_period;size:32"` Enabled bool `gorm:"column:enabled;index:idx_yumi_gk_activity_window,priority:2"` @@ -56,8 +57,9 @@ func (YumiGameKingPrize) TableName() string { return "yumi_game_king_prize" } type YumiGameKingRankReward struct { ID int64 `gorm:"column:id;primaryKey"` ActivityID int64 `gorm:"column:activity_id;uniqueIndex:uk_yumi_gk_rank_range,priority:1;index:idx_yumi_gk_rank_start,priority:1"` - StartRank int `gorm:"column:start_rank;uniqueIndex:uk_yumi_gk_rank_range,priority:2;index:idx_yumi_gk_rank_start,priority:2"` - EndRank int `gorm:"column:end_rank;uniqueIndex:uk_yumi_gk_rank_range,priority:3"` + RankingType string `gorm:"column:ranking_type;size:32;uniqueIndex:uk_yumi_gk_rank_range,priority:2;index:idx_yumi_gk_rank_start,priority:2"` + StartRank int `gorm:"column:start_rank;uniqueIndex:uk_yumi_gk_rank_range,priority:3;index:idx_yumi_gk_rank_start,priority:3"` + EndRank int `gorm:"column:end_rank;uniqueIndex:uk_yumi_gk_rank_range,priority:4"` ResourceGroupID int64 `gorm:"column:resource_group_id"` RewardName string `gorm:"column:reward_name;size:128"` RewardItemsJSON string `gorm:"column:reward_items_json;type:text"` @@ -74,6 +76,7 @@ type YumiGameKingConsumeLedger struct { EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_yumi_gk_ledger_event,priority:2"` UserID int64 `gorm:"column:user_id;index:idx_yumi_gk_ledger_user,priority:2"` SysOrigin string `gorm:"column:sys_origin;size:32"` + EventType string `gorm:"column:event_type;size:32"` Amount int64 `gorm:"column:amount"` EventTime time.Time `gorm:"column:event_time;index:idx_yumi_gk_ledger_user,priority:3"` PayloadJSON string `gorm:"column:payload_json;type:text"` @@ -99,11 +102,13 @@ func (YumiGameKingBackfillState) TableName() string { return "yumi_game_king_bac // YumiGameKingUser 保存榜单聚合和已使用抽奖次数,避免排行榜扫描消费账本。 type YumiGameKingUser struct { - ActivityID int64 `gorm:"column:activity_id;primaryKey;index:idx_yumi_gk_user_rank,priority:1"` - UserID int64 `gorm:"column:user_id;primaryKey;index:idx_yumi_gk_user_rank,priority:4,sort:asc"` + ActivityID int64 `gorm:"column:activity_id;primaryKey;index:idx_yumi_gk_user_rank,priority:1;index:idx_yumi_gk_user_win_rank,priority:1"` + UserID int64 `gorm:"column:user_id;primaryKey;index:idx_yumi_gk_user_rank,priority:4,sort:asc;index:idx_yumi_gk_user_win_rank,priority:4,sort:asc"` TotalConsumed int64 `gorm:"column:total_consumed;index:idx_yumi_gk_user_rank,priority:2,sort:desc"` + TotalWon int64 `gorm:"column:total_won;index:idx_yumi_gk_user_win_rank,priority:2,sort:desc"` UsedChances int64 `gorm:"column:used_chances"` ConsumeReachedTime time.Time `gorm:"column:consume_reached_time;index:idx_yumi_gk_user_rank,priority:3,sort:asc"` + WinReachedTime time.Time `gorm:"column:win_reached_time;index:idx_yumi_gk_user_win_rank,priority:3,sort:asc"` CreateTime time.Time `gorm:"column:create_time"` UpdateTime time.Time `gorm:"column:update_time"` } @@ -112,11 +117,13 @@ func (YumiGameKingUser) TableName() string { return "yumi_game_king_user" } // YumiGameKingUserDaily 保存活动时区自然日聚合,DAILY 排行不扫描消费明细。 type YumiGameKingUserDaily struct { - ActivityID int64 `gorm:"column:activity_id;primaryKey;index:idx_yumi_gk_daily_rank,priority:1"` - StatDate time.Time `gorm:"column:stat_date;type:date;primaryKey;index:idx_yumi_gk_daily_rank,priority:2"` - UserID int64 `gorm:"column:user_id;primaryKey;index:idx_yumi_gk_daily_rank,priority:5,sort:asc"` + ActivityID int64 `gorm:"column:activity_id;primaryKey;index:idx_yumi_gk_daily_rank,priority:1;index:idx_yumi_gk_daily_win_rank,priority:1;index:idx_yumi_gk_daily_user_chance,priority:1"` + StatDate time.Time `gorm:"column:stat_date;type:date;primaryKey;index:idx_yumi_gk_daily_rank,priority:2;index:idx_yumi_gk_daily_win_rank,priority:2;index:idx_yumi_gk_daily_user_chance,priority:3"` + UserID int64 `gorm:"column:user_id;primaryKey;index:idx_yumi_gk_daily_rank,priority:5,sort:asc;index:idx_yumi_gk_daily_win_rank,priority:5,sort:asc;index:idx_yumi_gk_daily_user_chance,priority:2"` TotalConsumed int64 `gorm:"column:total_consumed;index:idx_yumi_gk_daily_rank,priority:3,sort:desc"` + TotalWon int64 `gorm:"column:total_won;index:idx_yumi_gk_daily_win_rank,priority:3,sort:desc"` ConsumeReachedTime time.Time `gorm:"column:consume_reached_time;index:idx_yumi_gk_daily_rank,priority:4,sort:asc"` + WinReachedTime time.Time `gorm:"column:win_reached_time;index:idx_yumi_gk_daily_win_rank,priority:4,sort:asc"` CreateTime time.Time `gorm:"column:create_time"` UpdateTime time.Time `gorm:"column:update_time"` } @@ -148,9 +155,11 @@ func (YumiGameKingDrawRecord) TableName() string { return "yumi_game_king_draw_r type YumiGameKingSettlementRecord struct { ID int64 `gorm:"column:id;primaryKey"` ActivityID int64 `gorm:"column:activity_id;uniqueIndex:uk_yumi_gk_settle_user,priority:1;uniqueIndex:uk_yumi_gk_settle_rank,priority:1;index:idx_yumi_gk_settle_status,priority:1"` - UserID int64 `gorm:"column:user_id;uniqueIndex:uk_yumi_gk_settle_user,priority:2"` - RankNo int `gorm:"column:rank_no;uniqueIndex:uk_yumi_gk_settle_rank,priority:2"` + RankingType string `gorm:"column:ranking_type;size:32;uniqueIndex:uk_yumi_gk_settle_user,priority:2;uniqueIndex:uk_yumi_gk_settle_rank,priority:2"` + UserID int64 `gorm:"column:user_id;uniqueIndex:uk_yumi_gk_settle_user,priority:3"` + RankNo int `gorm:"column:rank_no;uniqueIndex:uk_yumi_gk_settle_rank,priority:3"` TotalConsumed int64 `gorm:"column:total_consumed"` + Score int64 `gorm:"column:score"` RankRewardID int64 `gorm:"column:rank_reward_id"` ResourceGroupID int64 `gorm:"column:resource_group_id"` BusinessNo string `gorm:"column:business_no;size:128;uniqueIndex:uk_yumi_gk_settle_business"` diff --git a/internal/service/gameking/config.go b/internal/service/gameking/config.go index e1751f5..388aa58 100644 --- a/internal/service/gameking/config.go +++ b/internal/service/gameking/config.go @@ -66,7 +66,7 @@ func (s *Service) buildDetail(ctx context.Context, activity model.YumiGameKingAc Activity: activityView(activity), Prizes: prizeViews, RankRewards: rankViews, - SupportedRankingTypes: []string{rankingTypeTycoon}, + SupportedRankingTypes: []string{rankingTypeTycoon, rankingTypeVictorious}, SupportedRankingPeriods: []string{"DAILY", rankingPeriodOverall}, ActivityStatus: activityStatus(activity, now), ServerTime: now.UnixMilli(), @@ -210,6 +210,7 @@ func (s *Service) SaveActivity(ctx context.Context, req SaveActivityRequest) (*A row.SettlementDelayMinutes = *normalized.SettlementDelayMinutes row.SettlementTime = row.EndTime.Add(time.Duration(row.SettlementDelayMinutes) * time.Minute) row.CoinPerDraw = normalized.CoinPerDraw + row.DailyChanceLimit = normalized.DailyChanceLimit row.RankingType = normalized.RankingType row.RankingPeriod = normalized.RankingPeriod row.Enabled = normalized.Enabled @@ -440,7 +441,7 @@ func (s *Service) loadPrizes(ctx context.Context, activityID int64) ([]model.Yum func (s *Service) loadRankRewards(ctx context.Context, activityID int64) ([]model.YumiGameKingRankReward, error) { var rows []model.YumiGameKingRankReward - err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).Order("start_rank ASC").Find(&rows).Error + err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).Order("ranking_type ASC, start_rank ASC").Find(&rows).Error return rows, err } @@ -463,7 +464,7 @@ func (s *Service) currentRankInputs(ctx context.Context, activityID int64) ([]Ra } values := make([]RankRewardInput, 0, len(rows)) for _, row := range rows { - values = append(values, RankRewardInput{ID: FlexibleInt64(row.ID), StartRank: row.StartRank, EndRank: row.EndRank, ResourceGroupID: FlexibleInt64(row.ResourceGroupID), RewardName: row.RewardName}) + values = append(values, RankRewardInput{ID: FlexibleInt64(row.ID), RankingType: row.RankingType, StartRank: row.StartRank, EndRank: row.EndRank, ResourceGroupID: FlexibleInt64(row.ResourceGroupID), RewardName: row.RewardName}) } return values, nil } @@ -499,7 +500,7 @@ func replaceRankRewardsTx(tx *gorm.DB, activityID int64, values []normalizedRank return err } rows = append(rows, model.YumiGameKingRankReward{ - ID: id, ActivityID: activityID, StartRank: value.Input.StartRank, EndRank: value.Input.EndRank, + ID: id, ActivityID: activityID, RankingType: value.Input.RankingType, StartRank: value.Input.StartRank, EndRank: value.Input.EndRank, ResourceGroupID: value.Snapshot.GroupID, RewardName: value.Input.RewardName, RewardItemsJSON: value.Snapshot.ItemsJSON, CreateTime: now, UpdateTime: now, }) @@ -537,6 +538,7 @@ func validateStartedActivityUpdate(row model.YumiGameKingActivity, req SaveActiv if row.ActivityCode != req.ActivityCode || row.SysOrigin != req.SysOrigin || row.Timezone != req.Timezone || row.StartTime.UnixMilli() != req.StartTime || row.EndTime.UnixMilli() != req.EndTime || row.SettlementDelayMinutes != *req.SettlementDelayMinutes || row.CoinPerDraw != req.CoinPerDraw || + row.DailyChanceLimit != req.DailyChanceLimit || row.RankingType != req.RankingType || row.RankingPeriod != req.RankingPeriod || row.Enabled != req.Enabled { return NewAppError(http.StatusConflict, "activity_config_locked", "activity scope, time, chance rule and enable state cannot change after activity start") } diff --git a/internal/service/gameking/draw.go b/internal/service/gameking/draw.go index 65d1e52..e22f438 100644 --- a/internal/service/gameking/draw.go +++ b/internal/service/gameking/draw.go @@ -73,7 +73,11 @@ func (s *Service) Draw(ctx context.Context, user AuthUser, req DrawRequest) (*Dr return err } replayed = true - remaining = userRow.TotalConsumed/lockedActivity.CoinPerDraw - userRow.UsedChances + earned, _, _, earnedErr := earnedChancesTx(tx, lockedActivity, user.UserID) + if earnedErr != nil { + return earnedErr + } + remaining = earned - userRow.UsedChances if remaining < 0 { remaining = 0 } @@ -82,7 +86,10 @@ func (s *Service) Draw(ctx context.Context, user AuthUser, req DrawRequest) (*Dr return err } - earned := userRow.TotalConsumed / lockedActivity.CoinPerDraw + earned, _, _, earnedErr := earnedChancesTx(tx, lockedActivity, user.UserID) + if earnedErr != nil { + return earnedErr + } if userRow.UsedChances >= earned { return NewAppError(http.StatusConflict, "draw_chance_unavailable", "no draw chance is available") } @@ -165,13 +172,44 @@ func (s *Service) findDrawReplay(ctx context.Context, user AuthUser, req DrawReq if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", record.ActivityID, user.UserID).First(&aggregate).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } - remaining := aggregate.TotalConsumed/activity.CoinPerDraw - aggregate.UsedChances + earned, _, _, err := s.earnedChances(ctx, activity, user.UserID, time.Now()) + if err != nil { + return nil, err + } + remaining := earned - aggregate.UsedChances if remaining < 0 { remaining = 0 } return &DrawResult{Record: drawRecordView(record), Prize: prizeView(prize), RemainingChances: remaining, IdempotentReplay: true}, nil } +// 用户汇总行已在调用方 FOR UPDATE;事件入账先更新同一汇总行再更新日表,因此这里读取到 +// 的日聚合与汇总消费属于同一个已提交边界,并发抽奖不会突破每日次数上限。 +func earnedChancesTx(tx *gorm.DB, activity model.YumiGameKingActivity, userID int64) (int64, int64, int64, error) { + var rows []model.YumiGameKingUserDaily + if err := tx.Where("activity_id = ? AND user_id = ?", activity.ID, userID).Find(&rows).Error; err != nil { + return 0, 0, 0, err + } + _, location, err := normalizeTimezone(activity.Timezone) + if err != nil { + return 0, 0, 0, err + } + today := dateKey(time.Now(), location) + var total, daily, dailyConsumed int64 + for _, row := range rows { + earned := row.TotalConsumed / activity.CoinPerDraw + if earned > int64(activity.DailyChanceLimit) { + earned = int64(activity.DailyChanceLimit) + } + total += earned + if row.StatDate.Equal(today) { + daily = earned + dailyConsumed = row.TotalConsumed + } + } + return total, daily, dailyConsumed, nil +} + func selectAndReservePrizeTx(tx *gorm.DB, activityID int64) (model.YumiGameKingPrize, error) { for attempt := 0; attempt < 20; attempt++ { var rows []model.YumiGameKingPrize diff --git a/internal/service/gameking/event.go b/internal/service/gameking/event.go index 641dd57..e094550 100644 --- a/internal/service/gameking/event.go +++ b/internal/service/gameking/event.go @@ -16,14 +16,16 @@ import ( "gorm.io/gorm/clause" ) -// ConsumeGameEvent 实现 taskcenter.GameConsumeSink。GAME_CONSUME_GOLD 是唯一统计入口, -// 不再允许后台配置钱包 eventType 或把非游戏支出混入活动。 +// ConsumeGameEvent 实现 taskcenter.GameConsumeSink,只接受 Java 游戏回调归一化后的 +// GAME_CONSUME_GOLD 与 GAME_WIN_GOLD,避免礼物、充值或活动奖励进入两个榜单。 func (s *Service) ConsumeGameEvent(ctx context.Context, event taskcenter.ValidatedEvent) error { - if !strings.EqualFold(event.EventType, taskcenter.EventTypeGameConsumeGold) { + isConsume := strings.EqualFold(event.EventType, taskcenter.EventTypeGameConsumeGold) + isWin := strings.EqualFold(event.EventType, taskcenter.EventTypeGameWinGold) + if !isConsume && !isWin { return nil } if event.UserID <= 0 || event.DeltaValue <= 0 || strings.TrimSpace(event.EventID) == "" { - return NewAppError(http.StatusBadRequest, "invalid_game_consume_event", "game consume event requires eventId, userId and positive deltaValue") + return NewAppError(http.StatusBadRequest, "invalid_game_event", "game event requires eventId, userId and positive deltaValue") } event.SysOrigin = normalizeSysOrigin(event.SysOrigin) event.EventID = strings.TrimSpace(event.EventID) @@ -47,6 +49,7 @@ func (s *Service) ConsumeGameEvent(ctx context.Context, event taskcenter.Validat // applyEventToActivity 在一个事务内完成账本幂等、总榜和活动时区日榜聚合。 func (s *Service) applyEventToActivity(ctx context.Context, activityID int64, event taskcenter.ValidatedEvent) (bool, error) { accepted := false + isConsume := strings.EqualFold(event.EventType, taskcenter.EventTypeGameConsumeGold) err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var activity model.YumiGameKingActivity // 事件仅持有共享活动锁:不同用户可并发入账;结算使用排他锁并等待所有已进入的 @@ -67,7 +70,7 @@ func (s *Service) applyEventToActivity(ctx context.Context, activityID int64, ev payloadJSON, _ := json.Marshal(event.Payload) ledger := model.YumiGameKingConsumeLedger{ ID: ledgerID, ActivityID: activity.ID, EventID: event.EventID, UserID: event.UserID, - SysOrigin: event.SysOrigin, Amount: event.DeltaValue, EventTime: event.OccurredAt, + SysOrigin: event.SysOrigin, EventType: event.EventType, Amount: event.DeltaValue, EventTime: event.OccurredAt, PayloadJSON: string(payloadJSON), CreateTime: time.Now(), } result := tx.Clauses(clause.OnConflict{ @@ -82,15 +85,24 @@ func (s *Service) applyEventToActivity(ctx context.Context, activityID int64, ev } now := time.Now() - if err := addOverallConsumeTx(tx, activity.ID, event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil { - return err - } _, location, err := normalizeTimezone(activity.Timezone) if err != nil { return err } - if err := addDailyConsumeTx(tx, activity.ID, dateKey(event.OccurredAt, location), event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil { - return err + if isConsume { + if err := addOverallConsumeTx(tx, activity.ID, event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil { + return err + } + if err := addDailyConsumeTx(tx, activity.ID, dateKey(event.OccurredAt, location), event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil { + return err + } + } else { + if err := addOverallWinTx(tx, activity.ID, event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil { + return err + } + if err := addDailyWinTx(tx, activity.ID, dateKey(event.OccurredAt, location), event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil { + return err + } } accepted = true return nil @@ -98,10 +110,36 @@ func (s *Service) applyEventToActivity(ctx context.Context, activityID int64, ev return accepted, err } +func addOverallWinTx(tx *gorm.DB, activityID, userID, amount int64, eventTime, now time.Time) error { + row := model.YumiGameKingUser{ActivityID: activityID, UserID: userID, TotalWon: amount, + ConsumeReachedTime: eventTime, WinReachedTime: eventTime, CreateTime: now, UpdateTime: now} + return tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "activity_id"}, {Name: "user_id"}}, + DoUpdates: clause.Assignments(map[string]any{ + "total_won": gorm.Expr("total_won + ?", amount), + "win_reached_time": gorm.Expr("GREATEST(COALESCE(win_reached_time, ?), ?)", eventTime, eventTime), + "update_time": now, + }), + }).Create(&row).Error +} + +func addDailyWinTx(tx *gorm.DB, activityID int64, statDate time.Time, userID, amount int64, eventTime, now time.Time) error { + row := model.YumiGameKingUserDaily{ActivityID: activityID, StatDate: statDate, UserID: userID, + TotalWon: amount, ConsumeReachedTime: eventTime, WinReachedTime: eventTime, CreateTime: now, UpdateTime: now} + return tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "activity_id"}, {Name: "stat_date"}, {Name: "user_id"}}, + DoUpdates: clause.Assignments(map[string]any{ + "total_won": gorm.Expr("total_won + ?", amount), + "win_reached_time": gorm.Expr("GREATEST(COALESCE(win_reached_time, ?), ?)", eventTime, eventTime), + "update_time": now, + }), + }).Create(&row).Error +} + func addOverallConsumeTx(tx *gorm.DB, activityID, userID, amount int64, eventTime, now time.Time) error { row := model.YumiGameKingUser{ ActivityID: activityID, UserID: userID, TotalConsumed: amount, UsedChances: 0, - ConsumeReachedTime: eventTime, CreateTime: now, UpdateTime: now, + ConsumeReachedTime: eventTime, WinReachedTime: eventTime, CreateTime: now, UpdateTime: now, } // 首笔事件不能“SELECT 未命中后 INSERT”:同一用户并发首笔会发生 PK 冲突或死锁, // 同步上报若不重试就会漏计。单条 upsert 由聚合主键串行累加,并且不覆盖 used_chances。 @@ -118,7 +156,7 @@ func addOverallConsumeTx(tx *gorm.DB, activityID, userID, amount int64, eventTim func addDailyConsumeTx(tx *gorm.DB, activityID int64, statDate time.Time, userID, amount int64, eventTime, now time.Time) error { row := model.YumiGameKingUserDaily{ ActivityID: activityID, StatDate: statDate, UserID: userID, TotalConsumed: amount, - ConsumeReachedTime: eventTime, CreateTime: now, UpdateTime: now, + ConsumeReachedTime: eventTime, WinReachedTime: eventTime, CreateTime: now, UpdateTime: now, } return tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "activity_id"}, {Name: "stat_date"}, {Name: "user_id"}}, diff --git a/internal/service/gameking/normalize.go b/internal/service/gameking/normalize.go index fa8d895..d8a04ab 100644 --- a/internal/service/gameking/normalize.go +++ b/internal/service/gameking/normalize.go @@ -45,8 +45,8 @@ func normalizeRankingType(value string) (string, error) { if value == "" { value = rankingTypeTycoon } - if value != rankingTypeTycoon { - return "", NewAppError(http.StatusBadRequest, "invalid_ranking_type", "rankingType must be TYCOON") + if value != rankingTypeTycoon && value != rankingTypeVictorious { + return "", NewAppError(http.StatusBadRequest, "invalid_ranking_type", "rankingType must be TYCOON or VICTORIOUS") } return value, nil } @@ -97,6 +97,9 @@ func normalizeActivityInput(req SaveActivityRequest) (SaveActivityRequest, error if req.CoinPerDraw <= 0 { return req, NewAppError(http.StatusBadRequest, "invalid_coin_per_draw", "coinPerDraw must be greater than zero") } + if req.DailyChanceLimit <= 0 || req.DailyChanceLimit > 10000 { + return req, NewAppError(http.StatusBadRequest, "invalid_daily_chance_limit", "dailyChanceLimit must be between 1 and 10000") + } req.RankingType, err = normalizeRankingType(req.RankingType) if err != nil { return req, err @@ -150,39 +153,46 @@ func (s *Service) normalizePrizes(ctx context.Context, sysOrigin string, values } func (s *Service) normalizeRankRewards(ctx context.Context, sysOrigin string, values []RankRewardInput) ([]normalizedRankReward, error) { - if len(values) != len(fixedRankRanges) { - return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_count", "exactly six rank reward tiers are required") + if len(values) != len(fixedRankRanges)*2 { + return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_count", "exactly six tiers for each of TYCOON and VICTORIOUS are required") } byRange := make(map[string]RankRewardInput, len(values)) for _, value := range values { - key := fmt.Sprintf("%d-%d", value.StartRank, value.EndRank) + var err error + value.RankingType, err = normalizeRankingType(value.RankingType) + if err != nil { + return nil, err + } + key := fmt.Sprintf("%s:%d-%d", value.RankingType, value.StartRank, value.EndRank) if _, exists := byRange[key]; exists { return nil, NewAppError(http.StatusBadRequest, "duplicate_rank_reward", "rank reward ranges must be unique") } byRange[key] = value } - result := make([]normalizedRankReward, 0, len(fixedRankRanges)) - for _, rankRange := range fixedRankRanges { - key := fmt.Sprintf("%d-%d", rankRange[0], rankRange[1]) - value, exists := byRange[key] - if !exists { - return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_ranges", "rank rewards must be 1, 2, 3, 4-7, 8-10 and 11-30") + result := make([]normalizedRankReward, 0, len(fixedRankRanges)*2) + for _, rankingType := range []string{rankingTypeTycoon, rankingTypeVictorious} { + for _, rankRange := range fixedRankRanges { + key := fmt.Sprintf("%s:%d-%d", rankingType, rankRange[0], rankRange[1]) + value, exists := byRange[key] + if !exists { + return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_ranges", "rank rewards must be 1, 2, 3, 4-7, 8-10 and 11-30") + } + if value.ResourceGroupID.Int64() <= 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_group", "every rank tier must reference a reward group") + } + value.RewardName = strings.TrimSpace(value.RewardName) + snapshot, err := s.loadRewardGroupSnapshot(ctx, sysOrigin, value.ResourceGroupID.Int64()) + if err != nil { + return nil, err + } + if value.RewardName == "" { + value.RewardName = snapshot.GroupName + } + if len(value.RewardName) > 128 { + return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_name", "rewardName must not exceed 128 characters") + } + result = append(result, normalizedRankReward{Input: value, Snapshot: snapshot}) } - if value.ResourceGroupID.Int64() <= 0 { - return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_group", "every rank tier must reference a reward group") - } - value.RewardName = strings.TrimSpace(value.RewardName) - snapshot, err := s.loadRewardGroupSnapshot(ctx, sysOrigin, value.ResourceGroupID.Int64()) - if err != nil { - return nil, err - } - if value.RewardName == "" { - value.RewardName = snapshot.GroupName - } - if len(value.RewardName) > 128 { - return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_name", "rewardName must not exceed 128 characters") - } - result = append(result, normalizedRankReward{Input: value, Snapshot: snapshot}) } return result, nil } @@ -274,7 +284,8 @@ func activityView(row model.YumiGameKingActivity) ActivityView { SettlementDelayMinutes: row.SettlementDelayMinutes, SettlementTime: unixMilli(row.SettlementTime), CoinPerDraw: row.CoinPerDraw, - EventTypes: []string{"GAME_CONSUME_GOLD"}, + DailyChanceLimit: row.DailyChanceLimit, + EventTypes: []string{"GAME_CONSUME_GOLD", "GAME_WIN_GOLD"}, RankingType: row.RankingType, RankingPeriod: row.RankingPeriod, Enabled: row.Enabled, @@ -304,6 +315,7 @@ func rankRewardView(row model.YumiGameKingRankReward) RankRewardView { return RankRewardView{ ID: row.ID, ActivityID: row.ActivityID, + RankingType: row.RankingType, StartRank: row.StartRank, EndRank: row.EndRank, ResourceGroupID: row.ResourceGroupID, diff --git a/internal/service/gameking/page.go b/internal/service/gameking/page.go index 95e67dc..74b4300 100644 --- a/internal/service/gameking/page.go +++ b/internal/service/gameking/page.go @@ -29,19 +29,23 @@ func (s *Service) Me(ctx context.Context, user AuthUser, activityID int64) (*Use if errors.Is(err, gorm.ErrRecordNotFound) { row = model.YumiGameKingUser{ActivityID: activity.ID, UserID: user.UserID} } - earned := row.TotalConsumed / activity.CoinPerDraw + earned, dailyEarned, dailyConsumed, err := s.earnedChances(ctx, activity, user.UserID, time.Now()) + if err != nil { + return nil, err + } available := earned - row.UsedChances if available < 0 { available = 0 } - remainder := row.TotalConsumed % activity.CoinPerDraw + remainder := dailyConsumed % activity.CoinPerDraw next := activity.CoinPerDraw - if remainder > 0 { + // 达到日上限后,下一次资格只能从次日重新累计;进度也必须按当日消费计算。 + if dailyEarned < int64(activity.DailyChanceLimit) && remainder > 0 { next -= remainder } var rank *int if row.TotalConsumed > 0 { - value, rankErr := s.userRank(ctx, activity, user.UserID, activity.RankingPeriod, time.Now()) + value, rankErr := s.userRank(ctx, activity, user.UserID, rankingTypeTycoon, activity.RankingPeriod, time.Now()) if rankErr != nil { return nil, rankErr } @@ -49,12 +53,38 @@ func (s *Service) Me(ctx context.Context, user AuthUser, activityID int64) (*Use } return &UserStateResponse{ ActivityID: activity.ID, TotalConsumed: row.TotalConsumed, EarnedChances: earned, - UsedChances: row.UsedChances, AvailableChances: available, NextChanceRemaining: next, Rank: rank, + UsedChances: row.UsedChances, AvailableChances: available, DailyEarnedChances: dailyEarned, + DailyChanceLimit: activity.DailyChanceLimit, NextChanceRemaining: next, Rank: rank, }, nil } -// Ranking 返回活动时区日榜或总榜。VICTORIOUS 是旧 H5 兼容维度;当前活动没有胜利事件, -// 因此显式请求时返回空榜而不是 400,supportedRankingTypes 会让新 H5 隐藏该页签。 +// earnedChances 按活动时区日聚合逐日封顶;只读取该用户参与过的活动日期,不扫描事件账本。 +func (s *Service) earnedChances(ctx context.Context, activity model.YumiGameKingActivity, userID int64, now time.Time) (int64, int64, int64, error) { + var rows []model.YumiGameKingUserDaily + if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, userID).Find(&rows).Error; err != nil { + return 0, 0, 0, err + } + _, location, err := normalizeTimezone(activity.Timezone) + if err != nil { + return 0, 0, 0, err + } + today := dateKey(now, location) + var total, daily, dailyConsumed int64 + for _, row := range rows { + earned := row.TotalConsumed / activity.CoinPerDraw + if earned > int64(activity.DailyChanceLimit) { + earned = int64(activity.DailyChanceLimit) + } + total += earned + if row.StatDate.Equal(today) { + daily = earned + dailyConsumed = row.TotalConsumed + } + } + return total, daily, dailyConsumed, nil +} + +// Ranking 返回活动时区日榜或总榜;榜单类型只映射到服务端固定列名,不接受任意 SQL 字段。 func (s *Service) Ranking(ctx context.Context, user AuthUser, activityID int64, rankingType, period string, limit int) (*RankingResponse, error) { activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), activityID, time.Now()) if err != nil { @@ -71,7 +101,7 @@ func (s *Service) Ranking(ctx context.Context, user AuthUser, activityID int64, if period != "DAILY" && period != rankingPeriodOverall { return nil, NewAppError(http.StatusBadRequest, "invalid_ranking_period", "period must be DAILY or OVERALL") } - if rankingType != rankingTypeTycoon && rankingType != "VICTORIOUS" { + if rankingType != rankingTypeTycoon && rankingType != rankingTypeVictorious { return nil, NewAppError(http.StatusBadRequest, "invalid_ranking_type", "rankingType must be TYCOON or VICTORIOUS") } if limit <= 0 { @@ -81,11 +111,11 @@ func (s *Service) Ranking(ctx context.Context, user AuthUser, activityID int64, limit = maxRankingLimit } response := &RankingResponse{RankingType: rankingType, Period: period, Entries: []RankEntryView{}} - if rankingType == "VICTORIOUS" { - return response, nil - } - now := time.Now() + scoreColumn, reachedColumn := "total_consumed", "consume_reached_time" + if rankingType == rankingTypeVictorious { + scoreColumn, reachedColumn = "total_won", "win_reached_time" + } if period == "DAILY" { _, location, timezoneErr := normalizeTimezone(activity.Timezone) if timezoneErr != nil { @@ -95,29 +125,37 @@ func (s *Service) Ranking(ctx context.Context, user AuthUser, activityID int64, response.StatDate = now.In(location).Format("2006-01-02") var rows []model.YumiGameKingUserDaily if err := s.db.WithContext(ctx). - Where("activity_id = ? AND stat_date = ? AND total_consumed > 0", activity.ID, statDate). - Order("total_consumed DESC, consume_reached_time ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil { + Where("activity_id = ? AND stat_date = ? AND "+scoreColumn+" > 0", activity.ID, statDate). + Order(scoreColumn + " DESC, " + reachedColumn + " ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil { return nil, err } for index, row := range rows { - entry := RankEntryView{Rank: index + 1, UserID: row.UserID, Score: row.TotalConsumed, TotalConsumed: row.TotalConsumed, Me: row.UserID == user.UserID} + score := row.TotalConsumed + entry := RankEntryView{Rank: index + 1, UserID: row.UserID, Score: score, TotalConsumed: score, Me: row.UserID == user.UserID} + if rankingType == rankingTypeVictorious { + entry.Score, entry.TotalConsumed, entry.TotalWon = row.TotalWon, 0, row.TotalWon + } response.Entries = append(response.Entries, entry) } } else { var rows []model.YumiGameKingUser if err := s.db.WithContext(ctx). - Where("activity_id = ? AND total_consumed > 0", activity.ID). - Order("total_consumed DESC, consume_reached_time ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil { + Where("activity_id = ? AND "+scoreColumn+" > 0", activity.ID). + Order(scoreColumn + " DESC, " + reachedColumn + " ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil { return nil, err } for index, row := range rows { - entry := RankEntryView{Rank: index + 1, UserID: row.UserID, Score: row.TotalConsumed, TotalConsumed: row.TotalConsumed, Me: row.UserID == user.UserID} + score := row.TotalConsumed + entry := RankEntryView{Rank: index + 1, UserID: row.UserID, Score: score, TotalConsumed: score, Me: row.UserID == user.UserID} + if rankingType == rankingTypeVictorious { + entry.Score, entry.TotalConsumed, entry.TotalWon = row.TotalWon, 0, row.TotalWon + } response.Entries = append(response.Entries, entry) } } s.hydrateRankProfiles(ctx, response.Entries) if user.UserID > 0 { - response.My, err = s.myRankEntry(ctx, activity, user.UserID, period, now) + response.My, err = s.myRankEntry(ctx, activity, user.UserID, rankingType, period, now) if err != nil { return nil, err } @@ -126,8 +164,8 @@ func (s *Service) Ranking(ctx context.Context, user AuthUser, activityID int64, return response, nil } -func (s *Service) myRankEntry(ctx context.Context, activity model.YumiGameKingActivity, userID int64, period string, now time.Time) (*RankEntryView, error) { - rank, err := s.userRank(ctx, activity, userID, period, now) +func (s *Service) myRankEntry(ctx context.Context, activity model.YumiGameKingActivity, userID int64, rankingType, period string, now time.Time) (*RankEntryView, error) { + rank, err := s.userRank(ctx, activity, userID, rankingType, period, now) if err != nil || rank == nil { return nil, err } @@ -142,6 +180,9 @@ func (s *Service) myRankEntry(ctx context.Context, activity model.YumiGameKingAc return nil, err } entry.Score, entry.TotalConsumed = row.TotalConsumed, row.TotalConsumed + if rankingType == rankingTypeVictorious { + entry.Score, entry.TotalConsumed, entry.TotalWon = row.TotalWon, 0, row.TotalWon + } } else { var row model.YumiGameKingUser if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, userID).First(&row).Error; err != nil { @@ -151,11 +192,18 @@ func (s *Service) myRankEntry(ctx context.Context, activity model.YumiGameKingAc return nil, err } entry.Score, entry.TotalConsumed = row.TotalConsumed, row.TotalConsumed + if rankingType == rankingTypeVictorious { + entry.Score, entry.TotalConsumed, entry.TotalWon = row.TotalWon, 0, row.TotalWon + } } return entry, nil } -func (s *Service) userRank(ctx context.Context, activity model.YumiGameKingActivity, userID int64, period string, now time.Time) (*int, error) { +func (s *Service) userRank(ctx context.Context, activity model.YumiGameKingActivity, userID int64, rankingType, period string, now time.Time) (*int, error) { + scoreColumn, reachedColumn := "total_consumed", "consume_reached_time" + if rankingType == rankingTypeVictorious { + scoreColumn, reachedColumn = "total_won", "win_reached_time" + } if period == "DAILY" { _, location, _ := normalizeTimezone(activity.Timezone) statDate := dateKey(now, location) @@ -166,10 +214,17 @@ func (s *Service) userRank(ctx context.Context, activity model.YumiGameKingActiv } return nil, err } + score, reached := row.TotalConsumed, row.ConsumeReachedTime + if rankingType == rankingTypeVictorious { + score, reached = row.TotalWon, row.WinReachedTime + } + if score <= 0 { + return nil, nil + } var ahead int64 if err := s.db.WithContext(ctx).Model(&model.YumiGameKingUserDaily{}). - Where("activity_id = ? AND stat_date = ? AND (total_consumed > ? OR (total_consumed = ? AND (consume_reached_time < ? OR (consume_reached_time = ? AND user_id < ?))))", - activity.ID, statDate, row.TotalConsumed, row.TotalConsumed, row.ConsumeReachedTime, row.ConsumeReachedTime, userID). + Where("activity_id = ? AND stat_date = ? AND ("+scoreColumn+" > ? OR ("+scoreColumn+" = ? AND ("+reachedColumn+" < ? OR ("+reachedColumn+" = ? AND user_id < ?))))", + activity.ID, statDate, score, score, reached, reached, userID). Count(&ahead).Error; err != nil { return nil, err } @@ -183,10 +238,17 @@ func (s *Service) userRank(ctx context.Context, activity model.YumiGameKingActiv } return nil, err } + score, reached := row.TotalConsumed, row.ConsumeReachedTime + if rankingType == rankingTypeVictorious { + score, reached = row.TotalWon, row.WinReachedTime + } + if score <= 0 { + return nil, nil + } var ahead int64 if err := s.db.WithContext(ctx).Model(&model.YumiGameKingUser{}). - Where("activity_id = ? AND (total_consumed > ? OR (total_consumed = ? AND (consume_reached_time < ? OR (consume_reached_time = ? AND user_id < ?))))", - activity.ID, row.TotalConsumed, row.TotalConsumed, row.ConsumeReachedTime, row.ConsumeReachedTime, userID). + Where("activity_id = ? AND ("+scoreColumn+" > ? OR ("+scoreColumn+" = ? AND ("+reachedColumn+" < ? OR ("+reachedColumn+" = ? AND user_id < ?))))", + activity.ID, score, score, reached, reached, userID). Count(&ahead).Error; err != nil { return nil, err } @@ -322,6 +384,7 @@ func drawRecordView(row model.YumiGameKingDrawRecord) DrawRecordView { func settlementRecordView(row model.YumiGameKingSettlementRecord) SettlementRecordView { return SettlementRecordView{ ID: row.ID, ActivityID: row.ActivityID, UserID: row.UserID, Rank: row.RankNo, + RankingType: row.RankingType, Score: row.Score, TotalConsumed: row.TotalConsumed, RankRewardID: row.RankRewardID, ResourceGroupID: row.ResourceGroupID, BusinessNo: row.BusinessNo, DeliveryStatus: row.DeliveryStatus, RetryCount: row.RetryCount, diff --git a/internal/service/gameking/settlement.go b/internal/service/gameking/settlement.go index 00e883a..62e8b76 100644 --- a/internal/service/gameking/settlement.go +++ b/internal/service/gameking/settlement.go @@ -20,7 +20,7 @@ const ( staleDeliveryBatch = 20 ) -// Settle 在结算截点后冻结 TYCOON/OVERALL Top30,并发送尚未开始的 PENDING 奖励。 +// Settle 在结算截点后分别冻结 TYCOON/VICTORIOUS 的 OVERALL Top30,并发送 PENDING 奖励。 // 重复调用不会重建快照,也不会自动重试 FAILED/UNKNOWN。 func (s *Service) Settle(ctx context.Context, activityID int64) error { if activityID <= 0 { @@ -72,53 +72,71 @@ func (s *Service) freezeSettlement(ctx context.Context, activityID int64) error return err } - var users []model.YumiGameKingUser - if err := tx.Where("activity_id = ? AND total_consumed > 0", activity.ID). - Order("total_consumed DESC, consume_reached_time ASC, user_id ASC").Limit(30).Find(&users).Error; err != nil { + var rewards []model.YumiGameKingRankReward + if err := tx.Where("activity_id = ?", activity.ID).Order("ranking_type ASC, start_rank ASC").Find(&rewards).Error; err != nil { return err } - if len(users) == 0 { + if len(rewards) != len(fixedRankRanges)*2 { + return NewAppError(http.StatusConflict, "rank_reward_config_invalid", "six reward tiers are required for each ranking") + } + created := 0 + for _, rankingType := range []string{rankingTypeTycoon, rankingTypeVictorious} { + scoreColumn, reachedColumn := "total_consumed", "consume_reached_time" + if rankingType == rankingTypeVictorious { + scoreColumn, reachedColumn = "total_won", "win_reached_time" + } + var users []model.YumiGameKingUser + if err := tx.Where("activity_id = ? AND "+scoreColumn+" > 0", activity.ID). + Order(scoreColumn + " DESC, " + reachedColumn + " ASC, user_id ASC").Limit(30).Find(&users).Error; err != nil { + return err + } + boardRewards := make([]model.YumiGameKingRankReward, 0, len(fixedRankRanges)) + for _, reward := range rewards { + if reward.RankingType == rankingType { + boardRewards = append(boardRewards, reward) + } + } + for index, user := range users { + rank := index + 1 + reward, ok := rankRewardFor(boardRewards, rank) + if !ok { + return NewAppError(http.StatusConflict, "rank_reward_config_invalid", "rank reward tier does not cover Top30") + } + recordID, err := utils.NextID() + if err != nil { + return err + } + itemID, err := utils.NextID() + if err != nil { + return err + } + businessNo := fmt.Sprintf("YUMI_GAME_KING_SETTLEMENT:%d:%s:%d", activity.ID, rankingType, rank) + score := user.TotalConsumed + if rankingType == rankingTypeVictorious { + score = user.TotalWon + } + record := model.YumiGameKingSettlementRecord{ + ID: recordID, ActivityID: activity.ID, RankingType: rankingType, UserID: user.UserID, RankNo: rank, + TotalConsumed: user.TotalConsumed, Score: score, RankRewardID: reward.ID, ResourceGroupID: reward.ResourceGroupID, + BusinessNo: businessNo, DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now, + } + if err := tx.Create(&record).Error; err != nil { + return err + } + if err := tx.Create(&model.YumiGameKingDeliveryItem{ + ID: itemID, OwnerType: OwnerSettlement, OwnerID: record.ID, ActivityID: activity.ID, + UserID: user.UserID, ResourceGroupID: reward.ResourceGroupID, + DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now, + }).Error; err != nil { + return err + } + created++ + } + } + if created == 0 { return tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activity.ID). Updates(map[string]any{"settlement_status": SettlementCompleted, "update_time": now}).Error } - var rewards []model.YumiGameKingRankReward - if err := tx.Where("activity_id = ?", activity.ID).Order("start_rank ASC").Find(&rewards).Error; err != nil { - return err - } - if len(rewards) != len(fixedRankRanges) { - return NewAppError(http.StatusConflict, "rank_reward_config_invalid", "six rank reward tiers are required for settlement") - } - for index, user := range users { - rank := index + 1 - reward, ok := rankRewardFor(rewards, rank) - if !ok { - return NewAppError(http.StatusConflict, "rank_reward_config_invalid", "rank reward tier does not cover Top30") - } - recordID, err := utils.NextID() - if err != nil { - return err - } - itemID, err := utils.NextID() - if err != nil { - return err - } - businessNo := fmt.Sprintf("YUMI_GAME_KING_SETTLEMENT:%d:%d", activity.ID, rank) - record := model.YumiGameKingSettlementRecord{ - ID: recordID, ActivityID: activity.ID, UserID: user.UserID, RankNo: rank, - TotalConsumed: user.TotalConsumed, RankRewardID: reward.ID, ResourceGroupID: reward.ResourceGroupID, - BusinessNo: businessNo, DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now, - } - if err := tx.Create(&record).Error; err != nil { - return err - } - if err := tx.Create(&model.YumiGameKingDeliveryItem{ - ID: itemID, OwnerType: OwnerSettlement, OwnerID: record.ID, ActivityID: activity.ID, - UserID: user.UserID, ResourceGroupID: reward.ResourceGroupID, - DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now, - }).Error; err != nil { - return err - } - } return nil }) } diff --git a/internal/service/gameking/types.go b/internal/service/gameking/types.go index f34b4d0..8bd7a83 100644 --- a/internal/service/gameking/types.go +++ b/internal/service/gameking/types.go @@ -25,8 +25,9 @@ const ( maxRecordLimit = 100 defaultRecordLimit = 20 - rankingTypeTycoon = "TYCOON" - rankingPeriodOverall = "OVERALL" + rankingTypeTycoon = "TYCOON" + rankingTypeVictorious = "VICTORIOUS" + rankingPeriodOverall = "OVERALL" activityUpcoming = "UPCOMING" activityActive = "ACTIVE" @@ -131,6 +132,7 @@ type ActivityView struct { SettlementDelayMinutes int `json:"settlementDelayMinutes"` SettlementTime int64 `json:"settlementTime"` CoinPerDraw int64 `json:"coinPerDraw"` + DailyChanceLimit int `json:"dailyChanceLimit"` EventTypes []string `json:"eventTypes"` RankingType string `json:"rankingType"` RankingPeriod string `json:"rankingPeriod"` @@ -157,6 +159,7 @@ type PrizeView struct { type RankRewardView struct { ID int64 `json:"id,string"` ActivityID int64 `json:"activityId,string"` + RankingType string `json:"rankingType"` StartRank int `json:"startRank"` EndRank int `json:"endRank"` ResourceGroupID int64 `json:"resourceGroupId,string"` @@ -180,6 +183,8 @@ type UserStateResponse struct { EarnedChances int64 `json:"earnedChances"` UsedChances int64 `json:"usedChances"` AvailableChances int64 `json:"availableChances"` + DailyEarnedChances int64 `json:"dailyEarnedChances"` + DailyChanceLimit int `json:"dailyChanceLimit"` NextChanceRemaining int64 `json:"nextChanceRemaining"` Rank *int `json:"rank"` } @@ -255,6 +260,8 @@ type SettlementRecordView struct { UserID int64 `json:"userId,string"` Rank int `json:"rank"` TotalConsumed int64 `json:"totalConsumed"` + RankingType string `json:"rankingType"` + Score int64 `json:"score"` RankRewardID int64 `json:"rankRewardId,string"` ResourceGroupID int64 `json:"resourceGroupId,string"` BusinessNo string `json:"businessNo"` @@ -279,6 +286,7 @@ type PrizeInput struct { type RankRewardInput struct { ID FlexibleInt64 `json:"id"` + RankingType string `json:"rankingType"` StartRank int `json:"startRank"` EndRank int `json:"endRank"` ResourceGroupID FlexibleInt64 `json:"resourceGroupId"` @@ -296,6 +304,7 @@ type SaveActivityRequest struct { EndTime int64 `json:"endTime"` SettlementDelayMinutes *int `json:"settlementDelayMinutes"` CoinPerDraw int64 `json:"coinPerDraw"` + DailyChanceLimit int `json:"dailyChanceLimit"` RankingType string `json:"rankingType"` RankingPeriod string `json:"rankingPeriod"` Enabled bool `json:"enabled"` diff --git a/internal/service/taskcenter/admin.go b/internal/service/taskcenter/admin.go index cb00f30..e88fd46 100644 --- a/internal/service/taskcenter/admin.go +++ b/internal/service/taskcenter/admin.go @@ -430,7 +430,7 @@ func defaultConditionJump(conditionType string) (string, string) { switch strings.ToUpper(strings.TrimSpace(conditionType)) { case EventTypeRechargeGold: return JumpTypeRechargeInApp, "" - case EventTypeMicDurationSeconds, EventTypeGameConsumeGold, EventTypeGiftConsumeGold: + case EventTypeMicDurationSeconds, EventTypeGameConsumeGold, EventTypeGameWinGold, EventTypeGiftConsumeGold: return "ROOM_RANDOM_WITH_MIC", "" default: return "NONE", "" diff --git a/internal/service/taskcenter/archive_gc_work.go b/internal/service/taskcenter/archive_gc_work.go index 85e1daf..6a3a3fa 100644 --- a/internal/service/taskcenter/archive_gc_work.go +++ b/internal/service/taskcenter/archive_gc_work.go @@ -123,7 +123,7 @@ WHERE archive_row.status = ? AND archive_row.occurred_at < ? AND archive_row.cos_key <> '' AND ( - archive_row.event_type <> ? + archive_row.event_type NOT IN (?, ?) OR NOT EXISTS ( SELECT 1 FROM yumi_game_king_activity AS activity @@ -135,7 +135,8 @@ WHERE archive_row.status = ? ) ) ORDER BY archive_row.create_time ASC, archive_row.id ASC -LIMIT ?`, ArchiveStatusUploaded, cutoff, cutoff, cutoff, EventTypeGameConsumeGold, limit).Scan(&rows).Error +LIMIT ?`, ArchiveStatusUploaded, cutoff, cutoff, cutoff, + EventTypeGameConsumeGold, EventTypeGameWinGold, limit).Scan(&rows).Error return rows, err } @@ -483,7 +484,7 @@ func (s *Service) buildArchiveGCRecoveryKey(runID string, rows []model.TaskCente } func archiveGCActivityProtects(row model.TaskCenterEventArchiveOutbox, activities []model.YumiGameKingActivity) bool { - if row.EventType != EventTypeGameConsumeGold { + if row.EventType != EventTypeGameConsumeGold && row.EventType != EventTypeGameWinGold { return false } for _, activity := range activities { diff --git a/internal/service/taskcenter/event.go b/internal/service/taskcenter/event.go index 259baa3..12948ee 100644 --- a/internal/service/taskcenter/event.go +++ b/internal/service/taskcenter/event.go @@ -67,10 +67,10 @@ func (s *Service) ProcessEvent(ctx context.Context, req EventRequest) (*EventRes } occurredAt := parseOccurredAt(req.OccurredAt, time.Now()) receivedAt := time.Now() - // 游戏王以任务中心已经归一化的 GAME_CONSUME_GOLD 为唯一事实来源。这里必须位于 + // 游戏王以任务中心归一化的游戏消费/中奖入账事件为唯一事实来源。这里必须位于 // task-center 自身去重事务之前:即使当前没有配置任务,活动仍要统计;sink 失败时也 // 不能提前写入任务去重表,否则 MQ 重投会永久跳过活动入账。 - if eventType == EventTypeGameConsumeGold && s.gameConsumeSink != nil { + if (eventType == EventTypeGameConsumeGold || eventType == EventTypeGameWinGold) && s.gameConsumeSink != nil { if err := s.gameConsumeSink.ConsumeGameEvent(ctx, ValidatedEvent{ SysOrigin: sysOrigin, EventID: eventID, diff --git a/internal/service/taskcenter/types.go b/internal/service/taskcenter/types.go index 0c8690c..98d7994 100644 --- a/internal/service/taskcenter/types.go +++ b/internal/service/taskcenter/types.go @@ -23,6 +23,7 @@ const ( EventTypeMicDurationSeconds = "MIC_DURATION_SECONDS" EventTypeGameConsumeGold = "GAME_CONSUME_GOLD" + EventTypeGameWinGold = "GAME_WIN_GOLD" EventTypeGiftConsumeGold = "GIFT_CONSUME_GOLD" EventTypeRechargeGold = "RECHARGE_GOLD" @@ -74,7 +75,7 @@ type ValidatedEvent struct { Payload map[string]any } -// GameConsumeSink 接收权威 GAME_CONSUME_GOLD 事件。 +// GameConsumeSink 接收权威游戏消费与中奖入账事件;保留名称以避免扩大初始化改动面。 // sink 自己必须通过业务唯一键幂等,因为 HTTP 与 MQ 可能同时送达同一事件。 type GameConsumeSink interface { ConsumeGameEvent(context.Context, ValidatedEvent) error @@ -362,7 +363,7 @@ func validateCategory(category string) error { func isAllowedConditionType(conditionType string) bool { switch strings.ToUpper(strings.TrimSpace(conditionType)) { - case EventTypeMicDurationSeconds, EventTypeGameConsumeGold, EventTypeGiftConsumeGold, EventTypeRechargeGold: + case EventTypeMicDurationSeconds, EventTypeGameConsumeGold, EventTypeGameWinGold, EventTypeGiftConsumeGold, EventTypeRechargeGold: return true default: return false diff --git a/migrations/062_yumi_game_king_dual_ranking_daily_limit.sql b/migrations/062_yumi_game_king_dual_ranking_daily_limit.sql new file mode 100644 index 0000000..ef78968 --- /dev/null +++ b/migrations/062_yumi_game_king_dual_ranking_daily_limit.sql @@ -0,0 +1,67 @@ +-- Yumi Game King: real winning-income ranking, separate reward configs, and daily chance cap. +-- +-- Performance review: +-- 1. Activity/rank-reward/settlement are small configuration/result tables; their ALTERs are +-- bounded metadata/index rebuilds and do not touch the 35M-row task archive. +-- 2. User aggregate ALTERs add two narrow columns plus ranking indexes. Run during low traffic: +-- MySQL builds each index once; online DDL keeps reads/writes available with LOCK=NONE. +-- 3. No historical ledger is scanned or backfilled here. New winning rows arrive from the +-- canonical GAME_WIN_GOLD event. The daily-user index makes chance sums proportional to one +-- user's active dates instead of all activity participants. +-- 4. Existing six TYCOON reward rows are copied once to VICTORIOUS so old activities remain +-- editable; operators can replace each board's six resource groups before enabling. + +ALTER TABLE `yumi_game_king_activity` + ADD COLUMN `daily_chance_limit` INT NOT NULL DEFAULT 10 AFTER `coin_per_draw`, + ALGORITHM=INSTANT; + +ALTER TABLE `yumi_game_king_rank_reward` + DROP INDEX `uk_yumi_gk_rank_range`, + DROP INDEX `idx_yumi_gk_rank_start`, + ADD COLUMN `ranking_type` VARCHAR(32) NOT NULL DEFAULT 'TYCOON' AFTER `activity_id`, + ADD UNIQUE KEY `uk_yumi_gk_rank_range` (`activity_id`, `ranking_type`, `start_rank`, `end_rank`), + ADD KEY `idx_yumi_gk_rank_start` (`activity_id`, `ranking_type`, `start_rank`), + ALGORITHM=INPLACE, LOCK=NONE; + +INSERT INTO `yumi_game_king_rank_reward` ( + `id`, `activity_id`, `ranking_type`, `start_rank`, `end_rank`, `resource_group_id`, + `reward_name`, `reward_items_json`, `create_time`, `update_time` +) +SELECT UUID_SHORT(), `activity_id`, 'VICTORIOUS', `start_rank`, `end_rank`, `resource_group_id`, + `reward_name`, `reward_items_json`, `create_time`, `update_time` +FROM `yumi_game_king_rank_reward` +WHERE `ranking_type` = 'TYCOON'; + +ALTER TABLE `yumi_game_king_consume_ledger` + ADD COLUMN `event_type` VARCHAR(32) NOT NULL DEFAULT 'GAME_CONSUME_GOLD' AFTER `sys_origin`, + ALGORITHM=INSTANT; + +ALTER TABLE `yumi_game_king_user` + ADD COLUMN `total_won` BIGINT NOT NULL DEFAULT 0 AFTER `total_consumed`, + ADD COLUMN `win_reached_time` DATETIME(3) NULL AFTER `consume_reached_time`, + ADD KEY `idx_yumi_gk_user_win_rank` ( + `activity_id`, `total_won` DESC, `win_reached_time` ASC, `user_id` ASC + ), + ALGORITHM=INPLACE, LOCK=NONE; + +ALTER TABLE `yumi_game_king_user_daily` + ADD COLUMN `total_won` BIGINT NOT NULL DEFAULT 0 AFTER `total_consumed`, + ADD COLUMN `win_reached_time` DATETIME(3) NULL AFTER `consume_reached_time`, + ADD KEY `idx_yumi_gk_daily_user_chance` (`activity_id`, `user_id`, `stat_date`), + ADD KEY `idx_yumi_gk_daily_win_rank` ( + `activity_id`, `stat_date`, `total_won` DESC, `win_reached_time` ASC, `user_id` ASC + ), + ALGORITHM=INPLACE, LOCK=NONE; + +ALTER TABLE `yumi_game_king_settlement_record` + DROP INDEX `uk_yumi_gk_settle_user`, + DROP INDEX `uk_yumi_gk_settle_rank`, + ADD COLUMN `ranking_type` VARCHAR(32) NOT NULL DEFAULT 'TYCOON' AFTER `activity_id`, + ADD COLUMN `score` BIGINT NOT NULL DEFAULT 0 AFTER `total_consumed`, + ADD UNIQUE KEY `uk_yumi_gk_settle_user` (`activity_id`, `ranking_type`, `user_id`), + ADD UNIQUE KEY `uk_yumi_gk_settle_rank` (`activity_id`, `ranking_type`, `rank_no`), + ALGORITHM=INPLACE, LOCK=NONE; + +UPDATE `yumi_game_king_settlement_record` +SET `score` = `total_consumed` +WHERE `ranking_type` = 'TYCOON' AND `score` = 0;