feat: add daily Game King rewards settlement

This commit is contained in:
hy001 2026-07-22 20:42:27 +08:00
parent 175f4ac8e6
commit d891f0fc10
10 changed files with 432 additions and 73 deletions

View File

@ -94,11 +94,11 @@
`GET /go/app/h5/game-king/yumi/ranking?activityId={activityId}&rankingType=TYCOON&period=OVERALL&limit=30`
- `rankingType`正式口径仅 `TYCOON`。旧 H5 请求 `VICTORIOUS` 时返回空榜,避免兼容页面报错
- `rankingType``TYCOON` 为消耗榜,`VICTORIOUS` 为游戏中奖入账榜
- `period``DAILY``OVERALL`;配置页固定保存 `OVERALL`H5 仍可切日榜。
- `limit` 默认 30最大 100。
- 排序固定为:消费金币降序、达到当前消费值的事件时间升序、用户 ID 升序。
- `DAILY` 按活动时区自然日统计;活动结束结算只使用 `OVERALL` Top30。
- `DAILY` 按活动时区自然日统计并每日结算 Top1/Top2/Top3;活动结束结算 `OVERALL` Top30。
- 用户资料通过 Java 批量接口补齐;资料服务异常时榜单仍返回 `userId` 和分数。
### 2.5 我的抽奖记录
@ -152,6 +152,8 @@
"prizes": [
{
"id": "0",
"rankingPeriod": "DAILY",
"rankingType": "TYCOON",
"prizeName": "Prize 1",
"prizeImage": "https://example.com/prize-1.png",
"weight": 100,
@ -176,8 +178,8 @@
示例数组为节省篇幅只展示一项,真实启用配置必须满足:
- 恰好 7 个奖项,全部启用,`sortOrder` 完整覆盖 1..7 且不重复;权重必须大于 0。
- 恰好 6 个排名档:`1``2``3``4-7``8-10``11-30`
- `sysOrigin` 固定 `LIKEI``rankingType` 固定 `TYCOON`;配置 `rankingPeriod` 固定 `OVERALL`。
- 两个榜单各配置日榜 `1``2``3` 三档和总榜 `1``2``3``4-7``8-10``11-30` 六档,共 18 行
- `sysOrigin` 固定 `LIKEI`活动默认口径仍为 `TYCOON / OVERALL`,奖励行必须显式提交 `rankingType``rankingPeriod`。
- 首次启用时 `startTime` 必须严格晚于当前时间;同租户启用活动时间窗不能重叠。
- 计数窗口严格为 `[startTime, endTime)`。活动开始后锁定时间、口径、奖项、排名奖励和启停状态,只允许修改名称、说明等展示信息。
- `settlementDelayMinutes` 默认 30可配置 0..1440。
@ -225,7 +227,7 @@
| POST | `/go/resident-activity/game-king/settlement/{id}` | 到结算时间后人工冻结并派发一小批 |
| POST | `/go/resident-activity/game-king/settlement/retry/{recordId}` | 只重试 `FAILED` |
结算只冻结一次 `OVERALL` Top30。记录和发奖任务在同一事务生成人工接口每次最多派发该活动 4 个 `PENDING`,其余由内部定时任务继续。单项硬超时 20 秒,因此最坏外部调用约 80 秒,并为数据库处理留出余量。
定时任务按活动时区每日冻结两个 `DAILY` Top3人工接口只冻结一次 `OVERALL` Top30。门闩、记录和发奖任务在同一事务生成迟到流水仍进入总榜但不会改变已冻结日榜。人工接口每次最多派发该活动 4 个 `PENDING`,其余由内部定时任务继续。
### 3.4 事件补数
@ -279,7 +281,7 @@ Header`X-Internal-Token`。
本 Go 服务不启动 ticker。由 `chatapp-cron` 周期调用:
- 每次最多修复 20 个过期 `PROCESSING`,并扫描、冻结 10 个到期活动。
- 每次最多修复 20 个过期 `PROCESSING`,并分别扫描 10 个到期日榜周期和 10 个到期总榜活动。
- 每次最多派发全局 4 个 `PENDING`;单项硬超时 20 秒,调用方超时应配置为至少 120 秒。
- 只自动恢复 `PENDING``FAILED` / `UNKNOWN` 永不自动重试;超过 10 分钟的 `PROCESSING` 只转为 `UNKNOWN`
@ -289,15 +291,19 @@ Header`X-Internal-Token`。
{
"scanned": 2,
"settled": 2,
"dailyScanned": 1,
"dailySettled": 1,
"overallScanned": 1,
"overallSettled": 1,
"failedIds": []
}
```
## 7. 数据库迁移与性能
迁移文件:`migrations/058_yumi_game_king.sql`。
基础迁移为 `058`,双榜迁移为 `062`,日榜每日发奖迁移为 `063_yumi_game_king_daily_settlement.sql`。
- 新建活动、七奖项、六档奖励、幂等消费账本、总榜/日榜聚合、抽奖、结算和发放表
- `063` 为奖励与结算记录增加周期维度,并新增空榜也会落库的每日结算门闩
- 排行读取物化聚合并使用复合排序索引,不对消费流水做运行时 `GROUP BY`
- 补数给 `task_center_event_archive_outbox` 增加 `(sys_origin,event_type,occurred_at,id)` 复合索引,以范围 + keyset 扫描,避免全表排序。
- 迁移不执行历史 `UPDATE/DELETE/backfill`。新增复合索引仍属于线上 DDL执行前应按目标 MySQL 版本、表数据量和 DDL 策略确认锁影响,并在低峰发布。

View File

@ -53,13 +53,14 @@ type YumiGameKingPrize struct {
func (YumiGameKingPrize) TableName() string { return "yumi_game_king_prize" }
// YumiGameKingRankReward 保存活动结束时固定六个连续名次奖励档。
// YumiGameKingRankReward 保存 DAILY Top3 或 OVERALL Top30 的固定连续名次奖励档。
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"`
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"`
RankingType string `gorm:"column:ranking_type;size:32;uniqueIndex:uk_yumi_gk_rank_range,priority:3;index:idx_yumi_gk_rank_start,priority:3"`
RankingPeriod string `gorm:"column:ranking_period;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:4;index:idx_yumi_gk_rank_start,priority:4"`
EndRank int `gorm:"column:end_rank;uniqueIndex:uk_yumi_gk_rank_range,priority:5"`
ResourceGroupID int64 `gorm:"column:resource_group_id"`
RewardName string `gorm:"column:reward_name;size:128"`
RewardItemsJSON string `gorm:"column:reward_items_json;type:text"`
@ -69,6 +70,24 @@ type YumiGameKingRankReward struct {
func (YumiGameKingRankReward) TableName() string { return "yumi_game_king_rank_reward" }
// YumiGameKingPeriodSettlement 是包括空榜在内的日结门闩。PROCESSING 后事件仍可进入
// 总榜,但不能再改变该自然日已经冻结的 DAILY 排名。
type YumiGameKingPeriodSettlement struct {
ID int64 `gorm:"column:id;primaryKey"`
ActivityID int64 `gorm:"column:activity_id;uniqueIndex:uk_yumi_gk_period,priority:1"`
PeriodType string `gorm:"column:period_type;size:32;uniqueIndex:uk_yumi_gk_period,priority:2"`
PeriodKey string `gorm:"column:period_key;size:32;uniqueIndex:uk_yumi_gk_period,priority:3"`
StatDate time.Time `gorm:"column:stat_date;type:date"`
SnapshotDueTime time.Time `gorm:"column:snapshot_due_time;index:idx_yumi_gk_period_due,priority:2"`
Status string `gorm:"column:status;size:32;index:idx_yumi_gk_period_due,priority:1"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
}
func (YumiGameKingPeriodSettlement) TableName() string {
return "yumi_game_king_period_settlement"
}
// YumiGameKingConsumeLedger 是活动维度的幂等消费账本,实时事件和后台补数共用此边界。
type YumiGameKingConsumeLedger struct {
ID int64 `gorm:"column:id;primaryKey"`
@ -151,19 +170,22 @@ type YumiGameKingDrawRecord struct {
func (YumiGameKingDrawRecord) TableName() string { return "yumi_game_king_draw_record" }
// YumiGameKingSettlementRecord 是活动结算时冻结的 Top30 排名和发奖状态。
// YumiGameKingSettlementRecord 是 DAILY Top3 或 OVERALL Top30 的冻结排名和发奖状态。
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"`
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"`
RankingType string `gorm:"column:ranking_type;size:32;uniqueIndex:uk_yumi_gk_settle_user,priority:3;uniqueIndex:uk_yumi_gk_settle_rank,priority:3"`
RankingPeriod string `gorm:"column:ranking_period;size:32;index:idx_yumi_gk_settle_status,priority:2"`
PeriodKey string `gorm:"column:period_key;size:32;uniqueIndex:uk_yumi_gk_settle_user,priority:2;uniqueIndex:uk_yumi_gk_settle_rank,priority:2;index:idx_yumi_gk_settle_status,priority:3"`
StatDate *time.Time `gorm:"column:stat_date;type:date"`
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_yumi_gk_settle_user,priority:4"`
RankNo int `gorm:"column:rank_no;uniqueIndex:uk_yumi_gk_settle_rank,priority:4"`
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"`
DeliveryStatus string `gorm:"column:delivery_status;size:32;index:idx_yumi_gk_settle_status,priority:2"`
DeliveryStatus string `gorm:"column:delivery_status;size:32;index:idx_yumi_gk_settle_status,priority:4"`
RetryCount int `gorm:"column:retry_count"`
FailureReason string `gorm:"column:failure_reason;size:500"`
DeliverTime *time.Time `gorm:"column:deliver_time"`

View File

@ -67,7 +67,7 @@ func (s *Service) buildDetail(ctx context.Context, activity model.YumiGameKingAc
Prizes: prizeViews,
RankRewards: rankViews,
SupportedRankingTypes: []string{rankingTypeTycoon, rankingTypeVictorious},
SupportedRankingPeriods: []string{"DAILY", rankingPeriodOverall},
SupportedRankingPeriods: []string{rankingPeriodDaily, rankingPeriodOverall},
ActivityStatus: activityStatus(activity, now),
ServerTime: now.UnixMilli(),
}, nil
@ -123,7 +123,7 @@ func (s *Service) SaveActivity(ctx context.Context, req SaveActivityRequest) (*A
}
if normalizedRanks == nil {
if existing == nil {
return nil, NewAppError(http.StatusBadRequest, "rank_rewards_required", "six rank reward tiers are required before enabling")
return nil, NewAppError(http.StatusBadRequest, "rank_rewards_required", "DAILY Top3 and OVERALL Top30 reward tiers are required before enabling")
}
inputs, loadErr := s.currentRankInputs(ctx, existing.ID)
if loadErr != nil {
@ -441,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("ranking_type ASC, start_rank ASC").Find(&rows).Error
err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).Order("ranking_period ASC, ranking_type ASC, start_rank ASC").Find(&rows).Error
return rows, err
}
@ -464,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), RankingType: row.RankingType, StartRank: row.StartRank, EndRank: row.EndRank, ResourceGroupID: FlexibleInt64(row.ResourceGroupID), RewardName: row.RewardName})
values = append(values, RankRewardInput{ID: FlexibleInt64(row.ID), RankingType: row.RankingType, RankingPeriod: row.RankingPeriod, StartRank: row.StartRank, EndRank: row.EndRank, ResourceGroupID: FlexibleInt64(row.ResourceGroupID), RewardName: row.RewardName})
}
return values, nil
}
@ -500,7 +500,7 @@ func replaceRankRewardsTx(tx *gorm.DB, activityID int64, values []normalizedRank
return err
}
rows = append(rows, model.YumiGameKingRankReward{
ID: id, ActivityID: activityID, RankingType: value.Input.RankingType, StartRank: value.Input.StartRank, EndRank: value.Input.EndRank,
ID: id, ActivityID: activityID, RankingType: value.Input.RankingType, RankingPeriod: value.Input.RankingPeriod, StartRank: value.Input.StartRank, EndRank: value.Input.EndRank,
ResourceGroupID: value.Snapshot.GroupID, RewardName: value.Input.RewardName,
RewardItemsJSON: value.Snapshot.ItemsJSON, CreateTime: now, UpdateTime: now,
})

