feat: add dual game king rankings
This commit is contained in:
parent
5cc47b90f6
commit
175f4ac8e6
@ -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"`
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -16,14 +16,16 @@ import (
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// ConsumeGameEvent 实现 taskcenter.GameConsumeSink。GAME_CONSUME_GOLD 是唯一统计入口,
|
||||
// 不再允许后台配置钱包 eventType 或把非游戏支出混入活动。
|
||||
// ConsumeGameEvent 实现 taskcenter.GameConsumeSink,只接受 Java 游戏回调归一化后的
|
||||
// GAME_CONSUME_GOLD 与 GAME_WIN_GOLD,避免礼物、充值或活动奖励进入两个榜单。
|
||||
func (s *Service) ConsumeGameEvent(ctx context.Context, event taskcenter.ValidatedEvent) error {
|
||||
if !strings.EqualFold(event.EventType, taskcenter.EventTypeGameConsumeGold) {
|
||||
isConsume := strings.EqualFold(event.EventType, taskcenter.EventTypeGameConsumeGold)
|
||||
isWin := strings.EqualFold(event.EventType, taskcenter.EventTypeGameWinGold)
|
||||
if !isConsume && !isWin {
|
||||
return nil
|
||||
}
|
||||
if event.UserID <= 0 || event.DeltaValue <= 0 || strings.TrimSpace(event.EventID) == "" {
|
||||
return NewAppError(http.StatusBadRequest, "invalid_game_consume_event", "game consume event requires eventId, userId and positive deltaValue")
|
||||
return NewAppError(http.StatusBadRequest, "invalid_game_event", "game event requires eventId, userId and positive deltaValue")
|
||||
}
|
||||
event.SysOrigin = normalizeSysOrigin(event.SysOrigin)
|
||||
event.EventID = strings.TrimSpace(event.EventID)
|
||||
@ -47,6 +49,7 @@ func (s *Service) ConsumeGameEvent(ctx context.Context, event taskcenter.Validat
|
||||
// applyEventToActivity 在一个事务内完成账本幂等、总榜和活动时区日榜聚合。
|
||||
func (s *Service) applyEventToActivity(ctx context.Context, activityID int64, event taskcenter.ValidatedEvent) (bool, error) {
|
||||
accepted := false
|
||||
isConsume := strings.EqualFold(event.EventType, taskcenter.EventTypeGameConsumeGold)
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var activity model.YumiGameKingActivity
|
||||
// 事件仅持有共享活动锁:不同用户可并发入账;结算使用排他锁并等待所有已进入的
|
||||
@ -67,7 +70,7 @@ func (s *Service) applyEventToActivity(ctx context.Context, activityID int64, ev
|
||||
payloadJSON, _ := json.Marshal(event.Payload)
|
||||
ledger := model.YumiGameKingConsumeLedger{
|
||||
ID: ledgerID, ActivityID: activity.ID, EventID: event.EventID, UserID: event.UserID,
|
||||
SysOrigin: event.SysOrigin, Amount: event.DeltaValue, EventTime: event.OccurredAt,
|
||||
SysOrigin: event.SysOrigin, EventType: event.EventType, Amount: event.DeltaValue, EventTime: event.OccurredAt,
|
||||
PayloadJSON: string(payloadJSON), CreateTime: time.Now(),
|
||||
}
|
||||
result := tx.Clauses(clause.OnConflict{
|
||||
@ -82,15 +85,24 @@ func (s *Service) applyEventToActivity(ctx context.Context, activityID int64, ev
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := addOverallConsumeTx(tx, activity.ID, event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil {
|
||||
return err
|
||||
}
|
||||
_, location, err := normalizeTimezone(activity.Timezone)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addDailyConsumeTx(tx, activity.ID, dateKey(event.OccurredAt, location), event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil {
|
||||
return err
|
||||
if isConsume {
|
||||
if err := addOverallConsumeTx(tx, activity.ID, event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addDailyConsumeTx(tx, activity.ID, dateKey(event.OccurredAt, location), event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := addOverallWinTx(tx, activity.ID, event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addDailyWinTx(tx, activity.ID, dateKey(event.OccurredAt, location), event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
accepted = true
|
||||
return nil
|
||||
@ -98,10 +110,36 @@ func (s *Service) applyEventToActivity(ctx context.Context, activityID int64, ev
|
||||
return accepted, err
|
||||
}
|
||||
|
||||
func addOverallWinTx(tx *gorm.DB, activityID, userID, amount int64, eventTime, now time.Time) error {
|
||||
row := model.YumiGameKingUser{ActivityID: activityID, UserID: userID, TotalWon: amount,
|
||||
ConsumeReachedTime: eventTime, WinReachedTime: eventTime, CreateTime: now, UpdateTime: now}
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "activity_id"}, {Name: "user_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{
|
||||
"total_won": gorm.Expr("total_won + ?", amount),
|
||||
"win_reached_time": gorm.Expr("GREATEST(COALESCE(win_reached_time, ?), ?)", eventTime, eventTime),
|
||||
"update_time": now,
|
||||
}),
|
||||
}).Create(&row).Error
|
||||
}
|
||||
|
||||
func addDailyWinTx(tx *gorm.DB, activityID int64, statDate time.Time, userID, amount int64, eventTime, now time.Time) error {
|
||||
row := model.YumiGameKingUserDaily{ActivityID: activityID, StatDate: statDate, UserID: userID,
|
||||
TotalWon: amount, ConsumeReachedTime: eventTime, WinReachedTime: eventTime, CreateTime: now, UpdateTime: now}
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "activity_id"}, {Name: "stat_date"}, {Name: "user_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{
|
||||
"total_won": gorm.Expr("total_won + ?", amount),
|
||||
"win_reached_time": gorm.Expr("GREATEST(COALESCE(win_reached_time, ?), ?)", eventTime, eventTime),
|
||||
"update_time": now,
|
||||
}),
|
||||
}).Create(&row).Error
|
||||
}
|
||||
|
||||
func addOverallConsumeTx(tx *gorm.DB, activityID, userID, amount int64, eventTime, now time.Time) error {
|
||||
row := model.YumiGameKingUser{
|
||||
ActivityID: activityID, UserID: userID, TotalConsumed: amount, UsedChances: 0,
|
||||
ConsumeReachedTime: eventTime, CreateTime: now, UpdateTime: now,
|
||||
ConsumeReachedTime: eventTime, WinReachedTime: eventTime, CreateTime: now, UpdateTime: now,
|
||||
}
|
||||
// 首笔事件不能“SELECT 未命中后 INSERT”:同一用户并发首笔会发生 PK 冲突或死锁,
|
||||
// 同步上报若不重试就会漏计。单条 upsert 由聚合主键串行累加,并且不覆盖 used_chances。
|
||||
@ -118,7 +156,7 @@ func addOverallConsumeTx(tx *gorm.DB, activityID, userID, amount int64, eventTim
|
||||
func addDailyConsumeTx(tx *gorm.DB, activityID int64, statDate time.Time, userID, amount int64, eventTime, now time.Time) error {
|
||||
row := model.YumiGameKingUserDaily{
|
||||
ActivityID: activityID, StatDate: statDate, UserID: userID, TotalConsumed: amount,
|
||||
ConsumeReachedTime: eventTime, CreateTime: now, UpdateTime: now,
|
||||
ConsumeReachedTime: eventTime, WinReachedTime: eventTime, CreateTime: now, UpdateTime: now,
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "activity_id"}, {Name: "stat_date"}, {Name: "user_id"}},
|
||||
|
||||
@ -45,8 +45,8 @@ func normalizeRankingType(value string) (string, error) {
|
||||
if value == "" {
|
||||
value = rankingTypeTycoon
|
||||
}
|
||||
if value != rankingTypeTycoon {
|
||||
return "", NewAppError(http.StatusBadRequest, "invalid_ranking_type", "rankingType must be TYCOON")
|
||||
if value != rankingTypeTycoon && value != rankingTypeVictorious {
|
||||
return "", NewAppError(http.StatusBadRequest, "invalid_ranking_type", "rankingType must be TYCOON or VICTORIOUS")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@ -97,6 +97,9 @@ func normalizeActivityInput(req SaveActivityRequest) (SaveActivityRequest, error
|
||||
if req.CoinPerDraw <= 0 {
|
||||
return req, NewAppError(http.StatusBadRequest, "invalid_coin_per_draw", "coinPerDraw must be greater than zero")
|
||||
}
|
||||
if req.DailyChanceLimit <= 0 || req.DailyChanceLimit > 10000 {
|
||||
return req, NewAppError(http.StatusBadRequest, "invalid_daily_chance_limit", "dailyChanceLimit must be between 1 and 10000")
|
||||
}
|
||||
req.RankingType, err = normalizeRankingType(req.RankingType)
|
||||
if err != nil {
|
||||
return req, err
|
||||
@ -150,39 +153,46 @@ func (s *Service) normalizePrizes(ctx context.Context, sysOrigin string, values
|
||||
}
|
||||
|
||||
func (s *Service) normalizeRankRewards(ctx context.Context, sysOrigin string, values []RankRewardInput) ([]normalizedRankReward, error) {
|
||||
if len(values) != len(fixedRankRanges) {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_count", "exactly six rank reward tiers are required")
|
||||
if len(values) != len(fixedRankRanges)*2 {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_count", "exactly six tiers for each of TYCOON and VICTORIOUS are required")
|
||||
}
|
||||
byRange := make(map[string]RankRewardInput, len(values))
|
||||
for _, value := range values {
|
||||
key := fmt.Sprintf("%d-%d", value.StartRank, value.EndRank)
|
||||
var err error
|
||||
value.RankingType, err = normalizeRankingType(value.RankingType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := fmt.Sprintf("%s:%d-%d", value.RankingType, value.StartRank, value.EndRank)
|
||||
if _, exists := byRange[key]; exists {
|
||||
return nil, NewAppError(http.StatusBadRequest, "duplicate_rank_reward", "rank reward ranges must be unique")
|
||||
}
|
||||
byRange[key] = value
|
||||
}
|
||||
result := make([]normalizedRankReward, 0, len(fixedRankRanges))
|
||||
for _, rankRange := range fixedRankRanges {
|
||||
key := fmt.Sprintf("%d-%d", rankRange[0], rankRange[1])
|
||||
value, exists := byRange[key]
|
||||
if !exists {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_ranges", "rank rewards must be 1, 2, 3, 4-7, 8-10 and 11-30")
|
||||
result := make([]normalizedRankReward, 0, len(fixedRankRanges)*2)
|
||||
for _, rankingType := range []string{rankingTypeTycoon, rankingTypeVictorious} {
|
||||
for _, rankRange := range fixedRankRanges {
|
||||
key := fmt.Sprintf("%s:%d-%d", rankingType, rankRange[0], rankRange[1])
|
||||
value, exists := byRange[key]
|
||||
if !exists {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_ranges", "rank rewards must be 1, 2, 3, 4-7, 8-10 and 11-30")
|
||||
}
|
||||
if value.ResourceGroupID.Int64() <= 0 {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_group", "every rank tier must reference a reward group")
|
||||
}
|
||||
value.RewardName = strings.TrimSpace(value.RewardName)
|
||||
snapshot, err := s.loadRewardGroupSnapshot(ctx, sysOrigin, value.ResourceGroupID.Int64())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value.RewardName == "" {
|
||||
value.RewardName = snapshot.GroupName
|
||||
}
|
||||
if len(value.RewardName) > 128 {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_name", "rewardName must not exceed 128 characters")
|
||||
}
|
||||
result = append(result, normalizedRankReward{Input: value, Snapshot: snapshot})
|
||||
}
|
||||
if value.ResourceGroupID.Int64() <= 0 {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_group", "every rank tier must reference a reward group")
|
||||
}
|
||||
value.RewardName = strings.TrimSpace(value.RewardName)
|
||||
snapshot, err := s.loadRewardGroupSnapshot(ctx, sysOrigin, value.ResourceGroupID.Int64())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value.RewardName == "" {
|
||||
value.RewardName = snapshot.GroupName
|
||||
}
|
||||
if len(value.RewardName) > 128 {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_name", "rewardName must not exceed 128 characters")
|
||||
}
|
||||
result = append(result, normalizedRankReward{Input: value, Snapshot: snapshot})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@ -274,7 +284,8 @@ func activityView(row model.YumiGameKingActivity) ActivityView {
|
||||
SettlementDelayMinutes: row.SettlementDelayMinutes,
|
||||
SettlementTime: unixMilli(row.SettlementTime),
|
||||
CoinPerDraw: row.CoinPerDraw,
|
||||
EventTypes: []string{"GAME_CONSUME_GOLD"},
|
||||
DailyChanceLimit: row.DailyChanceLimit,
|
||||
EventTypes: []string{"GAME_CONSUME_GOLD", "GAME_WIN_GOLD"},
|
||||
RankingType: row.RankingType,
|
||||
RankingPeriod: row.RankingPeriod,
|
||||
Enabled: row.Enabled,
|
||||
@ -304,6 +315,7 @@ func rankRewardView(row model.YumiGameKingRankReward) RankRewardView {
|
||||
return RankRewardView{
|
||||
ID: row.ID,
|
||||
ActivityID: row.ActivityID,
|
||||
RankingType: row.RankingType,
|
||||
StartRank: row.StartRank,
|
||||
EndRank: row.EndRank,
|
||||
ResourceGroupID: row.ResourceGroupID,
|
||||
|
||||
@ -29,19 +29,23 @@ func (s *Service) Me(ctx context.Context, user AuthUser, activityID int64) (*Use
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
row = model.YumiGameKingUser{ActivityID: activity.ID, UserID: user.UserID}
|
||||
}
|
||||
earned := row.TotalConsumed / activity.CoinPerDraw
|
||||
earned, dailyEarned, dailyConsumed, err := s.earnedChances(ctx, activity, user.UserID, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
available := earned - row.UsedChances
|
||||
if available < 0 {
|
||||
available = 0
|
||||
}
|
||||
remainder := row.TotalConsumed % activity.CoinPerDraw
|
||||
remainder := dailyConsumed % activity.CoinPerDraw
|
||||
next := activity.CoinPerDraw
|
||||
if remainder > 0 {
|
||||
// 达到日上限后,下一次资格只能从次日重新累计;进度也必须按当日消费计算。
|
||||
if dailyEarned < int64(activity.DailyChanceLimit) && remainder > 0 {
|
||||
next -= remainder
|
||||
}
|
||||
var rank *int
|
||||
if row.TotalConsumed > 0 {
|
||||
value, rankErr := s.userRank(ctx, activity, user.UserID, activity.RankingPeriod, time.Now())
|
||||
value, rankErr := s.userRank(ctx, activity, user.UserID, rankingTypeTycoon, activity.RankingPeriod, time.Now())
|
||||
if rankErr != nil {
|
||||
return nil, rankErr
|
||||
}
|
||||
@ -49,12 +53,38 @@ func (s *Service) Me(ctx context.Context, user AuthUser, activityID int64) (*Use
|
||||
}
|
||||
return &UserStateResponse{
|
||||
ActivityID: activity.ID, TotalConsumed: row.TotalConsumed, EarnedChances: earned,
|
||||
UsedChances: row.UsedChances, AvailableChances: available, NextChanceRemaining: next, Rank: rank,
|
||||
UsedChances: row.UsedChances, AvailableChances: available, DailyEarnedChances: dailyEarned,
|
||||
DailyChanceLimit: activity.DailyChanceLimit, NextChanceRemaining: next, Rank: rank,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Ranking 返回活动时区日榜或总榜。VICTORIOUS 是旧 H5 兼容维度;当前活动没有胜利事件,
|
||||
// 因此显式请求时返回空榜而不是 400,supportedRankingTypes 会让新 H5 隐藏该页签。
|
||||
// earnedChances 按活动时区日聚合逐日封顶;只读取该用户参与过的活动日期,不扫描事件账本。
|
||||
func (s *Service) earnedChances(ctx context.Context, activity model.YumiGameKingActivity, userID int64, now time.Time) (int64, int64, int64, error) {
|
||||
var rows []model.YumiGameKingUserDaily
|
||||
if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, userID).Find(&rows).Error; err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
_, location, err := normalizeTimezone(activity.Timezone)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
today := dateKey(now, location)
|
||||
var total, daily, dailyConsumed int64
|
||||
for _, row := range rows {
|
||||
earned := row.TotalConsumed / activity.CoinPerDraw
|
||||
if earned > int64(activity.DailyChanceLimit) {
|
||||
earned = int64(activity.DailyChanceLimit)
|
||||
}
|
||||
total += earned
|
||||
if row.StatDate.Equal(today) {
|
||||
daily = earned
|
||||
dailyConsumed = row.TotalConsumed
|
||||
}
|
||||
}
|
||||
return total, daily, dailyConsumed, nil
|
||||
}
|
||||
|
||||
// Ranking 返回活动时区日榜或总榜;榜单类型只映射到服务端固定列名,不接受任意 SQL 字段。
|
||||
func (s *Service) Ranking(ctx context.Context, user AuthUser, activityID int64, rankingType, period string, limit int) (*RankingResponse, error) {
|
||||
activity, err := s.resolveAppActivity(ctx, normalizeSysOrigin(user.SysOrigin), activityID, time.Now())
|
||||
if err != nil {
|
||||
@ -71,7 +101,7 @@ func (s *Service) Ranking(ctx context.Context, user AuthUser, activityID int64,
|
||||
if period != "DAILY" && period != rankingPeriodOverall {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_ranking_period", "period must be DAILY or OVERALL")
|
||||
}
|
||||
if rankingType != rankingTypeTycoon && rankingType != "VICTORIOUS" {
|
||||
if rankingType != rankingTypeTycoon && rankingType != rankingTypeVictorious {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_ranking_type", "rankingType must be TYCOON or VICTORIOUS")
|
||||
}
|
||||
if limit <= 0 {
|
||||
@ -81,11 +111,11 @@ func (s *Service) Ranking(ctx context.Context, user AuthUser, activityID int64,
|
||||
limit = maxRankingLimit
|
||||
}
|
||||
response := &RankingResponse{RankingType: rankingType, Period: period, Entries: []RankEntryView{}}
|
||||
if rankingType == "VICTORIOUS" {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
scoreColumn, reachedColumn := "total_consumed", "consume_reached_time"
|
||||
if rankingType == rankingTypeVictorious {
|
||||
scoreColumn, reachedColumn = "total_won", "win_reached_time"
|
||||
}
|
||||
if period == "DAILY" {
|
||||
_, location, timezoneErr := normalizeTimezone(activity.Timezone)
|
||||
if timezoneErr != nil {
|
||||
@ -95,29 +125,37 @@ func (s *Service) Ranking(ctx context.Context, user AuthUser, activityID int64,
|
||||
response.StatDate = now.In(location).Format("2006-01-02")
|
||||
var rows []model.YumiGameKingUserDaily
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("activity_id = ? AND stat_date = ? AND total_consumed > 0", activity.ID, statDate).
|
||||
Order("total_consumed DESC, consume_reached_time ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil {
|
||||
Where("activity_id = ? AND stat_date = ? AND "+scoreColumn+" > 0", activity.ID, statDate).
|
||||
Order(scoreColumn + " DESC, " + reachedColumn + " ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index, row := range rows {
|
||||
entry := RankEntryView{Rank: index + 1, UserID: row.UserID, Score: row.TotalConsumed, TotalConsumed: row.TotalConsumed, Me: row.UserID == user.UserID}
|
||||
score := row.TotalConsumed
|
||||
entry := RankEntryView{Rank: index + 1, UserID: row.UserID, Score: score, TotalConsumed: score, Me: row.UserID == user.UserID}
|
||||
if rankingType == rankingTypeVictorious {
|
||||
entry.Score, entry.TotalConsumed, entry.TotalWon = row.TotalWon, 0, row.TotalWon
|
||||
}
|
||||
response.Entries = append(response.Entries, entry)
|
||||
}
|
||||
} else {
|
||||
var rows []model.YumiGameKingUser
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("activity_id = ? AND total_consumed > 0", activity.ID).
|
||||
Order("total_consumed DESC, consume_reached_time ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil {
|
||||
Where("activity_id = ? AND "+scoreColumn+" > 0", activity.ID).
|
||||
Order(scoreColumn + " DESC, " + reachedColumn + " ASC, user_id ASC").Limit(limit).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index, row := range rows {
|
||||
entry := RankEntryView{Rank: index + 1, UserID: row.UserID, Score: row.TotalConsumed, TotalConsumed: row.TotalConsumed, Me: row.UserID == user.UserID}
|
||||
score := row.TotalConsumed
|
||||
entry := RankEntryView{Rank: index + 1, UserID: row.UserID, Score: score, TotalConsumed: score, Me: row.UserID == user.UserID}
|
||||
if rankingType == rankingTypeVictorious {
|
||||
entry.Score, entry.TotalConsumed, entry.TotalWon = row.TotalWon, 0, row.TotalWon
|
||||
}
|
||||
response.Entries = append(response.Entries, entry)
|
||||
}
|
||||
}
|
||||
s.hydrateRankProfiles(ctx, response.Entries)
|
||||
if user.UserID > 0 {
|
||||
response.My, err = s.myRankEntry(ctx, activity, user.UserID, period, now)
|
||||
response.My, err = s.myRankEntry(ctx, activity, user.UserID, rankingType, period, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -126,8 +164,8 @@ func (s *Service) Ranking(ctx context.Context, user AuthUser, activityID int64,
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *Service) myRankEntry(ctx context.Context, activity model.YumiGameKingActivity, userID int64, period string, now time.Time) (*RankEntryView, error) {
|
||||
rank, err := s.userRank(ctx, activity, userID, period, now)
|
||||
func (s *Service) myRankEntry(ctx context.Context, activity model.YumiGameKingActivity, userID int64, rankingType, period string, now time.Time) (*RankEntryView, error) {
|
||||
rank, err := s.userRank(ctx, activity, userID, rankingType, period, now)
|
||||
if err != nil || rank == nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -142,6 +180,9 @@ func (s *Service) myRankEntry(ctx context.Context, activity model.YumiGameKingAc
|
||||
return nil, err
|
||||
}
|
||||
entry.Score, entry.TotalConsumed = row.TotalConsumed, row.TotalConsumed
|
||||
if rankingType == rankingTypeVictorious {
|
||||
entry.Score, entry.TotalConsumed, entry.TotalWon = row.TotalWon, 0, row.TotalWon
|
||||
}
|
||||
} else {
|
||||
var row model.YumiGameKingUser
|
||||
if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, userID).First(&row).Error; err != nil {
|
||||
@ -151,11 +192,18 @@ func (s *Service) myRankEntry(ctx context.Context, activity model.YumiGameKingAc
|
||||
return nil, err
|
||||
}
|
||||
entry.Score, entry.TotalConsumed = row.TotalConsumed, row.TotalConsumed
|
||||
if rankingType == rankingTypeVictorious {
|
||||
entry.Score, entry.TotalConsumed, entry.TotalWon = row.TotalWon, 0, row.TotalWon
|
||||
}
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (s *Service) userRank(ctx context.Context, activity model.YumiGameKingActivity, userID int64, period string, now time.Time) (*int, error) {
|
||||
func (s *Service) userRank(ctx context.Context, activity model.YumiGameKingActivity, userID int64, rankingType, period string, now time.Time) (*int, error) {
|
||||
scoreColumn, reachedColumn := "total_consumed", "consume_reached_time"
|
||||
if rankingType == rankingTypeVictorious {
|
||||
scoreColumn, reachedColumn = "total_won", "win_reached_time"
|
||||
}
|
||||
if period == "DAILY" {
|
||||
_, location, _ := normalizeTimezone(activity.Timezone)
|
||||
statDate := dateKey(now, location)
|
||||
@ -166,10 +214,17 @@ func (s *Service) userRank(ctx context.Context, activity model.YumiGameKingActiv
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
score, reached := row.TotalConsumed, row.ConsumeReachedTime
|
||||
if rankingType == rankingTypeVictorious {
|
||||
score, reached = row.TotalWon, row.WinReachedTime
|
||||
}
|
||||
if score <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var ahead int64
|
||||
if err := s.db.WithContext(ctx).Model(&model.YumiGameKingUserDaily{}).
|
||||
Where("activity_id = ? AND stat_date = ? AND (total_consumed > ? OR (total_consumed = ? AND (consume_reached_time < ? OR (consume_reached_time = ? AND user_id < ?))))",
|
||||
activity.ID, statDate, row.TotalConsumed, row.TotalConsumed, row.ConsumeReachedTime, row.ConsumeReachedTime, userID).
|
||||
Where("activity_id = ? AND stat_date = ? AND ("+scoreColumn+" > ? OR ("+scoreColumn+" = ? AND ("+reachedColumn+" < ? OR ("+reachedColumn+" = ? AND user_id < ?))))",
|
||||
activity.ID, statDate, score, score, reached, reached, userID).
|
||||
Count(&ahead).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -183,10 +238,17 @@ func (s *Service) userRank(ctx context.Context, activity model.YumiGameKingActiv
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
score, reached := row.TotalConsumed, row.ConsumeReachedTime
|
||||
if rankingType == rankingTypeVictorious {
|
||||
score, reached = row.TotalWon, row.WinReachedTime
|
||||
}
|
||||
if score <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var ahead int64
|
||||
if err := s.db.WithContext(ctx).Model(&model.YumiGameKingUser{}).
|
||||
Where("activity_id = ? AND (total_consumed > ? OR (total_consumed = ? AND (consume_reached_time < ? OR (consume_reached_time = ? AND user_id < ?))))",
|
||||
activity.ID, row.TotalConsumed, row.TotalConsumed, row.ConsumeReachedTime, row.ConsumeReachedTime, userID).
|
||||
Where("activity_id = ? AND ("+scoreColumn+" > ? OR ("+scoreColumn+" = ? AND ("+reachedColumn+" < ? OR ("+reachedColumn+" = ? AND user_id < ?))))",
|
||||
activity.ID, score, score, reached, reached, userID).
|
||||
Count(&ahead).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -322,6 +384,7 @@ func drawRecordView(row model.YumiGameKingDrawRecord) DrawRecordView {
|
||||
func settlementRecordView(row model.YumiGameKingSettlementRecord) SettlementRecordView {
|
||||
return SettlementRecordView{
|
||||
ID: row.ID, ActivityID: row.ActivityID, UserID: row.UserID, Rank: row.RankNo,
|
||||
RankingType: row.RankingType, Score: row.Score,
|
||||
TotalConsumed: row.TotalConsumed, RankRewardID: row.RankRewardID,
|
||||
ResourceGroupID: row.ResourceGroupID, BusinessNo: row.BusinessNo,
|
||||
DeliveryStatus: row.DeliveryStatus, RetryCount: row.RetryCount,
|
||||
|
||||
@ -20,7 +20,7 @@ const (
|
||||
staleDeliveryBatch = 20
|
||||
)
|
||||
|
||||
// Settle 在结算截点后冻结 TYCOON/OVERALL Top30,并发送尚未开始的 PENDING 奖励。
|
||||
// Settle 在结算截点后分别冻结 TYCOON/VICTORIOUS 的 OVERALL Top30,并发送 PENDING 奖励。
|
||||
// 重复调用不会重建快照,也不会自动重试 FAILED/UNKNOWN。
|
||||
func (s *Service) Settle(ctx context.Context, activityID int64) error {
|
||||
if activityID <= 0 {
|
||||
@ -72,53 +72,71 @@ func (s *Service) freezeSettlement(ctx context.Context, activityID int64) error
|
||||
return err
|
||||
}
|
||||
|
||||
var users []model.YumiGameKingUser
|
||||
if err := tx.Where("activity_id = ? AND total_consumed > 0", activity.ID).
|
||||
Order("total_consumed DESC, consume_reached_time ASC, user_id ASC").Limit(30).Find(&users).Error; err != nil {
|
||||
var rewards []model.YumiGameKingRankReward
|
||||
if err := tx.Where("activity_id = ?", activity.ID).Order("ranking_type ASC, start_rank ASC").Find(&rewards).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(users) == 0 {
|
||||
if len(rewards) != len(fixedRankRanges)*2 {
|
||||
return NewAppError(http.StatusConflict, "rank_reward_config_invalid", "six reward tiers are required for each ranking")
|
||||
}
|
||||
created := 0
|
||||
for _, rankingType := range []string{rankingTypeTycoon, rankingTypeVictorious} {
|
||||
scoreColumn, reachedColumn := "total_consumed", "consume_reached_time"
|
||||
if rankingType == rankingTypeVictorious {
|
||||
scoreColumn, reachedColumn = "total_won", "win_reached_time"
|
||||
}
|
||||
var users []model.YumiGameKingUser
|
||||
if err := tx.Where("activity_id = ? AND "+scoreColumn+" > 0", activity.ID).
|
||||
Order(scoreColumn + " DESC, " + reachedColumn + " ASC, user_id ASC").Limit(30).Find(&users).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
boardRewards := make([]model.YumiGameKingRankReward, 0, len(fixedRankRanges))
|
||||
for _, reward := range rewards {
|
||||
if reward.RankingType == rankingType {
|
||||
boardRewards = append(boardRewards, reward)
|
||||
}
|
||||
}
|
||||
for index, user := range users {
|
||||
rank := index + 1
|
||||
reward, ok := rankRewardFor(boardRewards, rank)
|
||||
if !ok {
|
||||
return NewAppError(http.StatusConflict, "rank_reward_config_invalid", "rank reward tier does not cover Top30")
|
||||
}
|
||||
recordID, err := utils.NextID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
itemID, err := utils.NextID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
businessNo := fmt.Sprintf("YUMI_GAME_KING_SETTLEMENT:%d:%s:%d", activity.ID, rankingType, rank)
|
||||
score := user.TotalConsumed
|
||||
if rankingType == rankingTypeVictorious {
|
||||
score = user.TotalWon
|
||||
}
|
||||
record := model.YumiGameKingSettlementRecord{
|
||||
ID: recordID, ActivityID: activity.ID, RankingType: rankingType, UserID: user.UserID, RankNo: rank,
|
||||
TotalConsumed: user.TotalConsumed, Score: score, RankRewardID: reward.ID, ResourceGroupID: reward.ResourceGroupID,
|
||||
BusinessNo: businessNo, DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
|
||||
}
|
||||
if err := tx.Create(&record).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&model.YumiGameKingDeliveryItem{
|
||||
ID: itemID, OwnerType: OwnerSettlement, OwnerID: record.ID, ActivityID: activity.ID,
|
||||
UserID: user.UserID, ResourceGroupID: reward.ResourceGroupID,
|
||||
DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
created++
|
||||
}
|
||||
}
|
||||
if created == 0 {
|
||||
return tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activity.ID).
|
||||
Updates(map[string]any{"settlement_status": SettlementCompleted, "update_time": now}).Error
|
||||
}
|
||||
var rewards []model.YumiGameKingRankReward
|
||||
if err := tx.Where("activity_id = ?", activity.ID).Order("start_rank ASC").Find(&rewards).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rewards) != len(fixedRankRanges) {
|
||||
return NewAppError(http.StatusConflict, "rank_reward_config_invalid", "six rank reward tiers are required for settlement")
|
||||
}
|
||||
for index, user := range users {
|
||||
rank := index + 1
|
||||
reward, ok := rankRewardFor(rewards, rank)
|
||||
if !ok {
|
||||
return NewAppError(http.StatusConflict, "rank_reward_config_invalid", "rank reward tier does not cover Top30")
|
||||
}
|
||||
recordID, err := utils.NextID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
itemID, err := utils.NextID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
businessNo := fmt.Sprintf("YUMI_GAME_KING_SETTLEMENT:%d:%d", activity.ID, rank)
|
||||
record := model.YumiGameKingSettlementRecord{
|
||||
ID: recordID, ActivityID: activity.ID, UserID: user.UserID, RankNo: rank,
|
||||
TotalConsumed: user.TotalConsumed, RankRewardID: reward.ID, ResourceGroupID: reward.ResourceGroupID,
|
||||
BusinessNo: businessNo, DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
|
||||
}
|
||||
if err := tx.Create(&record).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&model.YumiGameKingDeliveryItem{
|
||||
ID: itemID, OwnerType: OwnerSettlement, OwnerID: record.ID, ActivityID: activity.ID,
|
||||
UserID: user.UserID, ResourceGroupID: reward.ResourceGroupID,
|
||||
DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@ -25,8 +25,9 @@ const (
|
||||
maxRecordLimit = 100
|
||||
defaultRecordLimit = 20
|
||||
|
||||
rankingTypeTycoon = "TYCOON"
|
||||
rankingPeriodOverall = "OVERALL"
|
||||
rankingTypeTycoon = "TYCOON"
|
||||
rankingTypeVictorious = "VICTORIOUS"
|
||||
rankingPeriodOverall = "OVERALL"
|
||||
|
||||
activityUpcoming = "UPCOMING"
|
||||
activityActive = "ACTIVE"
|
||||
@ -131,6 +132,7 @@ type ActivityView struct {
|
||||
SettlementDelayMinutes int `json:"settlementDelayMinutes"`
|
||||
SettlementTime int64 `json:"settlementTime"`
|
||||
CoinPerDraw int64 `json:"coinPerDraw"`
|
||||
DailyChanceLimit int `json:"dailyChanceLimit"`
|
||||
EventTypes []string `json:"eventTypes"`
|
||||
RankingType string `json:"rankingType"`
|
||||
RankingPeriod string `json:"rankingPeriod"`
|
||||
@ -157,6 +159,7 @@ type PrizeView struct {
|
||||
type RankRewardView struct {
|
||||
ID int64 `json:"id,string"`
|
||||
ActivityID int64 `json:"activityId,string"`
|
||||
RankingType string `json:"rankingType"`
|
||||
StartRank int `json:"startRank"`
|
||||
EndRank int `json:"endRank"`
|
||||
ResourceGroupID int64 `json:"resourceGroupId,string"`
|
||||
@ -180,6 +183,8 @@ type UserStateResponse struct {
|
||||
EarnedChances int64 `json:"earnedChances"`
|
||||
UsedChances int64 `json:"usedChances"`
|
||||
AvailableChances int64 `json:"availableChances"`
|
||||
DailyEarnedChances int64 `json:"dailyEarnedChances"`
|
||||
DailyChanceLimit int `json:"dailyChanceLimit"`
|
||||
NextChanceRemaining int64 `json:"nextChanceRemaining"`
|
||||
Rank *int `json:"rank"`
|
||||
}
|
||||
@ -255,6 +260,8 @@ type SettlementRecordView struct {
|
||||
UserID int64 `json:"userId,string"`
|
||||
Rank int `json:"rank"`
|
||||
TotalConsumed int64 `json:"totalConsumed"`
|
||||
RankingType string `json:"rankingType"`
|
||||
Score int64 `json:"score"`
|
||||
RankRewardID int64 `json:"rankRewardId,string"`
|
||||
ResourceGroupID int64 `json:"resourceGroupId,string"`
|
||||
BusinessNo string `json:"businessNo"`
|
||||
@ -279,6 +286,7 @@ type PrizeInput struct {
|
||||
|
||||
type RankRewardInput struct {
|
||||
ID FlexibleInt64 `json:"id"`
|
||||
RankingType string `json:"rankingType"`
|
||||
StartRank int `json:"startRank"`
|
||||
EndRank int `json:"endRank"`
|
||||
ResourceGroupID FlexibleInt64 `json:"resourceGroupId"`
|
||||
@ -296,6 +304,7 @@ type SaveActivityRequest struct {
|
||||
EndTime int64 `json:"endTime"`
|
||||
SettlementDelayMinutes *int `json:"settlementDelayMinutes"`
|
||||
CoinPerDraw int64 `json:"coinPerDraw"`
|
||||
DailyChanceLimit int `json:"dailyChanceLimit"`
|
||||
RankingType string `json:"rankingType"`
|
||||
RankingPeriod string `json:"rankingPeriod"`
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
@ -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", ""
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
67
migrations/062_yumi_game_king_dual_ranking_daily_limit.sql
Normal file
67
migrations/062_yumi_game_king_dual_ranking_daily_limit.sql
Normal 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;
|
||||
Loading…
x
Reference in New Issue
Block a user