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"`
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"`

View File

@ -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")
}

View File

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

View File

@ -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,26 +85,61 @@ 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 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
})
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"}},

View File

@ -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,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) {
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))
result := make([]normalizedRankReward, 0, len(fixedRankRanges)*2)
for _, rankingType := range []string{rankingTypeTycoon, rankingTypeVictorious} {
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]
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")
@ -184,6 +193,7 @@ func (s *Service) normalizeRankRewards(ctx context.Context, sysOrigin string, va
}
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,

View File

@ -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 兼容维度;当前活动没有胜利事件,
// 因此显式请求时返回空榜而不是 400supportedRankingTypes 会让新 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,

View File

@ -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,25 +72,33 @@ 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 {
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
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
}
if len(rewards) != len(fixedRankRanges) {
return NewAppError(http.StatusConflict, "rank_reward_config_invalid", "six rank reward tiers are required for settlement")
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(rewards, rank)
reward, ok := rankRewardFor(boardRewards, rank)
if !ok {
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 {
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{
ID: recordID, ActivityID: activity.ID, UserID: user.UserID, RankNo: rank,
TotalConsumed: user.TotalConsumed, RankRewardID: reward.ID, ResourceGroupID: reward.ResourceGroupID,
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 {
@ -118,6 +130,12 @@ func (s *Service) freezeSettlement(ctx context.Context, activityID int64) error
}).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
}
return nil
})

View File

@ -26,6 +26,7 @@ const (
defaultRecordLimit = 20
rankingTypeTycoon = "TYCOON"
rankingTypeVictorious = "VICTORIOUS"
rankingPeriodOverall = "OVERALL"
activityUpcoming = "UPCOMING"
@ -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"`

View File

@ -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", ""

View File

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

View File

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

View File

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

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;