View File

@ -144,7 +144,7 @@ func (s *Service) finishDeliveryItem(ctx context.Context, itemID int64, status,
return err
}
if item.OwnerType == OwnerSettlement {
return refreshSettlementStatusTx(tx, item.ActivityID)
return refreshSettlementStatusTx(tx, item)
}
return nil
})
@ -167,22 +167,27 @@ func updateDeliveryParentTx(tx *gorm.DB, item model.YumiGameKingDeliveryItem, st
}
}
func refreshSettlementStatusTx(tx *gorm.DB, activityID int64) error {
func refreshSettlementStatusTx(tx *gorm.DB, item model.YumiGameKingDeliveryItem) error {
var record model.YumiGameKingSettlementRecord
if err := tx.Where("id = ? AND activity_id = ?", item.OwnerID, item.ActivityID).First(&record).Error; err != nil {
return err
}
var total, succeeded, unfinished, failed int64
base := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ?", activityID)
base := tx.Model(&model.YumiGameKingSettlementRecord{}).
Where("activity_id = ? AND ranking_period = ? AND period_key = ?", item.ActivityID, record.RankingPeriod, record.PeriodKey)
if err := base.Count(&total).Error; err != nil {
return err
}
if total == 0 {
return nil
}
if err := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ? AND delivery_status = ?", activityID, DeliverySuccess).Count(&succeeded).Error; err != nil {
if err := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ? AND ranking_period = ? AND period_key = ? AND delivery_status = ?", item.ActivityID, record.RankingPeriod, record.PeriodKey, DeliverySuccess).Count(&succeeded).Error; err != nil {
return err
}
if err := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ? AND delivery_status IN ?", activityID, []string{DeliveryPending, DeliveryProcessing}).Count(&unfinished).Error; err != nil {
if err := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ? AND ranking_period = ? AND period_key = ? AND delivery_status IN ?", item.ActivityID, record.RankingPeriod, record.PeriodKey, []string{DeliveryPending, DeliveryProcessing}).Count(&unfinished).Error; err != nil {
return err
}
if err := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ? AND delivery_status IN ?", activityID, []string{DeliveryFailed, DeliveryUnknown}).Count(&failed).Error; err != nil {
if err := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ? AND ranking_period = ? AND period_key = ? AND delivery_status IN ?", item.ActivityID, record.RankingPeriod, record.PeriodKey, []string{DeliveryFailed, DeliveryUnknown}).Count(&failed).Error; err != nil {
return err
}
status := SettlementProcessing
@ -193,7 +198,12 @@ func refreshSettlementStatusTx(tx *gorm.DB, activityID int64) error {
} else if unfinished == 0 {
status = SettlementPartialFailed
}
return tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activityID).
if record.RankingPeriod == rankingPeriodDaily {
return tx.Model(&model.YumiGameKingPeriodSettlement{}).
Where("activity_id = ? AND period_type = ? AND period_key = ?", item.ActivityID, rankingPeriodDaily, record.PeriodKey).
Updates(map[string]any{"status": status, "update_time": time.Now()}).Error
}
return tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", item.ActivityID).
Updates(map[string]any{"settlement_status": status, "update_time": time.Now()}).Error
}
@ -228,7 +238,8 @@ func (s *Service) loadFrozenDeliveryContext(ctx context.Context, item model.Yumi
if err := s.db.WithContext(ctx).Where("id = ? AND activity_id = ?", record.RankRewardID, item.ActivityID).First(&rankReward).Error; err != nil {
return result, err
}
if rankReward.ResourceGroupID != item.ResourceGroupID {
if rankReward.ResourceGroupID != item.ResourceGroupID ||
rankReward.RankingPeriod != record.RankingPeriod || rankReward.RankingType != record.RankingType {
return result, NewAppError(http.StatusConflict, "delivery_snapshot_mismatch", "settlement reward group does not match frozen rank tier")
}
result.BusinessNo = record.BusinessNo
@ -323,7 +334,7 @@ func (s *Service) ResolveUnknownDeliveryItem(ctx context.Context, itemID int64,
return err
}
if item.OwnerType == OwnerSettlement {
return refreshSettlementStatusTx(tx, item.ActivityID)
return refreshSettlementStatusTx(tx, item)
}
return nil
}); err != nil {
@ -381,7 +392,7 @@ func (s *Service) markStaleProcessingUnknown(ctx context.Context, limit int) err
return err
}
if item.OwnerType == OwnerSettlement {
return refreshSettlementStatusTx(tx, item.ActivityID)
return refreshSettlementStatusTx(tx, item)
}
return nil
}); err != nil {

View File

@ -93,16 +93,32 @@ func (s *Service) applyEventToActivity(ctx context.Context, activityID int64, ev
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 {
statDate := dateKey(event.OccurredAt, location)
frozen, err := dailyPeriodFrozenTx(tx, activity.ID, statDate)
if err != nil {
return err
}
// cron 即使停机也不能延长日榜口径:到达“自然日结束/活动结束 + 等待”
// 后迟到的事件仍进入 ledger 和 OVERALL但不再改变 DAILY 快照候选。
if !frozen && now.Before(dailySettlementDueTime(activity, statDate, location)) {
if err := addDailyConsumeTx(tx, activity.ID, statDate, 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 {
statDate := dateKey(event.OccurredAt, location)
frozen, err := dailyPeriodFrozenTx(tx, activity.ID, statDate)
if err != nil {
return err
}
if !frozen && now.Before(dailySettlementDueTime(activity, statDate, location)) {
if err := addDailyWinTx(tx, activity.ID, statDate, event.UserID, event.DeltaValue, event.OccurredAt, now); err != nil {
return err
}
}
}
accepted = true
return nil
@ -110,6 +126,16 @@ func (s *Service) applyEventToActivity(ctx context.Context, activityID int64, ev
return accepted, err
}
func dailyPeriodFrozenTx(tx *gorm.DB, activityID int64, statDate time.Time) (bool, error) {
var count int64
// 日结先持有活动排他锁;事件持有共享锁后才检查门闩,因此不会出现“结算已读榜、
// 迟到事件又写入日聚合”的窗口。门闩存在即冻结,不依赖最终发奖成功状态。
err := tx.Model(&model.YumiGameKingPeriodSettlement{}).
Where("activity_id = ? AND period_type = ? AND period_key = ?", activityID, rankingPeriodDaily, statDate.Format("2006-01-02")).
Count(&count).Error
return count > 0, 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}

View File

@ -18,7 +18,8 @@ import (
"gorm.io/gorm/clause"
)
var fixedRankRanges = [][2]int{{1, 1}, {2, 2}, {3, 3}, {4, 7}, {8, 10}, {11, 30}}
var fixedOverallRankRanges = [][2]int{{1, 1}, {2, 2}, {3, 3}, {4, 7}, {8, 10}, {11, 30}}
var fixedDailyRankRanges = [][2]int{{1, 1}, {2, 2}, {3, 3}}
func normalizeSysOrigin(value string) string {
value = strings.ToUpper(strings.TrimSpace(value))
@ -62,6 +63,17 @@ func normalizeRankingPeriod(value string) (string, error) {
return value, nil
}
func normalizeRewardRankingPeriod(value string) (string, error) {
value = strings.ToUpper(strings.TrimSpace(value))
if value == "" {
value = rankingPeriodOverall
}
if value != rankingPeriodDaily && value != rankingPeriodOverall {
return "", NewAppError(http.StatusBadRequest, "invalid_reward_ranking_period", "rank reward rankingPeriod must be DAILY or OVERALL")
}
return value, nil
}
func normalizeActivityInput(req SaveActivityRequest) (SaveActivityRequest, error) {
req.ActivityCode = strings.TrimSpace(req.ActivityCode)
req.ActivityName = strings.TrimSpace(req.ActivityName)
@ -153,8 +165,9 @@ 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)*2 {
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_count", "exactly six tiers for each of TYCOON and VICTORIOUS are required")
expectedCount := (len(fixedOverallRankRanges) + len(fixedDailyRankRanges)) * 2
if len(values) != expectedCount {
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_count", "each ranking requires DAILY Top1/Top2/Top3 and six OVERALL tiers")
}
byRange := make(map[string]RankRewardInput, len(values))
for _, value := range values {
@ -163,19 +176,31 @@ func (s *Service) normalizeRankRewards(ctx context.Context, sysOrigin string, va
if err != nil {
return nil, err
}
key := fmt.Sprintf("%s:%d-%d", value.RankingType, value.StartRank, value.EndRank)
value.RankingPeriod, err = normalizeRewardRankingPeriod(value.RankingPeriod)
if err != nil {
return nil, err
}
key := fmt.Sprintf("%s:%s:%d-%d", value.RankingPeriod, 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)*2)
result := make([]normalizedRankReward, 0, expectedCount)
periodRanges := []struct {
period string
ranges [][2]int
}{
{period: rankingPeriodDaily, ranges: fixedDailyRankRanges},
{period: rankingPeriodOverall, ranges: fixedOverallRankRanges},
}
for _, periodRange := range periodRanges {
for _, rankingType := range []string{rankingTypeTycoon, rankingTypeVictorious} {
for _, rankRange := range fixedRankRanges {
key := fmt.Sprintf("%s:%d-%d", rankingType, rankRange[0], rankRange[1])
for _, rankRange := range periodRange.ranges {
key := fmt.Sprintf("%s:%s:%d-%d", periodRange.period, 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")
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_ranges", "DAILY rewards must be Top1/Top2/Top3 and OVERALL rewards must cover Top30")
}
if value.ResourceGroupID.Int64() <= 0 {
return nil, NewAppError(http.StatusBadRequest, "invalid_rank_reward_group", "every rank tier must reference a reward group")
@ -194,6 +219,7 @@ func (s *Service) normalizeRankRewards(ctx context.Context, sysOrigin string, va
result = append(result, normalizedRankReward{Input: value, Snapshot: snapshot})
}
}
}
return result, nil
}
@ -316,6 +342,7 @@ func rankRewardView(row model.YumiGameKingRankReward) RankRewardView {
ID: row.ID,
ActivityID: row.ActivityID,
RankingType: row.RankingType,
RankingPeriod: row.RankingPeriod,
StartRank: row.StartRank,
EndRank: row.EndRank,
ResourceGroupID: row.ResourceGroupID,

View File

@ -355,7 +355,8 @@ func (s *Service) SettlementRecords(ctx context.Context, activityID int64) ([]Se
return nil, err
}
var rows []model.YumiGameKingSettlementRecord
if err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).Order("rank_no ASC").Find(&rows).Error; err != nil {
if err := s.db.WithContext(ctx).Where("activity_id = ?", activityID).
Order("ranking_period ASC, period_key DESC, ranking_type ASC, rank_no ASC").Find(&rows).Error; err != nil {
return nil, err
}
result := make([]SettlementRecordView, 0, len(rows))
@ -384,10 +385,18 @@ 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,
RankingType: row.RankingType, RankingPeriod: row.RankingPeriod, PeriodKey: row.PeriodKey,
StatDate: formatStatDate(row.StatDate), Score: row.Score,
TotalConsumed: row.TotalConsumed, RankRewardID: row.RankRewardID,
ResourceGroupID: row.ResourceGroupID, BusinessNo: row.BusinessNo,
DeliveryStatus: row.DeliveryStatus, RetryCount: row.RetryCount,
FailureReason: row.FailureReason, CreateTime: unixMilli(row.CreateTime), DeliverTime: unixMilliPtr(row.DeliverTime),
}
}
func formatStatDate(value *time.Time) string {
if value == nil || value.IsZero() {
return ""
}
return value.Format("2006-01-02")
}

View File

@ -17,6 +17,7 @@ import (
const (
settlementDispatchBatch = 4
settlementDueBatch = 10
dailySettlementDueBatch = 10
staleDeliveryBatch = 20
)
@ -73,10 +74,10 @@ func (s *Service) freezeSettlement(ctx context.Context, activityID int64) error
}
var rewards []model.YumiGameKingRankReward
if err := tx.Where("activity_id = ?", activity.ID).Order("ranking_type ASC, start_rank ASC").Find(&rewards).Error; err != nil {
if err := tx.Where("activity_id = ? AND ranking_period = ?", activity.ID, rankingPeriodOverall).Order("ranking_type ASC, start_rank ASC").Find(&rewards).Error; err != nil {
return err
}
if len(rewards) != len(fixedRankRanges)*2 {
if len(rewards) != len(fixedOverallRankRanges)*2 {
return NewAppError(http.StatusConflict, "rank_reward_config_invalid", "six reward tiers are required for each ranking")
}
created := 0
@ -90,7 +91,7 @@ func (s *Service) freezeSettlement(ctx context.Context, activityID int64) error
Order(scoreColumn + " DESC, " + reachedColumn + " ASC, user_id ASC").Limit(30).Find(&users).Error; err != nil {
return err
}
boardRewards := make([]model.YumiGameKingRankReward, 0, len(fixedRankRanges))
boardRewards := make([]model.YumiGameKingRankReward, 0, len(fixedOverallRankRanges))
for _, reward := range rewards {
if reward.RankingType == rankingType {
boardRewards = append(boardRewards, reward)
@ -110,13 +111,13 @@ func (s *Service) freezeSettlement(ctx context.Context, activityID int64) error
if err != nil {
return err
}
businessNo := fmt.Sprintf("YUMI_GAME_KING_SETTLEMENT:%d:%s:%d", activity.ID, rankingType, rank)
businessNo := fmt.Sprintf("YUMI_GAME_KING_SETTLEMENT:%d:%s:%s:%d", activity.ID, rankingPeriodOverall, 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,
ID: recordID, ActivityID: activity.ID, RankingType: rankingType, RankingPeriod: rankingPeriodOverall, PeriodKey: rankingPeriodOverall, UserID: user.UserID, RankNo: rank,
TotalConsumed: user.TotalConsumed, Score: score, RankRewardID: reward.ID, ResourceGroupID: reward.ResourceGroupID,
BusinessNo: businessNo, DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now,
}
@ -150,27 +151,217 @@ func rankRewardFor(rows []model.YumiGameKingRankReward, rank int) (model.YumiGam
return model.YumiGameKingRankReward{}, false
}
// nextDailySettlement 返回活动时区内最早尚未建立门闩且已经到达结算等待时间的自然日。
// 每次只推进一个连续日期,空榜也会写门闩,因此 cron 中断数日后仍能顺序补齐且不会
// 给结算截点之后才迟到的历史流水重新制造获奖机会。
func (s *Service) nextDailySettlement(ctx context.Context, activity model.YumiGameKingActivity, now time.Time) (time.Time, time.Time, bool, error) {
_, location, err := normalizeTimezone(activity.Timezone)
if err != nil {
return time.Time{}, time.Time{}, false, err
}
statDate := dateKey(activity.StartTime, location)
var latest model.YumiGameKingPeriodSettlement
err = s.db.WithContext(ctx).
Where("activity_id = ? AND period_type = ?", activity.ID, rankingPeriodDaily).
// YYYY-MM-DD 的 period_key 与日期顺序一致,并可直接利用唯一索引前缀,避免
// 每次 cron 为寻找最新门闩额外排序该活动的全部历史日期。
Order("period_key DESC").Limit(1).First(&latest).Error
if err == nil {
statDate = latest.StatDate.AddDate(0, 0, 1)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return time.Time{}, time.Time{}, false, err
}
lastDate := dateKey(activity.EndTime.Add(-time.Nanosecond), location)
if statDate.After(lastDate) {
return time.Time{}, time.Time{}, false, nil
}
dueTime := dailySettlementDueTime(activity, statDate, location)
return statDate, dueTime, !now.Before(dueTime), nil
}
func dailySettlementDueTime(activity model.YumiGameKingActivity, statDate time.Time, location *time.Location) time.Time {
periodEndLocal := time.Date(statDate.Year(), statDate.Month(), statDate.Day()+1, 0, 0, 0, 0, location)
dueBase := periodEndLocal
if activity.EndTime.Before(dueBase) {
dueBase = activity.EndTime
}
return dueBase.Add(time.Duration(activity.SettlementDelayMinutes) * time.Minute)
}
// freezeDailySettlement 在活动排他锁内先建立 DAILY 门闩,再读取日聚合 Top3。
// 事件事务持有同一活动的共享锁并检查门闩,所以锁释放后的迟到事件只补总榜。
func (s *Service) freezeDailySettlement(ctx context.Context, activityID int64, statDate, dueTime time.Time) (bool, error) {
createdGate := false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var activity model.YumiGameKingActivity
if err := withWriteLock(tx).Where("id = ?", activityID).First(&activity).Error; err != nil {
return err
}
if !activity.Enabled {
return NewAppError(http.StatusConflict, "activity_disabled", "disabled activity cannot be settled")
}
if time.Now().Before(dueTime) {
return NewAppError(http.StatusConflict, "daily_settlement_not_due", "daily settlement time has not arrived")
}
periodKey := statDate.Format("2006-01-02")
var existing model.YumiGameKingPeriodSettlement
findErr := tx.Where("activity_id = ? AND period_type = ? AND period_key = ?", activity.ID, rankingPeriodDaily, periodKey).First(&existing).Error
if findErr == nil {
return nil
}
if !errors.Is(findErr, gorm.ErrRecordNotFound) {
return findErr
}
gateID, err := utils.NextID()
if err != nil {
return err
}
now := time.Now()
gate := model.YumiGameKingPeriodSettlement{
ID: gateID, ActivityID: activity.ID, PeriodType: rankingPeriodDaily,
PeriodKey: periodKey, StatDate: statDate, SnapshotDueTime: dueTime,
Status: SettlementProcessing, CreateTime: now, UpdateTime: now,
}
if err := tx.Create(&gate).Error; err != nil {
return err
}
createdGate = true
var rewards []model.YumiGameKingRankReward
if err := tx.Where("activity_id = ? AND ranking_period = ?", activity.ID, rankingPeriodDaily).
Order("ranking_type ASC, start_rank ASC").Find(&rewards).Error; err != nil {
return err
}
// 迁移前已经开始的旧活动没有日榜奖励配置;空配置只建立完成门闩,不擅自
// 复制总榜奖励。新活动启用前则由配置校验强制两个榜单各三档。
if len(rewards) == 0 {
return tx.Model(&model.YumiGameKingPeriodSettlement{}).Where("id = ?", gate.ID).
Updates(map[string]any{"status": SettlementCompleted, "update_time": now}).Error
}
if len(rewards) != len(fixedDailyRankRanges)*2 {
return NewAppError(http.StatusConflict, "daily_rank_reward_config_invalid", "DAILY Top1, Top2 and Top3 rewards are required for each ranking")
}
createdRecords := 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.YumiGameKingUserDaily
if err := tx.Where("activity_id = ? AND stat_date = ? AND "+scoreColumn+" > 0", activity.ID, statDate).
Order(scoreColumn + " DESC, " + reachedColumn + " ASC, user_id ASC").Limit(3).Find(&users).Error; err != nil {
return err
}
boardRewards := make([]model.YumiGameKingRankReward, 0, len(fixedDailyRankRanges))
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, "daily_rank_reward_config_invalid", "DAILY reward tier does not cover Top3")
}
recordID, idErr := utils.NextID()
if idErr != nil {
return idErr
}
itemID, idErr := utils.NextID()
if idErr != nil {
return idErr
}
score := user.TotalConsumed
if rankingType == rankingTypeVictorious {
score = user.TotalWon
}
storedDate := statDate
record := model.YumiGameKingSettlementRecord{
ID: recordID, ActivityID: activity.ID, RankingType: rankingType,
RankingPeriod: rankingPeriodDaily, PeriodKey: periodKey, StatDate: &storedDate,
UserID: user.UserID, RankNo: rank, TotalConsumed: user.TotalConsumed, Score: score,
RankRewardID: reward.ID, ResourceGroupID: reward.ResourceGroupID,
BusinessNo: fmt.Sprintf("YUMI_GAME_KING_SETTLEMENT:%d:%s:%s:%s:%d", activity.ID, rankingPeriodDaily, periodKey, rankingType, rank),
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
}
createdRecords++
}
}
if createdRecords == 0 {
return tx.Model(&model.YumiGameKingPeriodSettlement{}).Where("id = ?", gate.ID).
Updates(map[string]any{"status": SettlementCompleted, "update_time": now}).Error
}
return nil
})
return createdGate && err == nil, err
}
// SettleDue 是外部 cron 的唯一批量触发业务方法;本仓库不启动 ticker。
func (s *Service) SettleDue(ctx context.Context) (*SettleDueResult, error) {
// 先把过期 PROCESSING 保守转 UNKNOWN只做状态修复不盲发。
if err := s.markStaleProcessingUnknown(ctx, staleDeliveryBatch); err != nil {
return nil, err
}
result := &SettleDueResult{FailedIDs: []string{}}
now := time.Now()
var dailyActivities []model.YumiGameKingActivity
if err := s.db.WithContext(ctx).
Where("enabled = ? AND start_time < ?", true, now).
Order("start_time ASC, id ASC").Find(&dailyActivities).Error; err != nil {
return nil, err
}
for _, activity := range dailyActivities {
if result.DailyScanned >= dailySettlementDueBatch {
break
}
statDate, dueTime, due, err := s.nextDailySettlement(ctx, activity, now)
if err != nil {
result.FailedIDs = append(result.FailedIDs, fmt.Sprintf("%d:%s", activity.ID, rankingPeriodDaily))
continue
}
if !due {
continue
}
result.Scanned++
result.DailyScanned++
created, err := s.freezeDailySettlement(ctx, activity.ID, statDate, dueTime)
if err != nil {
result.FailedIDs = append(result.FailedIDs, fmt.Sprintf("%d:%s:%s", activity.ID, rankingPeriodDaily, statDate.Format("2006-01-02")))
continue
}
if created {
result.Settled++
result.DailySettled++
}
}
var activities []model.YumiGameKingActivity
if err := s.db.WithContext(ctx).
Where("enabled = ? AND settlement_time <= ? AND settlement_status = ?", true, time.Now(), SettlementNotStarted).
Where("enabled = ? AND settlement_time <= ? AND settlement_status = ?", true, now, SettlementNotStarted).
Order("settlement_time ASC, id ASC").Limit(settlementDueBatch).Find(&activities).Error; err != nil {
return nil, err
}
result := &SettleDueResult{FailedIDs: []string{}}
for _, activity := range activities {
result.Scanned++
result.OverallScanned++
// 批量端点只快速冻结排名并生成 PENDING不在单个活动内串行发送 Top30。
if err := s.freezeSettlement(ctx, activity.ID); err != nil {
result.FailedIDs = append(result.FailedIDs, strconv.FormatInt(activity.ID, 10))
result.FailedIDs = append(result.FailedIDs, strconv.FormatInt(activity.ID, 10)+":"+rankingPeriodOverall)
continue
}
result.Settled++
result.OverallSettled++
}
// 每次最多领取 4 个全局 PENDING单项 dispatch 硬超时 20 秒,外部调用上界约
// 80 秒,为冻结/状态修复留约 40 秒chatapp-cron 应配置 >=120 秒超时。

View File

@ -27,6 +27,7 @@ const (
rankingTypeTycoon = "TYCOON"
rankingTypeVictorious = "VICTORIOUS"
rankingPeriodDaily = "DAILY"
rankingPeriodOverall = "OVERALL"
activityUpcoming = "UPCOMING"
@ -160,6 +161,7 @@ type RankRewardView struct {
ID int64 `json:"id,string"`
ActivityID int64 `json:"activityId,string"`
RankingType string `json:"rankingType"`
RankingPeriod string `json:"rankingPeriod"`
StartRank int `json:"startRank"`
EndRank int `json:"endRank"`
ResourceGroupID int64 `json:"resourceGroupId,string"`
@ -261,6 +263,9 @@ type SettlementRecordView struct {
Rank int `json:"rank"`
TotalConsumed int64 `json:"totalConsumed"`
RankingType string `json:"rankingType"`
RankingPeriod string `json:"rankingPeriod"`
PeriodKey string `json:"periodKey"`
StatDate string `json:"statDate,omitempty"`
Score int64 `json:"score"`
RankRewardID int64 `json:"rankRewardId,string"`
ResourceGroupID int64 `json:"resourceGroupId,string"`
@ -287,6 +292,7 @@ type PrizeInput struct {
type RankRewardInput struct {
ID FlexibleInt64 `json:"id"`
RankingType string `json:"rankingType"`
RankingPeriod string `json:"rankingPeriod"`
StartRank int `json:"startRank"`
EndRank int `json:"endRank"`
ResourceGroupID FlexibleInt64 `json:"resourceGroupId"`
@ -333,6 +339,10 @@ type BackfillResult struct {
type SettleDueResult struct {
Scanned int `json:"scanned"`
Settled int `json:"settled"`
DailyScanned int `json:"dailyScanned"`
DailySettled int `json:"dailySettled"`
OverallScanned int `json:"overallScanned"`
OverallSettled int `json:"overallSettled"`
FailedIDs []string `json:"failedIds"`
}

View File

@ -0,0 +1,57 @@
-- Yumi Game King: configurable DAILY Top1/Top2/Top3 rewards and idempotent daily settlement.
--
-- Performance review:
-- 1. rank_reward and settlement_record are bounded configuration/result tables. Their index
-- rebuilds do not scan the high-volume game-event ledger or task archive.
-- 2. Existing settlement rows are marked OVERALL by constant defaults; no aggregate backfill
-- or ranking sort is executed by this migration.
-- 3. The new period gate has one row per activity day. Its due-status index supports bounded
-- cron discovery, while the unique activity/date key makes repeated cron calls idempotent.
-- 4. Run the two ALTER statements during low traffic. LOCK=NONE keeps reads/writes available,
-- but MySQL still needs temporary space to rebuild the small secondary indexes.
ALTER TABLE `yumi_game_king_rank_reward`
DROP INDEX `uk_yumi_gk_rank_range`,
DROP INDEX `idx_yumi_gk_rank_start`,
ADD COLUMN `ranking_period` VARCHAR(32) NOT NULL DEFAULT 'OVERALL' AFTER `ranking_type`,
ADD UNIQUE KEY `uk_yumi_gk_rank_range` (
`activity_id`, `ranking_period`, `ranking_type`, `start_rank`, `end_rank`
),
ADD KEY `idx_yumi_gk_rank_start` (
`activity_id`, `ranking_period`, `ranking_type`, `start_rank`
),
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`,
DROP INDEX `idx_yumi_gk_settle_status`,
ADD COLUMN `ranking_period` VARCHAR(32) NOT NULL DEFAULT 'OVERALL' AFTER `ranking_type`,
ADD COLUMN `period_key` VARCHAR(32) NOT NULL DEFAULT 'OVERALL' AFTER `ranking_period`,
ADD COLUMN `stat_date` DATE NULL AFTER `period_key`,
ADD UNIQUE KEY `uk_yumi_gk_settle_user` (
`activity_id`, `period_key`, `ranking_type`, `user_id`
),
ADD UNIQUE KEY `uk_yumi_gk_settle_rank` (
`activity_id`, `period_key`, `ranking_type`, `rank_no`
),
ADD KEY `idx_yumi_gk_settle_status` (
`activity_id`, `ranking_period`, `period_key`, `delivery_status`
),
ALGORITHM=INPLACE, LOCK=NONE;
CREATE TABLE IF NOT EXISTS `yumi_game_king_period_settlement` (
`id` BIGINT NOT NULL,
`activity_id` BIGINT NOT NULL,
`period_type` VARCHAR(32) NOT NULL DEFAULT 'DAILY',
`period_key` VARCHAR(32) NOT NULL,
`stat_date` DATE NOT NULL,
`snapshot_due_time` DATETIME(3) NOT NULL,
`status` VARCHAR(32) NOT NULL DEFAULT 'PROCESSING',
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_yumi_gk_period` (`activity_id`, `period_type`, `period_key`),
KEY `idx_yumi_gk_period_due` (`status`, `snapshot_due_time`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Daily Game King settlement gate including empty ranking days';