package gameking import ( "context" "errors" "fmt" "net/http" "strconv" "time" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "gorm.io/gorm" ) const ( settlementDispatchBatch = 4 settlementDueBatch = 10 staleDeliveryBatch = 20 ) // Settle 在结算截点后冻结 TYCOON/OVERALL Top30,并发送尚未开始的 PENDING 奖励。 // 重复调用不会重建快照,也不会自动重试 FAILED/UNKNOWN。 func (s *Service) Settle(ctx context.Context, activityID int64) error { if activityID <= 0 { return NewAppError(http.StatusBadRequest, "invalid_activity_id", "activityId is required") } if err := s.freezeSettlement(ctx, activityID); err != nil { return err } var items []model.YumiGameKingDeliveryItem if err := s.db.WithContext(ctx). Where("activity_id = ? AND owner_type = ? AND delivery_status = ?", activityID, OwnerSettlement, DeliveryPending). Order("id ASC").Limit(settlementDispatchBatch).Find(&items).Error; err != nil { return err } // 人工结算同样限制每次最多发送 4 组,最坏外部调用约 80 秒并为 DB 留出余量; // 剩余 PENDING 由后续 settle-due 批次继续恢复,FAILED/UNKNOWN 不会自动发送。 var lastErr error for _, item := range items { if err := s.dispatchDeliveryItem(ctx, item.ID, false); err != nil { lastErr = err } } return lastErr } func (s *Service) freezeSettlement(ctx context.Context, activityID int64) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var activity model.YumiGameKingActivity // 排他活动锁与事件/抽奖共享锁构成冻结闸门:先进入的业务事务完成后,状态在同一 // 事务切为 PROCESSING;此后新事件会在共享锁放行后看到非 NOT_STARTED 并拒绝入账。 if err := withWriteLock(tx).Where("id = ?", activityID).First(&activity).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return NewAppError(http.StatusNotFound, "activity_not_found", "game king activity was not found") } return err } now := time.Now() if !activity.Enabled { return NewAppError(http.StatusConflict, "activity_disabled", "disabled activity cannot be settled") } if now.Before(activity.SettlementTime) { return NewAppError(http.StatusConflict, "settlement_not_due", "activity settlement time has not arrived") } if activity.SettlementStatus != SettlementNotStarted { return nil } if err := tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activity.ID). Updates(map[string]any{"settlement_status": SettlementProcessing, "update_time": now}).Error; err != nil { return err } var users []model.YumiGameKingUser if err := tx.Where("activity_id = ? AND total_consumed > 0", activity.ID). Order("total_consumed DESC, consume_reached_time ASC, user_id ASC").Limit(30).Find(&users).Error; err != nil { return err } if len(users) == 0 { return tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activity.ID). Updates(map[string]any{"settlement_status": SettlementCompleted, "update_time": now}).Error } var rewards []model.YumiGameKingRankReward if err := tx.Where("activity_id = ?", activity.ID).Order("start_rank ASC").Find(&rewards).Error; err != nil { 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 }) } func rankRewardFor(rows []model.YumiGameKingRankReward, rank int) (model.YumiGameKingRankReward, bool) { for _, row := range rows { if rank >= row.StartRank && rank <= row.EndRank { return row, true } } return model.YumiGameKingRankReward{}, false } // 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 } var activities []model.YumiGameKingActivity if err := s.db.WithContext(ctx). Where("enabled = ? AND settlement_time <= ? AND settlement_status = ?", true, time.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++ // 批量端点只快速冻结排名并生成 PENDING,不在单个活动内串行发送 Top30。 if err := s.freezeSettlement(ctx, activity.ID); err != nil { result.FailedIDs = append(result.FailedIDs, strconv.FormatInt(activity.ID, 10)) continue } result.Settled++ } // 每次最多领取 4 个全局 PENDING;单项 dispatch 硬超时 20 秒,外部调用上界约 // 80 秒,为冻结/状态修复留约 40 秒,chatapp-cron 应配置 >=120 秒超时。 if _, err := s.RecoverPendingDeliveries(ctx, settlementDispatchBatch); err != nil { return nil, err } return result, nil }