feat: add dual game king rankings

This commit is contained in:
hy001 2026-07-22 18:54:53 +08:00
parent 5cc47b90f6
commit 175f4ac8e6
13 changed files with 392 additions and 134 deletions

View File

@ -15,6 +15,7 @@ type YumiGameKingActivity struct {
SettlementDelayMinutes int `gorm:"column:settlement_delay_minutes"` SettlementDelayMinutes int `gorm:"column:settlement_delay_minutes"`
SettlementTime time.Time `gorm:"column:settlement_time;index:idx_yumi_gk_activity_settle,priority:2"` SettlementTime time.Time `gorm:"column:settlement_time;index:idx_yumi_gk_activity_settle,priority:2"`
CoinPerDraw int64 `gorm:"column:coin_per_draw"` CoinPerDraw int64 `gorm:"column:coin_per_draw"`
DailyChanceLimit int `gorm:"column:daily_chance_limit"`
RankingType string `gorm:"column:ranking_type;size:32"` RankingType string `gorm:"column:ranking_type;size:32"`
RankingPeriod string `gorm:"column:ranking_period;size:32"` RankingPeriod string `gorm:"column:ranking_period;size:32"`
Enabled bool `gorm:"column:enabled;index:idx_yumi_gk_activity_window,priority:2"` 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 { type YumiGameKingRankReward struct {
ID int64 `gorm:"column:id;primaryKey"` 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"` 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"` RankingType string `gorm:"column:ranking_type;size:32;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"` 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"` ResourceGroupID int64 `gorm:"column:resource_group_id"`
RewardName string `gorm:"column:reward_name;size:128"` RewardName string `gorm:"column:reward_name;size:128"`
RewardItemsJSON string `gorm:"column:reward_items_json;type:text"` 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"` 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"` UserID int64 `gorm:"column:user_id;index:idx_yumi_gk_ledger_user,priority:2"`
SysOrigin string `gorm:"column:sys_origin;size:32"` SysOrigin string `gorm:"column:sys_origin;size:32"`
EventType string `gorm:"column:event_type;size:32"`
Amount int64 `gorm:"column:amount"` Amount int64 `gorm:"column:amount"`
EventTime time.Time `gorm:"column:event_time;index:idx_yumi_gk_ledger_user,priority:3"` EventTime time.Time `gorm:"column:event_time;index:idx_yumi_gk_ledger_user,priority:3"`
PayloadJSON string `gorm:"column:payload_json;type:text"` PayloadJSON string `gorm:"column:payload_json;type:text"`
@ -99,11 +102,13 @@ func (YumiGameKingBackfillState) TableName() string { return "yumi_game_king_bac
// YumiGameKingUser 保存榜单聚合和已使用抽奖次数,避免排行榜扫描消费账本。 // YumiGameKingUser 保存榜单聚合和已使用抽奖次数,避免排行榜扫描消费账本。
type YumiGameKingUser struct { type YumiGameKingUser struct {
ActivityID int64 `gorm:"column:activity_id;primaryKey;index:idx_yumi_gk_user_rank,priority:1"` 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"` 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"` 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"` UsedChances int64 `gorm:"column:used_chances"`
ConsumeReachedTime time.Time `gorm:"column:consume_reached_time;index:idx_yumi_gk_user_rank,priority:3,sort:asc"` 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"` CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"` UpdateTime time.Time `gorm:"column:update_time"`
} }
@ -112,11 +117,13 @@ func (YumiGameKingUser) TableName() string { return "yumi_game_king_user" }
// YumiGameKingUserDaily 保存活动时区自然日聚合DAILY 排行不扫描消费明细。 // YumiGameKingUserDaily 保存活动时区自然日聚合DAILY 排行不扫描消费明细。
type YumiGameKingUserDaily struct { type YumiGameKingUserDaily struct {
ActivityID int64 `gorm:"column:activity_id;primaryKey;index:idx_yumi_gk_daily_rank,priority:1"` 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"` 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"` 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"` 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"` 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"` CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_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 { type YumiGameKingSettlementRecord struct {
ID int64 `gorm:"column:id;primaryKey"` 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"` 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"` RankingType string `gorm:"column:ranking_type;size:32;uniqueIndex:uk_yumi_gk_settle_user,priority:2;uniqueIndex:uk_yumi_gk_settle_rank,priority:2"`
RankNo int `gorm:"column:rank_no;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"` TotalConsumed int64 `gorm:"column:total_consumed"`
Score int64 `gorm:"column:score"`
RankRewardID int64 `gorm:"column:rank_reward_id"` RankRewardID int64 `gorm:"column:rank_reward_id"`
ResourceGroupID int64 `gorm:"column:resource_group_id"` ResourceGroupID int64 `gorm:"column:resource_group_id"`
BusinessNo string `gorm:"column:business_no;size:128;uniqueIndex:uk_yumi_gk_settle_business"` BusinessNo string `gorm:"column:business_no;size:128;uniqueIndex:uk_yumi_gk_settle_business"`

View File

@ -66,7 +66,7 @@ func (s *Service) buildDetail(ctx context.Context, activity model.YumiGameKingAc
Activity: activityView(activity), Activity: activityView(activity),
Prizes: prizeViews, Prizes: prizeViews,
RankRewards: rankViews, RankRewards: rankViews,
SupportedRankingTypes: []string{rankingTypeTycoon}, SupportedRankingTypes: []string{rankingTypeTycoon, rankingTypeVictorious},
SupportedRankingPeriods: []string{"DAILY", rankingPeriodOverall}, SupportedRankingPeriods: []string{"DAILY", rankingPeriodOverall},
ActivityStatus: activityStatus(activity, now), ActivityStatus: activityStatus(activity, now),
ServerTime: now.UnixMilli(), ServerTime: now.UnixMilli(),
@ -210,6 +210,7 @@ func (s *Service) SaveActivity(ctx context.Context, req SaveActivityRequest) (*A
row.SettlementDelayMinutes = *normalized.SettlementDelayMinutes row.SettlementDelayMinutes = *normalized.SettlementDelayMinutes
row.SettlementTime = row.EndTime.Add(time.Duration(row.SettlementDelayMinutes) * time.Minute) row.SettlementTime = row.EndTime.Add(time.Duration(row.SettlementDelayMinutes) * time.Minute)
row.CoinPerDraw = normalized.CoinPerDraw row.CoinPerDraw = normalized.CoinPerDraw
row.DailyChanceLimit = normalized.DailyChanceLimit
row.RankingType = normalized.RankingType row.RankingType = normalized.RankingType
row.RankingPeriod = normalized.RankingPeriod row.RankingPeriod = normalized.RankingPeriod
row.Enabled = normalized.Enabled 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) { func (s *Service) loadRankRewards(ctx context.Context, activityID int64) ([]model.YumiGameKingRankReward, error) {
var rows []model.YumiGameKingRankReward 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 return rows, err
} }
@ -463,7 +464,7 @@ func (s *Service) currentRankInputs(ctx context.Context, activityID int64) ([]Ra
} }
values := make([]RankRewardInput, 0, len(rows)) values := make([]RankRewardInput, 0, len(rows))
for _, row := range 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 return values, nil
} }
@ -499,7 +500,7 @@ func replaceRankRewardsTx(tx *gorm.DB, activityID int64, values []normalizedRank
return err return err
} }
rows = append(rows, model.YumiGameKingRankReward{ 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, ResourceGroupID: value.Snapshot.GroupID, RewardName: value.Input.RewardName,
RewardItemsJSON: value.Snapshot.ItemsJSON, CreateTime: now, UpdateTime: now, 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 || if row.ActivityCode != req.ActivityCode || row.SysOrigin != req.SysOrigin || row.Timezone != req.Timezone ||
row.StartTime.UnixMilli() != req.StartTime || row.EndTime.UnixMilli() != req.EndTime || row.StartTime.UnixMilli() != req.StartTime || row.EndTime.UnixMilli() != req.EndTime ||
row.SettlementDelayMinutes != *req.SettlementDelayMinutes || row.CoinPerDraw != req.CoinPerDraw || row.SettlementDelayMinutes != *req.SettlementDelayMinutes || row.CoinPerDraw != req.CoinPerDraw ||
row.DailyChanceLimit != req.DailyChanceLimit ||
row.RankingType != req.RankingType || row.RankingPeriod != req.RankingPeriod || row.Enabled != req.Enabled { 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") return NewAppError(http.StatusConflict, "activity_config_locked", "activity scope, time, chance rule and enable state cannot change after activity start")
} }

View File

@ -73,7 +73,11 @@ func (s *Service) Draw(ctx context.Context, user AuthUser, req DrawRequest) (*Dr
return err return err
} }
replayed = true 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 { if remaining < 0 {
remaining = 0 remaining = 0
} }
@ -82,7 +86,10 @@ func (s *Service) Draw(ctx context.Context, user AuthUser, req DrawRequest) (*Dr
return err return err
} }
earned := userRow.TotalConsumed / lockedActivity.CoinPerDraw earned, _, _, earnedErr := earnedChancesTx(tx, lockedActivity, user.UserID)
if earnedErr != nil {
return earnedErr
}
if userRow.UsedChances >= earned { if userRow.UsedChances >= earned {
return NewAppError(http.StatusConflict, "draw_chance_unavailable", "no draw chance is available") 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) { 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 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 { if remaining < 0 {
remaining = 0 remaining = 0
} }
return &DrawResult{Record: drawRecordView(record), Prize: prizeView(prize), RemainingChances: remaining, IdempotentReplay: true}, nil 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) { func selectAndReservePrizeTx(tx *gorm.DB, activityID int64) (model.YumiGameKingPrize, error) {
for attempt := 0; attempt < 20; attempt++ { for attempt := 0; attempt < 20; attempt++ {
var rows []model.YumiGameKingPrize var rows []model.YumiGameKingPrize

View File

@ -16,14 +16,16 @@ import (
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
// ConsumeGameEvent 实现 taskcenter.GameConsumeSink。GAME_CONSUME_GOLD 是唯一统计入口, // ConsumeGameEvent 实现 taskcenter.GameConsumeSink,只接受 Java 游戏回调归一化后的
// 不再允许后台配置钱包 eventType 或把非游戏支出混入活动 // GAME_CONSUME_GOLD 与 GAME_WIN_GOLD避免礼物、充值或活动奖励进入两个榜单
func (s *Service) ConsumeGameEvent(ctx context.Context, event taskcenter.ValidatedEvent) error { 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 return nil
} }
if event.UserID <= 0 || event.DeltaValue <= 0 || strings.TrimSpace(event.EventID) == "" { 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.SysOrigin = normalizeSysOrigin(event.SysOrigin)
event.EventID = strings.TrimSpace(event.EventID) event.EventID = strings.TrimSpace(event.EventID)
@ -47,6 +49,7 @@ func (s *Service) ConsumeGameEvent(ctx context.Context, event taskcenter.Validat
// applyEventToActivity 在一个事务内完成账本幂等、总榜和活动时区日榜聚合。 // applyEventToActivity 在一个事务内完成账本幂等、总榜和活动时区日榜聚合。
func (s *Service) applyEventToActivity(ctx context.Context, activityID int64, event taskcenter.ValidatedEvent) (bool, error) { func (s *Service) applyEventToActivity(ctx context.Context, activityID int64, event taskcenter.ValidatedEvent) (bool, error) {
accepted := false accepted := false
isConsume := strings.EqualFold(event.EventType, taskcenter.EventTypeGameConsumeGold)
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var activity model.YumiGameKingActivity var activity model.YumiGameKingActivity
// 事件仅持有共享活动锁:不同用户可并发入账;结算使用排他锁并等待所有已进入的 // 事件仅持有共享活动锁:不同用户可并发入账;结算使用排他锁并等待所有已进入的
@ -67,7 +70,7 @@ func (s *Service) applyEventToActivity(ctx context.Context, activityID int64, ev
payloadJSON, _ := json.Marshal(event.Payload) payloadJSON, _ := json.Marshal(event.Payload)
ledger := model.YumiGameKingConsumeLedger{ ledger := model.YumiGameKingConsumeLedger{
ID: ledgerID, ActivityID: activity.ID, EventID: event.EventID, UserID: event.UserID, 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(), PayloadJSON: string(payloadJSON), CreateTime: time.Now(),
} }
result := tx.Clauses(clause.OnConflict{ result := tx.Clauses(clause.OnConflict{
@ -82,26 +85,61 @@ func (s *Service) applyEventToActivity(ctx context.Context, activityID int64, ev
} }
now := time.Now() 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) _, location, err := normalizeTimezone(activity.Timezone)
if err != nil { if err != nil {
return err 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 { if err := addDailyConsumeTx(tx, activity.ID, dateKey(event.OccurredAt, location), event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil {
return err 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 accepted = true
return nil return nil
}) })
return accepted, err 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 { func addOverallConsumeTx(tx *gorm.DB, activityID, userID, amount int64, eventTime, now time.Time) error {
row := model.YumiGameKingUser{ row := model.YumiGameKingUser{
ActivityID: activityID, UserID: userID, TotalConsumed: amount, UsedChances: 0, 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 冲突或死锁, // 首笔事件不能“SELECT 未命中后 INSERT”同一用户并发首笔会发生 PK 冲突或死锁,
// 同步上报若不重试就会漏计。单条 upsert 由聚合主键串行累加,并且不覆盖 used_chances。 // 同步上报若不重试就会漏计。单条 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 { func addDailyConsumeTx(tx *gorm.DB, activityID int64, statDate time.Time, userID, amount int64, eventTime, now time.Time) error {
row := model.YumiGameKingUserDaily{ row := model.YumiGameKingUserDaily{
ActivityID: activityID, StatDate: statDate, UserID: userID, TotalConsumed: amount, 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{ return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "activity_id"}, {Name: "stat_date"}, {Name: "user_id"}}, Columns: []clause.Column{{Name: "activity_id"}, {Name: "stat_date"}, {Name: "user_id"}},

View File

@ -45,8 +45,8 @@ func normalizeRankingType(value string) (string, error) {
if value == "" { if value == "" {
value = rankingTypeTycoon value = rankingTypeTycoon
} }
if value != rankingTypeTycoon { if value != rankingTypeTycoon && value != rankingTypeVictorious {
return "", NewAppError(http.StatusBadRequest, "invalid_ranking_type", "rankingType must be TYCOON") return "", NewAppError(http.StatusBadRequest, "invalid_ranking_type", "rankingType must be TYCOON or VICTORIOUS")
} }
return value, nil return value, nil
} }
@ -97,6 +97,9 @@ func normalizeActivityInput(req SaveActivityRequest) (SaveActivityRequest, error
if req.CoinPerDraw <= 0 { if req.CoinPerDraw <= 0 {
return req, NewAppError(http.StatusBadRequest, "invalid_coin_per_draw", "coinPerDraw must be greater than zero") 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) req.RankingType, err = normalizeRankingType(req.RankingType)
if err != nil { if err != nil {
return req, err return req, err
@ -150,20 +153,26 @@ func (s *Service) normalizePrizes(ctx context.Context, sysOrigin string, values
} }
func (s *Service) normalizeRankRewards(ctx context.Context, sysOrigin string, values []RankRewardInput) ([]normalizedRankReward, error) { func (s *Service) normalizeRankRewards(ctx context.Context, sysOrigin string, values []RankRewardInput) ([]normalizedRankReward, error) {
if len(values) != len(fixedRankRanges) { if len(values) != len(fixedRankRanges)*2 {
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_count", "exactly six rank reward tiers are required") 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)) byRange := make(map[string]RankRewardInput, len(values))
for _, value := range 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 { if _, exists := byRange[key]; exists {
return nil, NewAppError(http.StatusBadRequest, "duplicate_rank_reward", "rank reward ranges must be unique") return nil, NewAppError(http.StatusBadRequest, "duplicate_rank_reward", "rank reward ranges must be unique")
} }
byRange[key] = value byRange[key] = value
} }
result := make([]normalizedRankReward, 0, len(fixedRankRanges)) result := make([]normalizedRankReward, 0, len(fixedRankRanges)*2)
for _, rankingType := range []string{rankingTypeTycoon, rankingTypeVictorious} {
for _, rankRange := range fixedRankRanges { for _, rankRange := range fixedRankRanges {
key := fmt.Sprintf("%d-%d", rankRange[0], rankRange[1]) key := fmt.Sprintf("%s:%d-%d", rankingType, rankRange[0], rankRange[1])
value, exists := byRange[key] value, exists := byRange[key]
if !exists { 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") return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_ranges", "rank rewards must be 1, 2, 3, 4-7, 8-10 and 11-30")
@ -184,6 +193,7 @@ func (s *Service) normalizeRankRewards(ctx context.Context, sysOrigin string, va
} }
result = append(result, normalizedRankReward{Input: value, Snapshot: snapshot}) result = append(result, normalizedRankReward{Input: value, Snapshot: snapshot})
} }
}
return result, nil return result, nil
} }
@ -274,7 +284,8 @@ func activityView(row model.YumiGameKingActivity) ActivityView {
SettlementDelayMinutes: row.SettlementDelayMinutes, SettlementDelayMinutes: row.SettlementDelayMinutes,
SettlementTime: unixMilli(row.SettlementTime), SettlementTime: unixMilli(row.SettlementTime),
CoinPerDraw: row.CoinPerDraw, CoinPerDraw: row.CoinPerDraw,
EventTypes: []string{"GAME_CONSUME_GOLD"}, DailyChanceLimit: row.DailyChanceLimit,
EventTypes: []string{"GAME_CONSUME_GOLD", "GAME_WIN_GOLD"},
RankingType: row.RankingType, RankingType: row.RankingType,
RankingPeriod: row.RankingPeriod, RankingPeriod: row.RankingPeriod,
Enabled: row.Enabled, Enabled: row.Enabled,
@ -304,6 +315,7 @@ func rankRewardView(row model.YumiGameKingRankReward) RankRewardView {
return RankRewardView{ return RankRewardView{
ID: row.ID, ID: row.ID,
ActivityID: row.ActivityID, ActivityID: row.ActivityID,
RankingType: row.RankingType,
StartRank: row.StartRank, StartRank: row.StartRank,
EndRank: row.EndRank, EndRank: row.EndRank,
ResourceGroupID: row.ResourceGroupID, ResourceGroupID: row.ResourceGroupID,

View File

@ -29,19 +29,23 @@ func (s *Service) Me(ctx context.Context, user AuthUser, activityID int64) (*Use
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
row = model.YumiGameKingUser{ActivityID: activity.ID, UserID: user.UserID} 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 available := earned - row.UsedChances
if available < 0 { if available < 0 {
available = 0 available = 0
} }
remainder := row.TotalConsumed % activity.CoinPerDraw remainder := dailyConsumed % activity.CoinPerDraw
next := activity.CoinPerDraw next := activity.CoinPerDraw
if remainder > 0 { // 达到日上限后,下一次资格只能从次日重新累计;进度也必须按当日消费计算。
if dailyEarned < int64(activity.DailyChanceLimit) && remainder > 0 {
next -= remainder next -= remainder
} }
var rank *int var rank *int
if row.TotalConsumed > 0 { 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 { if rankErr != nil {
return nil, rankErr return nil, rankErr
} }
@ -49,12 +53,38 @@ func (s *Service) Me(ctx context.Context, user AuthUser, activityID int64) (*Use
} }
return &UserStateResponse{ return &UserStateResponse{
ActivityID: activity.ID, TotalConsumed: row.TotalConsumed, EarnedChances: earned, 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 }, nil
} }
// Ranking 返回活动时区日榜或总榜。VICTORIOUS 是旧 H5 兼容维度;当前活动没有胜利事件, // earnedChances 按活动时区日聚合逐日封顶;只读取该用户参与过的活动日期,不扫描事件账本。
// 因此显式请求时返回空榜而不是 400supportedRankingTypes 会让新 H5 隐藏该页签。 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) { 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()) activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), activityID, time.Now())
if err != nil { if err != nil {
@ -71,7 +101,7 @@ func (s *Service) Ranking(ctx context.Context, user AuthUser, activityID int64,
if period != "DAILY" && period != rankingPeriodOverall { if period != "DAILY" && period != rankingPeriodOverall {
return nil, NewAppError(http.StatusBadRequest, "invalid_ranking_period", "period must be DAILY or OVERALL") 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") return nil, NewAppError(http.StatusBadRequest, "invalid_ranking_type", "rankingType must be TYCOON or VICTORIOUS")
} }
if limit <= 0 { if limit <= 0 {
@ -81,11 +111,11 @@ func (s *Service) Ranking(ctx context.Context, user AuthUser, activityID int64,
limit = maxRankingLimit limit = maxRankingLimit
} }
response := &RankingResponse{RankingType: rankingType, Period: period, Entries: []RankEntryView{}} response := &RankingResponse{RankingType: rankingType, Period: period, Entries: []RankEntryView{}}
if rankingType == "VICTORIOUS" {
return response, nil
}
now := time.Now() now := time.Now()
scoreColumn, reachedColumn := "total_consumed", "consume_reached_time"
if rankingType == rankingTypeVictorious {
scoreColumn, reachedColumn = "total_won", "win_reached_time"
}
if period == "DAILY" { if period == "DAILY" {
_, location, timezoneErr := normalizeTimezone(activity.Timezone) _, location, timezoneErr := normalizeTimezone(activity.Timezone)
if timezoneErr != nil { 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") response.StatDate = now.In(location).Format("2006-01-02")
var rows []model.YumiGameKingUserDaily var rows []model.YumiGameKingUserDaily
if err := s.db.WithContext(ctx). if err := s.db.WithContext(ctx).
Where("activity_id = ? AND stat_date = ? AND total_consumed > 0", activity.ID, statDate). Where("activity_id = ? AND stat_date = ? AND "+scoreColumn+" > 0", activity.ID, statDate).
Order("total_consumed DESC, consume_reached_time ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil { Order(scoreColumn + " DESC, " + reachedColumn + " ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
for index, row := range rows { 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) response.Entries = append(response.Entries, entry)
} }
} else { } else {
var rows []model.YumiGameKingUser var rows []model.YumiGameKingUser
if err := s.db.WithContext(ctx). if err := s.db.WithContext(ctx).
Where("activity_id = ? AND total_consumed > 0", activity.ID). Where("activity_id = ? AND "+scoreColumn+" > 0", activity.ID).
Order("total_consumed DESC, consume_reached_time ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil { Order(scoreColumn + " DESC, " + reachedColumn + " ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
for index, row := range rows { 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) response.Entries = append(response.Entries, entry)
} }
} }
s.hydrateRankProfiles(ctx, response.Entries) s.hydrateRankProfiles(ctx, response.Entries)
if user.UserID > 0 { 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 { if err != nil {
return nil, err return nil, err
} }
@ -126,8 +164,8 @@ func (s *Service) Ranking(ctx context.Context, user AuthUser, activityID int64,
return response, nil return response, nil
} }
func (s *Service) myRankEntry(ctx context.Context, activity model.YumiGameKingActivity, userID int64, period string, now time.Time) (*RankEntryView, error) { 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, period, now) rank, err := s.userRank(ctx, activity, userID, rankingType, period, now)
if err != nil || rank == nil { if err != nil || rank == nil {
return nil, err return nil, err
} }
@ -142,6 +180,9 @@ func (s *Service) myRankEntry(ctx context.Context, activity model.YumiGameKingAc
return nil, err return nil, err
} }
entry.Score, entry.TotalConsumed = row.TotalConsumed, row.TotalConsumed entry.Score, entry.TotalConsumed = row.TotalConsumed, row.TotalConsumed
if rankingType == rankingTypeVictorious {
entry.Score, entry.TotalConsumed, entry.TotalWon = row.TotalWon, 0, row.TotalWon
}
} else { } else {
var row model.YumiGameKingUser var row model.YumiGameKingUser
if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, userID).First(&row).Error; err != nil { 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 return nil, err
} }
entry.Score, entry.TotalConsumed = row.TotalConsumed, row.TotalConsumed 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 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" { if period == "DAILY" {
_, location, _ := normalizeTimezone(activity.Timezone) _, location, _ := normalizeTimezone(activity.Timezone)
statDate := dateKey(now, location) statDate := dateKey(now, location)
@ -166,10 +214,17 @@ func (s *Service) userRank(ctx context.Context, activity model.YumiGameKingActiv
} }
return nil, err 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 var ahead int64
if err := s.db.WithContext(ctx).Model(&model.YumiGameKingUserDaily{}). 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 < ?))))", Where("activity_id = ? AND stat_date = ? AND ("+scoreColumn+" > ? OR ("+scoreColumn+" = ? AND ("+reachedColumn+" < ? OR ("+reachedColumn+" = ? AND user_id < ?))))",
activity.ID, statDate, row.TotalConsumed, row.TotalConsumed, row.ConsumeReachedTime, row.ConsumeReachedTime, userID). activity.ID, statDate, score, score, reached, reached, userID).
Count(&ahead).Error; err != nil { Count(&ahead).Error; err != nil {
return nil, err return nil, err
} }
@ -183,10 +238,17 @@ func (s *Service) userRank(ctx context.Context, activity model.YumiGameKingActiv
} }
return nil, err 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 var ahead int64
if err := s.db.WithContext(ctx).Model(&model.YumiGameKingUser{}). 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 < ?))))", Where("activity_id = ? AND ("+scoreColumn+" > ? OR ("+scoreColumn+" = ? AND ("+reachedColumn+" < ? OR ("+reachedColumn+" = ? AND user_id < ?))))",
activity.ID, row.TotalConsumed, row.TotalConsumed, row.ConsumeReachedTime, row.ConsumeReachedTime, userID). activity.ID, score, score, reached, reached, userID).
Count(&ahead).Error; err != nil { Count(&ahead).Error; err != nil {
return nil, err return nil, err
} }
@ -322,6 +384,7 @@ func drawRecordView(row model.YumiGameKingDrawRecord) DrawRecordView {
func settlementRecordView(row model.YumiGameKingSettlementRecord) SettlementRecordView { func settlementRecordView(row model.YumiGameKingSettlementRecord) SettlementRecordView {
return SettlementRecordView{ return SettlementRecordView{
ID: row.ID, ActivityID: row.ActivityID, UserID: row.UserID, Rank: row.RankNo, ID: row.ID, ActivityID: row.ActivityID, UserID: row.UserID, Rank: row.RankNo,
RankingType: row.RankingType, Score: row.Score,
TotalConsumed: row.TotalConsumed, RankRewardID: row.RankRewardID, TotalConsumed: row.TotalConsumed, RankRewardID: row.RankRewardID,
ResourceGroupID: row.ResourceGroupID, BusinessNo: row.BusinessNo, ResourceGroupID: row.ResourceGroupID, BusinessNo: row.BusinessNo,
DeliveryStatus: row.DeliveryStatus, RetryCount: row.RetryCount, DeliveryStatus: row.DeliveryStatus, RetryCount: row.RetryCount,

View File

@ -20,7 +20,7 @@ const (
staleDeliveryBatch = 20 staleDeliveryBatch = 20
) )
// Settle 在结算截点后冻结 TYCOON/OVERALL Top30并发送尚未开始的 PENDING 奖励。 // Settle 在结算截点后分别冻结 TYCOON/VICTORIOUS 的 OVERALL Top30并发送 PENDING 奖励。
// 重复调用不会重建快照,也不会自动重试 FAILED/UNKNOWN。 // 重复调用不会重建快照,也不会自动重试 FAILED/UNKNOWN。
func (s *Service) Settle(ctx context.Context, activityID int64) error { func (s *Service) Settle(ctx context.Context, activityID int64) error {
if activityID <= 0 { if activityID <= 0 {
@ -72,25 +72,33 @@ func (s *Service) freezeSettlement(ctx context.Context, activityID int64) error
return err 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 {
return err
}
if len(users) == 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 var rewards []model.YumiGameKingRankReward
if err := tx.Where("activity_id = ?", activity.ID).Order("start_rank ASC").Find(&rewards).Error; err != nil { if err := tx.Where("activity_id = ?", activity.ID).Order("ranking_type ASC, start_rank ASC").Find(&rewards).Error; err != nil {
return err return err
} }
if len(rewards) != len(fixedRankRanges) { if len(rewards) != len(fixedRankRanges)*2 {
return NewAppError(http.StatusConflict, "rank_reward_config_invalid", "six rank reward tiers are required for settlement") 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 { for index, user := range users {
rank := index + 1 rank := index + 1
reward, ok := rankRewardFor(rewards, rank) reward, ok := rankRewardFor(boardRewards, rank)
if !ok { if !ok {
return NewAppError(http.StatusConflict, "rank_reward_config_invalid", "rank reward tier does not cover Top30") return NewAppError(http.StatusConflict, "rank_reward_config_invalid", "rank reward tier does not cover Top30")
} }
@ -102,10 +110,14 @@ func (s *Service) freezeSettlement(ctx context.Context, activityID int64) error
if err != nil { if err != nil {
return err return err
} }
businessNo := fmt.Sprintf("YUMI_GAME_KING_SETTLEMENT:%d:%d", activity.ID, rank) 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{ record := model.YumiGameKingSettlementRecord{
ID: recordID, ActivityID: activity.ID, UserID: user.UserID, RankNo: rank, ID: recordID, ActivityID: activity.ID, RankingType: rankingType, UserID: user.UserID, RankNo: rank,
TotalConsumed: user.TotalConsumed, RankRewardID: reward.ID, ResourceGroupID: reward.ResourceGroupID, TotalConsumed: user.TotalConsumed, Score: score, RankRewardID: reward.ID, ResourceGroupID: reward.ResourceGroupID,
BusinessNo: businessNo, DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now, BusinessNo: businessNo, DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
} }
if err := tx.Create(&record).Error; err != nil { if err := tx.Create(&record).Error; err != nil {
@ -118,6 +130,12 @@ func (s *Service) freezeSettlement(ctx context.Context, activityID int64) error
}).Error; err != nil { }).Error; err != nil {
return err 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
} }
return nil return nil
}) })

View File

@ -26,6 +26,7 @@ const (
defaultRecordLimit = 20 defaultRecordLimit = 20
rankingTypeTycoon = "TYCOON" rankingTypeTycoon = "TYCOON"
rankingTypeVictorious = "VICTORIOUS"
rankingPeriodOverall = "OVERALL" rankingPeriodOverall = "OVERALL"
activityUpcoming = "UPCOMING" activityUpcoming = "UPCOMING"
@ -131,6 +132,7 @@ type ActivityView struct {
SettlementDelayMinutes int `json:"settlementDelayMinutes"` SettlementDelayMinutes int `json:"settlementDelayMinutes"`
SettlementTime int64 `json:"settlementTime"` SettlementTime int64 `json:"settlementTime"`
CoinPerDraw int64 `json:"coinPerDraw"` CoinPerDraw int64 `json:"coinPerDraw"`
DailyChanceLimit int `json:"dailyChanceLimit"`
EventTypes []string `json:"eventTypes"` EventTypes []string `json:"eventTypes"`
RankingType string `json:"rankingType"` RankingType string `json:"rankingType"`
RankingPeriod string `json:"rankingPeriod"` RankingPeriod string `json:"rankingPeriod"`
@ -157,6 +159,7 @@ type PrizeView struct {
type RankRewardView struct { type RankRewardView struct {
ID int64 `json:"id,string"` ID int64 `json:"id,string"`
ActivityID int64 `json:"activityId,string"` ActivityID int64 `json:"activityId,string"`
RankingType string `json:"rankingType"`
StartRank int `json:"startRank"` StartRank int `json:"startRank"`
EndRank int `json:"endRank"` EndRank int `json:"endRank"`
ResourceGroupID int64 `json:"resourceGroupId,string"` ResourceGroupID int64 `json:"resourceGroupId,string"`
@ -180,6 +183,8 @@ type UserStateResponse struct {
EarnedChances int64 `json:"earnedChances"` EarnedChances int64 `json:"earnedChances"`
UsedChances int64 `json:"usedChances"` UsedChances int64 `json:"usedChances"`
AvailableChances int64 `json:"availableChances"` AvailableChances int64 `json:"availableChances"`
DailyEarnedChances int64 `json:"dailyEarnedChances"`
DailyChanceLimit int `json:"dailyChanceLimit"`
NextChanceRemaining int64 `json:"nextChanceRemaining"` NextChanceRemaining int64 `json:"nextChanceRemaining"`
Rank *int `json:"rank"` Rank *int `json:"rank"`
} }
@ -255,6 +260,8 @@ type SettlementRecordView struct {
UserID int64 `json:"userId,string"` UserID int64 `json:"userId,string"`
Rank int `json:"rank"` Rank int `json:"rank"`
TotalConsumed int64 `json:"totalConsumed"` TotalConsumed int64 `json:"totalConsumed"`
RankingType string `json:"rankingType"`
Score int64 `json:"score"`
RankRewardID int64 `json:"rankRewardId,string"` RankRewardID int64 `json:"rankRewardId,string"`
ResourceGroupID int64 `json:"resourceGroupId,string"` ResourceGroupID int64 `json:"resourceGroupId,string"`
BusinessNo string `json:"businessNo"` BusinessNo string `json:"businessNo"`
@ -279,6 +286,7 @@ type PrizeInput struct {
type RankRewardInput struct { type RankRewardInput struct {
ID FlexibleInt64 `json:"id"` ID FlexibleInt64 `json:"id"`
RankingType string `json:"rankingType"`
StartRank int `json:"startRank"` StartRank int `json:"startRank"`
EndRank int `json:"endRank"` EndRank int `json:"endRank"`
ResourceGroupID FlexibleInt64 `json:"resourceGroupId"` ResourceGroupID FlexibleInt64 `json:"resourceGroupId"`
@ -296,6 +304,7 @@ type SaveActivityRequest struct {
EndTime int64 `json:"endTime"` EndTime int64 `json:"endTime"`
SettlementDelayMinutes *int `json:"settlementDelayMinutes"` SettlementDelayMinutes *int `json:"settlementDelayMinutes"`
CoinPerDraw int64 `json:"coinPerDraw"` CoinPerDraw int64 `json:"coinPerDraw"`
DailyChanceLimit int `json:"dailyChanceLimit"`
RankingType string `json:"rankingType"` RankingType string `json:"rankingType"`
RankingPeriod string `json:"rankingPeriod"` RankingPeriod string `json:"rankingPeriod"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`

View File

@ -430,7 +430,7 @@ func defaultConditionJump(conditionType string) (string, string) {
switch strings.ToUpper(strings.TrimSpace(conditionType)) { switch strings.ToUpper(strings.TrimSpace(conditionType)) {
case EventTypeRechargeGold: case EventTypeRechargeGold:
return JumpTypeRechargeInApp, "" return JumpTypeRechargeInApp, ""
case EventTypeMicDurationSeconds, EventTypeGameConsumeGold, EventTypeGiftConsumeGold: case EventTypeMicDurationSeconds, EventTypeGameConsumeGold, EventTypeGameWinGold, EventTypeGiftConsumeGold:
return "ROOM_RANDOM_WITH_MIC", "" return "ROOM_RANDOM_WITH_MIC", ""
default: default:
return "NONE", "" return "NONE", ""

View File

@ -123,7 +123,7 @@ WHERE archive_row.status = ?
AND archive_row.occurred_at < ? AND archive_row.occurred_at < ?
AND archive_row.cos_key <> '' AND archive_row.cos_key <> ''
AND ( AND (
archive_row.event_type <> ? archive_row.event_type NOT IN (?, ?)
OR NOT EXISTS ( OR NOT EXISTS (
SELECT 1 SELECT 1
FROM yumi_game_king_activity AS activity 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 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 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 { func archiveGCActivityProtects(row model.TaskCenterEventArchiveOutbox, activities []model.YumiGameKingActivity) bool {
if row.EventType != EventTypeGameConsumeGold { if row.EventType != EventTypeGameConsumeGold && row.EventType != EventTypeGameWinGold {
return false return false
} }
for _, activity := range activities { for _, activity := range activities {

View File

@ -67,10 +67,10 @@ func (s *Service) ProcessEvent(ctx context.Context, req EventRequest) (*EventRes
} }
occurredAt := parseOccurredAt(req.OccurredAt, time.Now()) occurredAt := parseOccurredAt(req.OccurredAt, time.Now())
receivedAt := time.Now() receivedAt := time.Now()
// 游戏王以任务中心已经归一化的 GAME_CONSUME_GOLD 为唯一事实来源。这里必须位于 // 游戏王以任务中心归一化的游戏消费/中奖入账事件为唯一事实来源。这里必须位于
// task-center 自身去重事务之前即使当前没有配置任务活动仍要统计sink 失败时也 // task-center 自身去重事务之前即使当前没有配置任务活动仍要统计sink 失败时也
// 不能提前写入任务去重表,否则 MQ 重投会永久跳过活动入账。 // 不能提前写入任务去重表,否则 MQ 重投会永久跳过活动入账。
if eventType == EventTypeGameConsumeGold && s.gameConsumeSink != nil { if (eventType == EventTypeGameConsumeGold || eventType == EventTypeGameWinGold) && s.gameConsumeSink != nil {
if err := s.gameConsumeSink.ConsumeGameEvent(ctx, ValidatedEvent{ if err := s.gameConsumeSink.ConsumeGameEvent(ctx, ValidatedEvent{
SysOrigin: sysOrigin, SysOrigin: sysOrigin,
EventID: eventID, EventID: eventID,

View File

@ -23,6 +23,7 @@ const (
EventTypeMicDurationSeconds = "MIC_DURATION_SECONDS" EventTypeMicDurationSeconds = "MIC_DURATION_SECONDS"
EventTypeGameConsumeGold = "GAME_CONSUME_GOLD" EventTypeGameConsumeGold = "GAME_CONSUME_GOLD"
EventTypeGameWinGold = "GAME_WIN_GOLD"
EventTypeGiftConsumeGold = "GIFT_CONSUME_GOLD" EventTypeGiftConsumeGold = "GIFT_CONSUME_GOLD"
EventTypeRechargeGold = "RECHARGE_GOLD" EventTypeRechargeGold = "RECHARGE_GOLD"
@ -74,7 +75,7 @@ type ValidatedEvent struct {
Payload map[string]any Payload map[string]any
} }
// GameConsumeSink 接收权威 GAME_CONSUME_GOLD 事件 // GameConsumeSink 接收权威游戏消费与中奖入账事件;保留名称以避免扩大初始化改动面
// sink 自己必须通过业务唯一键幂等,因为 HTTP 与 MQ 可能同时送达同一事件。 // sink 自己必须通过业务唯一键幂等,因为 HTTP 与 MQ 可能同时送达同一事件。
type GameConsumeSink interface { type GameConsumeSink interface {
ConsumeGameEvent(context.Context, ValidatedEvent) error ConsumeGameEvent(context.Context, ValidatedEvent) error
@ -362,7 +363,7 @@ func validateCategory(category string) error {
func isAllowedConditionType(conditionType string) bool { func isAllowedConditionType(conditionType string) bool {
switch strings.ToUpper(strings.TrimSpace(conditionType)) { switch strings.ToUpper(strings.TrimSpace(conditionType)) {
case EventTypeMicDurationSeconds, EventTypeGameConsumeGold, EventTypeGiftConsumeGold, EventTypeRechargeGold: case EventTypeMicDurationSeconds, EventTypeGameConsumeGold, EventTypeGameWinGold, EventTypeGiftConsumeGold, EventTypeRechargeGold:
return true return true
default: default:
return false return false

View File

@ -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;