package mysql import ( "context" "crypto/sha1" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "strings" "hyapp/pkg/appcode" "hyapp/pkg/idgen" "hyapp/pkg/xerr" lotteryengine "hyapp/services/activity-service/internal/domain/lotteryengine" domain "hyapp/services/activity-service/internal/domain/wheel" ) const wheelPPMScale int64 = 1_000_000 type wheelRTPWindow struct { WheelID string WindowIndex int64 TargetRTPPPM int64 ControlDraws int64 PaidDraws int64 WagerCoins int64 TargetPayout int64 ActualRTPValue int64 CarryPPM int64 Status string } type wheelPool struct { WheelID string Balance int64 ReserveFloor int64 TotalIn int64 TotalOut int64 } type wheelCandidate struct { Tier domain.Tier } // PublishWheelRuleConfig 新增转盘不可变配置版本;历史 draw record 永远引用旧版本,避免后台改配置后审计口径漂移。 func (r *Repository) PublishWheelRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error) { if r == nil || r.db == nil { return domain.RuleConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } appCode := appcode.FromContext(ctx) config.AppCode = appCode config.WheelID = normalizeWheelID(config.WheelID) config.Tiers = normalizeWheelTiers(config.Tiers) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return domain.RuleConfig{}, err } defer func() { _ = tx.Rollback() }() var latest sql.NullInt64 if err := tx.QueryRowContext(ctx, ` SELECT MAX(rule_version) FROM wheel_rule_versions WHERE app_code = ? AND wheel_id = ? FOR UPDATE`, appCode, config.WheelID, ).Scan(&latest); err != nil { return domain.RuleConfig{}, err } if latest.Valid { config.RuleVersion = latest.Int64 + 1 } else { config.RuleVersion = 1 } if config.EffectiveFromMS <= 0 { config.EffectiveFromMS = nowMS } config.CreatedAtMS = nowMS if _, err := tx.ExecContext(ctx, ` INSERT INTO wheel_rule_versions ( app_code, wheel_id, rule_version, enabled, draw_price_coins, target_rtp_ppm, pool_rate_ppm, settlement_window_draws, initial_pool_coins, pool_reserve_coins, max_single_rtp_payout, effective_from_ms, created_by_admin_id, created_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, appCode, config.WheelID, config.RuleVersion, config.Enabled, config.DrawPriceCoins, config.TargetRTPPPM, config.PoolRatePPM, config.SettlementWindowDraws, config.InitialPoolCoins, config.PoolReserveCoins, config.MaxSingleRTPPayout, config.EffectiveFromMS, config.CreatedByAdminID, nowMS, ); err != nil { return domain.RuleConfig{}, err } for _, tier := range config.Tiers { if _, err := tx.ExecContext(ctx, ` INSERT INTO wheel_prize_tiers ( app_code, wheel_id, rule_version, tier_id, display_name, reward_type, reward_id, reward_count, reward_coins, rtp_value_coins, weight_ppm, enabled, metadata_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, appCode, config.WheelID, config.RuleVersion, tier.TierID, tier.DisplayName, tier.RewardType, tier.RewardID, tier.RewardCount, tier.RewardCoins, tier.RTPValueCoins, tier.WeightPPM, tier.Enabled, wheelMetadataJSON(tier.MetadataJSON), ); err != nil { return domain.RuleConfig{}, err } } if err := tx.Commit(); err != nil { return domain.RuleConfig{}, err } return config, nil } func (r *Repository) GetWheelRuleConfig(ctx context.Context, wheelID string) (domain.RuleConfig, bool, error) { if r == nil || r.db == nil { return domain.RuleConfig{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") } return r.getWheelRuleConfig(ctx, r.db, appcode.FromContext(ctx), normalizeWheelID(wheelID), false) } // ExecuteWheelDraw 是转盘线上主事务:扣费事实已发生,事务内只做命中奖档、RTP 观察、奖池扣减和发放 outbox。 func (r *Repository) ExecuteWheelDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error) { if r == nil || r.db == nil { return domain.DrawResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } cmd.WheelID = normalizeWheelID(cmd.WheelID) if cmd.DrawCount <= 0 { cmd.DrawCount = 1 } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return domain.DrawResult{}, err } defer func() { _ = tx.Rollback() }() appCode := appcode.FromContext(ctx) existing, complete, err := r.collectWheelDrawsByCommand(ctx, tx, appCode, cmd.CommandID, cmd.DrawCount) if err != nil { return domain.DrawResult{}, err } if complete { return aggregateWheelDrawResults(cmd, existing), nil } if len(existing) > 0 { return domain.DrawResult{}, xerr.New(xerr.Conflict, "partial wheel draw command exists") } config, exists, err := r.getWheelRuleConfig(ctx, tx, appCode, cmd.WheelID, true) if err != nil { return domain.DrawResult{}, err } if !exists || !config.Enabled { return domain.DrawResult{}, xerr.New(xerr.Conflict, "wheel is disabled") } if cmd.CoinSpent <= 0 || cmd.UserID <= 0 || strings.TrimSpace(cmd.CommandID) == "" || strings.TrimSpace(cmd.DeviceID) == "" { return domain.DrawResult{}, xerr.New(xerr.InvalidArgument, "wheel draw command is incomplete") } expectedSpent := config.DrawPriceCoins * int64(cmd.DrawCount) if cmd.CoinSpent != expectedSpent { return domain.DrawResult{}, xerr.New(xerr.InvalidArgument, "wheel draw coin_spent does not match current price") } if cmd.PaidAtMS <= 0 { cmd.PaidAtMS = nowMS } window, err := r.getOpenWheelRTPWindow(ctx, tx, appCode, config, nowMS) if err != nil { return domain.DrawResult{}, err } pool, err := r.getOrCreateWheelPool(ctx, tx, appCode, config, nowMS) if err != nil { return domain.DrawResult{}, err } results := make([]domain.DrawResult, 0, cmd.DrawCount) var totalPoolIn, totalPoolOut int64 for i := int32(1); i <= cmd.DrawCount; i++ { unitSpent := wheelDrawUnitSpend(cmd.CoinSpent, cmd.DrawCount, i) totalPoolIn += unitSpent * config.PoolRatePPM / wheelPPMScale pool.Balance += unitSpent * config.PoolRatePPM / wheelPPMScale subCommand := wheelSubCommandID(cmd.CommandID, i, cmd.DrawCount) result, poolOut, err := r.executeSingleWheelDraw(cmd, subCommand, unitSpent, config, &window, &pool, nowMS) if err != nil { return domain.DrawResult{}, err } totalPoolOut += poolOut results = append(results, result) } if err := r.persistWheelRTPWindow(ctx, tx, appCode, config.WheelID, window, nowMS); err != nil { return domain.DrawResult{}, err } if err := r.persistWheelPoolDelta(ctx, tx, appCode, config.WheelID, totalPoolIn, totalPoolOut, nowMS); err != nil { return domain.DrawResult{}, err } if err := r.updateWheelStats(ctx, tx, appCode, config.WheelID, cmd.UserID, cmd.CoinSpent, results, nowMS); err != nil { return domain.DrawResult{}, err } aggregate := aggregateWheelDrawResults(cmd, results) if err := r.insertAggregateWheelDraw(ctx, tx, appCode, cmd, aggregate, nowMS); err != nil { return domain.DrawResult{}, err } if aggregate.RewardStatus == domain.StatusPending { if err := r.insertWheelRewardOutbox(ctx, tx, appCode, aggregate, cmd, nowMS); err != nil { return domain.DrawResult{}, err } } if err := tx.Commit(); err != nil { return domain.DrawResult{}, err } return aggregate, nil } func (r *Repository) ListWheelDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) { if r == nil || r.db == nil { return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") } whereSQL, args := wheelDrawWhereClause(appcode.FromContext(ctx), query) var total int64 if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM wheel_draw_records WHERE `+whereSQL, args...).Scan(&total); err != nil { return nil, 0, err } page, pageSize := normalizeWheelPage(query.Page, query.PageSize) args = append(args, int(pageSize), int((page-1)*pageSize)) rows, err := r.db.QueryContext(ctx, ` SELECT draw_id, command_id, wheel_id, rule_version, selected_tier_id, reward_type, reward_id, reward_count, reward_coins, rtp_value_coins, reward_status, reward_transaction_id, rtp_window_index, created_at_ms, COALESCE(CAST(prize_metadata_json AS CHAR), '{}') FROM wheel_draw_records WHERE `+whereSQL+` ORDER BY created_at_ms DESC, draw_id DESC LIMIT ? OFFSET ?`, args...) if err != nil { return nil, 0, err } defer rows.Close() items := make([]domain.DrawResult, 0, pageSize) for rows.Next() { var item domain.DrawResult if err := rows.Scan(&item.DrawID, &item.CommandID, &item.WheelID, &item.RuleVersion, &item.SelectedTierID, &item.RewardType, &item.RewardID, &item.RewardCount, &item.RewardCoins, &item.RTPValueCoins, &item.RewardStatus, &item.WalletTransactionID, &item.RTPWindowIndex, &item.CreatedAtMS, &item.MetadataJSON); err != nil { return nil, 0, err } item.DrawIDs = []string{item.DrawID} item.Rewards = wheelRewardsFromMetadata(item.MetadataJSON) items = append(items, item) } return items, total, rows.Err() } func (r *Repository) GetWheelDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) { if r == nil || r.db == nil { return domain.DrawSummary{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } if query.UserID <= 0 && strings.TrimSpace(query.Status) == "" { return r.getWheelDrawSummaryFromStats(ctx, query) } return r.getWheelDrawSummaryFromRecords(ctx, query) } func (r *Repository) getWheelDrawSummaryFromRecords(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) { whereSQL, args := wheelDrawWhereClause(appcode.FromContext(ctx), query) var summary domain.DrawSummary summary.WheelID = normalizeWheelID(query.WheelID) if err := r.db.QueryRowContext(ctx, ` SELECT COUNT(*), COUNT(DISTINCT user_id), COALESCE(SUM(coin_spent), 0), COALESCE(SUM(rtp_value_coins), 0), COALESCE(SUM(CASE WHEN reward_status = 'pending' THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN reward_status = 'granted' THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN reward_status = 'failed' THEN 1 ELSE 0 END), 0) FROM wheel_draw_records WHERE `+whereSQL, args...).Scan( &summary.TotalDraws, &summary.UniqueUsers, &summary.TotalSpentCoins, &summary.TotalRTPValueCoins, &summary.PendingDraws, &summary.GrantedDraws, &summary.FailedDraws, ); err != nil { return domain.DrawSummary{}, err } summary.ActualRTPPPM = wheelRTPPPM(summary.TotalSpentCoins, summary.TotalRTPValueCoins) return summary, nil } func (r *Repository) getWheelDrawSummaryFromStats(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) { var summary domain.DrawSummary summary.WheelID = normalizeWheelID(query.WheelID) err := r.db.QueryRowContext(ctx, ` SELECT total_draws, unique_users, total_spent_coins, total_rtp_value_coins, pending_draws, granted_draws, failed_draws FROM wheel_draw_stats WHERE app_code = ? AND wheel_id = ?`, appcode.FromContext(ctx), summary.WheelID, ).Scan( &summary.TotalDraws, &summary.UniqueUsers, &summary.TotalSpentCoins, &summary.TotalRTPValueCoins, &summary.PendingDraws, &summary.GrantedDraws, &summary.FailedDraws, ) if errors.Is(err, sql.ErrNoRows) { return summary, nil } if err != nil { return domain.DrawSummary{}, err } summary.ActualRTPPPM = wheelRTPPPM(summary.TotalSpentCoins, summary.TotalRTPValueCoins) return summary, nil } func (r *Repository) MarkWheelDrawsGranted(ctx context.Context, appCode string, drawIDs []string, transactionID string, nowMS int64) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } appCode = appcode.Normalize(appCode) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() for _, drawID := range drawIDs { drawID = strings.TrimSpace(drawID) if drawID == "" { continue } var wheelID, oldStatus string if err := tx.QueryRowContext(ctx, ` SELECT wheel_id, reward_status FROM wheel_draw_records WHERE app_code = ? AND draw_id = ? FOR UPDATE`, appCode, drawID, ).Scan(&wheelID, &oldStatus); err != nil { if errors.Is(err, sql.ErrNoRows) { continue } return err } if oldStatus != domain.StatusGranted { if _, err := tx.ExecContext(ctx, ` UPDATE wheel_draw_records SET reward_status = 'granted', reward_transaction_id = ?, reward_failure_reason = '', updated_at_ms = ? WHERE app_code = ? AND draw_id = ? AND reward_status <> 'granted'`, transactionID, nowMS, appCode, drawID, ); err != nil { return err } // 转盘统计是 draw 事务内即时累加的,后续发奖状态变化必须同步搬移计数,避免后台看到永久 pending。 if _, err := tx.ExecContext(ctx, ` UPDATE wheel_draw_stats SET pending_draws = CASE WHEN pending_draws > 0 THEN pending_draws - 1 ELSE 0 END, granted_draws = granted_draws + 1, updated_at_ms = ? WHERE app_code = ? AND wheel_id = ?`, nowMS, appCode, wheelID, ); err != nil { return err } } if _, err := tx.ExecContext(ctx, ` UPDATE activity_outbox SET status = 'delivered', locked_by = '', lock_until_ms = 0, last_error = '', updated_at_ms = ? WHERE app_code = ? AND outbox_id = ?`, nowMS, appCode, "wheel_reward_"+drawID, ); err != nil { return err } } return tx.Commit() } func (r *Repository) executeSingleWheelDraw(cmd domain.DrawCommand, commandID string, unitSpent int64, config domain.RuleConfig, window *wheelRTPWindow, pool *wheelPool, nowMS int64) (domain.DrawResult, int64, error) { candidates, limited := wheelPayableCandidates(config, pool) if len(candidates) == 0 { return domain.DrawResult{}, 0, xerr.New(xerr.Conflict, "wheel has no payable tier") } engineCandidates := make([]lotteryengine.Candidate, 0, len(candidates)) byID := make(map[string]domain.Tier, len(candidates)) for _, candidate := range candidates { engineCandidates = append(engineCandidates, lotteryengine.Candidate{ ID: candidate.Tier.TierID, Weight: candidate.Tier.WeightPPM, PrizeKind: candidate.Tier.RewardType, RTPValueCoins: candidate.Tier.RTPValueCoins, }) byID[candidate.Tier.TierID] = candidate.Tier } selected, err := lotteryengine.SelectWeighted(engineCandidates) if err != nil { return domain.DrawResult{}, 0, err } tier := byID[selected.ID] tier.RTPValueCoins = selected.RTPValueCoins pool.Balance -= tier.RTPValueCoins pool.TotalOut += tier.RTPValueCoins window.PaidDraws++ window.WagerCoins += unitSpent window.TargetPayout = window.WagerCoins * window.TargetRTPPPM / wheelPPMScale window.ActualRTPValue += tier.RTPValueCoins drawID := idgen.New("wheel_draw") status := domain.StatusGranted if wheelDrawNeedsFulfillment(tier) { status = domain.StatusPending } _ = limited return domain.DrawResult{ DrawID: drawID, CommandID: commandID, WheelID: config.WheelID, RuleVersion: config.RuleVersion, SelectedTierID: tier.TierID, RewardType: tier.RewardType, RewardID: tier.RewardID, RewardCount: tier.RewardCount, RewardCoins: tier.RewardCoins, RTPValueCoins: tier.RTPValueCoins, RewardStatus: status, RTPWindowIndex: window.WindowIndex, ActualRTPPPM: wheelRTPPPM(window.WagerCoins, window.ActualRTPValue), CreatedAtMS: nowMS, MetadataJSON: wheelMetadataJSON(tier.MetadataJSON), }, tier.RTPValueCoins, nil } func (r *Repository) getWheelRuleConfig(ctx context.Context, queryer interface { QueryRowContext(context.Context, string, ...any) *sql.Row QueryContext(context.Context, string, ...any) (*sql.Rows, error) }, appCode, wheelID string, forUpdate bool) (domain.RuleConfig, bool, error) { lockSQL := "" if forUpdate { lockSQL = " FOR UPDATE" } row := queryer.QueryRowContext(ctx, ` SELECT app_code, wheel_id, rule_version, enabled, draw_price_coins, target_rtp_ppm, pool_rate_ppm, settlement_window_draws, initial_pool_coins, pool_reserve_coins, max_single_rtp_payout, effective_from_ms, created_by_admin_id, created_at_ms FROM wheel_rule_versions WHERE app_code = ? AND wheel_id = ? AND enabled = 1 ORDER BY rule_version DESC LIMIT 1`+lockSQL, appCode, wheelID, ) var config domain.RuleConfig if err := row.Scan(&config.AppCode, &config.WheelID, &config.RuleVersion, &config.Enabled, &config.DrawPriceCoins, &config.TargetRTPPPM, &config.PoolRatePPM, &config.SettlementWindowDraws, &config.InitialPoolCoins, &config.PoolReserveCoins, &config.MaxSingleRTPPayout, &config.EffectiveFromMS, &config.CreatedByAdminID, &config.CreatedAtMS); err != nil { if errors.Is(err, sql.ErrNoRows) { return domain.RuleConfig{}, false, nil } return domain.RuleConfig{}, false, err } rows, err := queryer.QueryContext(ctx, ` SELECT tier_id, display_name, reward_type, reward_id, reward_count, reward_coins, rtp_value_coins, weight_ppm, enabled, COALESCE(CAST(metadata_json AS CHAR), '{}') FROM wheel_prize_tiers WHERE app_code = ? AND wheel_id = ? AND rule_version = ? ORDER BY tier_id`, appCode, config.WheelID, config.RuleVersion, ) if err != nil { return domain.RuleConfig{}, false, err } defer rows.Close() for rows.Next() { var tier domain.Tier if err := rows.Scan(&tier.TierID, &tier.DisplayName, &tier.RewardType, &tier.RewardID, &tier.RewardCount, &tier.RewardCoins, &tier.RTPValueCoins, &tier.WeightPPM, &tier.Enabled, &tier.MetadataJSON); err != nil { return domain.RuleConfig{}, false, err } config.Tiers = append(config.Tiers, tier) } if err := rows.Err(); err != nil { return domain.RuleConfig{}, false, err } return config, true, nil } func (r *Repository) getOpenWheelRTPWindow(ctx context.Context, tx *sql.Tx, appCode string, config domain.RuleConfig, nowMS int64) (wheelRTPWindow, error) { window, exists, err := r.getLatestWheelRTPWindow(ctx, tx, appCode, config.WheelID, true) if err != nil { return wheelRTPWindow{}, err } if !exists { return r.createWheelRTPWindow(ctx, tx, appCode, config.WheelID, 1, 0, config.SettlementWindowDraws, config.TargetRTPPPM, nowMS) } if window.Status == "open" && window.PaidDraws < window.ControlDraws { return window, nil } if window.Status == "open" { if _, err := tx.ExecContext(ctx, `UPDATE wheel_rtp_windows SET status = 'closed', updated_at_ms = ? WHERE app_code = ? AND wheel_id = ? AND window_index = ?`, nowMS, appCode, config.WheelID, window.WindowIndex); err != nil { return wheelRTPWindow{}, err } } return r.createWheelRTPWindow(ctx, tx, appCode, config.WheelID, window.WindowIndex+1, window.CarryPPM, config.SettlementWindowDraws, config.TargetRTPPPM, nowMS) } func (r *Repository) getLatestWheelRTPWindow(ctx context.Context, tx *sql.Tx, appCode, wheelID string, forUpdate bool) (wheelRTPWindow, bool, error) { lockSQL := "" if forUpdate { lockSQL = " FOR UPDATE" } row := tx.QueryRowContext(ctx, ` SELECT wheel_id, window_index, target_rtp_ppm, control_window_draws, paid_draws, wager_coins, target_payout_coins, actual_rtp_value_coins, carry_ppm, status FROM wheel_rtp_windows WHERE app_code = ? AND wheel_id = ? ORDER BY window_index DESC LIMIT 1`+lockSQL, appCode, wheelID, ) var window wheelRTPWindow if err := row.Scan(&window.WheelID, &window.WindowIndex, &window.TargetRTPPPM, &window.ControlDraws, &window.PaidDraws, &window.WagerCoins, &window.TargetPayout, &window.ActualRTPValue, &window.CarryPPM, &window.Status); err != nil { if errors.Is(err, sql.ErrNoRows) { return wheelRTPWindow{}, false, nil } return wheelRTPWindow{}, false, err } return window, true, nil } func (r *Repository) createWheelRTPWindow(ctx context.Context, tx *sql.Tx, appCode, wheelID string, index, carry, windowDraws, targetPPM, nowMS int64) (wheelRTPWindow, error) { if windowDraws <= 0 { windowDraws = 1 } window := wheelRTPWindow{WheelID: wheelID, WindowIndex: index, TargetRTPPPM: targetPPM, ControlDraws: windowDraws, CarryPPM: carry, Status: "open"} if _, err := tx.ExecContext(ctx, ` INSERT INTO wheel_rtp_windows ( app_code, wheel_id, window_index, target_rtp_ppm, control_window_draws, paid_draws, wager_coins, target_payout_coins, actual_rtp_value_coins, carry_ppm, status, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, 0, 0, 0, 0, ?, 'open', ?, ?)`, appCode, wheelID, index, targetPPM, windowDraws, carry, nowMS, nowMS, ); err != nil { return wheelRTPWindow{}, err } return window, nil } func (r *Repository) getOrCreateWheelPool(ctx context.Context, tx *sql.Tx, appCode string, config domain.RuleConfig, nowMS int64) (wheelPool, error) { row := tx.QueryRowContext(ctx, `SELECT wheel_id, balance, reserve_floor, total_in, total_out FROM wheel_pools WHERE app_code = ? AND wheel_id = ? FOR UPDATE`, appCode, config.WheelID) var pool wheelPool if err := row.Scan(&pool.WheelID, &pool.Balance, &pool.ReserveFloor, &pool.TotalIn, &pool.TotalOut); err != nil { if !errors.Is(err, sql.ErrNoRows) { return wheelPool{}, err } pool = wheelPool{WheelID: config.WheelID, Balance: config.InitialPoolCoins, ReserveFloor: config.PoolReserveCoins} if _, err := tx.ExecContext(ctx, `INSERT INTO wheel_pools (app_code, wheel_id, balance, reserve_floor, total_in, total_out, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, 0, 0, ?, ?)`, appCode, pool.WheelID, pool.Balance, pool.ReserveFloor, nowMS, nowMS); err != nil { return wheelPool{}, err } } return pool, nil } func (r *Repository) persistWheelPoolDelta(ctx context.Context, tx *sql.Tx, appCode, wheelID string, in, out, nowMS int64) error { _, err := tx.ExecContext(ctx, ` UPDATE wheel_pools SET balance = balance + ? - ?, total_in = total_in + ?, total_out = total_out + ?, updated_at_ms = ? WHERE app_code = ? AND wheel_id = ?`, in, out, in, out, nowMS, appCode, wheelID, ) return err } func (r *Repository) persistWheelRTPWindow(ctx context.Context, tx *sql.Tx, appCode, wheelID string, window wheelRTPWindow, nowMS int64) error { _, err := tx.ExecContext(ctx, ` UPDATE wheel_rtp_windows SET paid_draws = ?, wager_coins = ?, target_payout_coins = ?, actual_rtp_value_coins = ?, carry_ppm = ?, updated_at_ms = ? WHERE app_code = ? AND wheel_id = ? AND window_index = ?`, window.PaidDraws, window.WagerCoins, window.TargetPayout, window.ActualRTPValue, window.CarryPPM, nowMS, appCode, wheelID, window.WindowIndex, ) return err } func wheelPayableCandidates(config domain.RuleConfig, pool *wheelPool) ([]wheelCandidate, map[string]bool) { limited := map[string]bool{} capacity := pool.Balance - pool.ReserveFloor if capacity < 0 { capacity = 0 } if config.MaxSingleRTPPayout > 0 && config.MaxSingleRTPPayout < capacity { capacity = config.MaxSingleRTPPayout } candidates := make([]wheelCandidate, 0, len(config.Tiers)) for _, tier := range config.Tiers { if !tier.Enabled { continue } tier.RewardType = lotteryengine.NormalizePrizeKind(tier.RewardType) tier.RTPValueCoins = lotteryengine.NormalizeRTPValueCoins(tier.RewardType, tier.RTPValueCoins) if tier.RTPValueCoins > capacity { limited["pool_cap"] = true continue } candidates = append(candidates, wheelCandidate{Tier: tier}) } return candidates, limited } func (r *Repository) updateWheelStats(ctx context.Context, tx *sql.Tx, appCode, wheelID string, userID int64, totalSpent int64, results []domain.DrawResult, nowMS int64) error { var spent, rtpValue, pending, granted, failed int64 spent = totalSpent for _, result := range results { rtpValue += result.RTPValueCoins switch result.RewardStatus { case domain.StatusPending: pending++ case domain.StatusFailed: failed++ default: granted++ } } if _, err := tx.ExecContext(ctx, `INSERT IGNORE INTO wheel_draw_stat_users (app_code, wheel_id, user_id, created_at_ms) VALUES (?, ?, ?, ?)`, appCode, wheelID, userID, nowMS); err != nil { return err } var uniqueDelta int64 if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM wheel_draw_stat_users WHERE app_code = ? AND wheel_id = ?`, appCode, wheelID).Scan(&uniqueDelta); err != nil { return err } if _, err := tx.ExecContext(ctx, ` INSERT INTO wheel_draw_stats ( app_code, wheel_id, total_draws, unique_users, total_spent_coins, total_rtp_value_coins, pending_draws, granted_draws, failed_draws, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE total_draws = total_draws + VALUES(total_draws), unique_users = ?, total_spent_coins = total_spent_coins + VALUES(total_spent_coins), total_rtp_value_coins = total_rtp_value_coins + VALUES(total_rtp_value_coins), pending_draws = pending_draws + VALUES(pending_draws), granted_draws = granted_draws + VALUES(granted_draws), failed_draws = failed_draws + VALUES(failed_draws), updated_at_ms = VALUES(updated_at_ms)`, appCode, wheelID, int64(len(results)), uniqueDelta, spent, rtpValue, pending, granted, failed, nowMS, nowMS, uniqueDelta, ); err != nil { return err } return nil } func (r *Repository) insertAggregateWheelDraw(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, result domain.DrawResult, nowMS int64) error { candidateJSON, _ := json.Marshal(map[string]any{ "draw_count": len(result.Rewards), "batch": len(result.Rewards) > 1, }) rtpJSON, _ := json.Marshal(map[string]any{ "rtp_window_index": result.RTPWindowIndex, "total_rtp_value_coins": result.RTPValueCoins, }) _, err := tx.ExecContext(ctx, ` INSERT INTO wheel_draw_records ( app_code, draw_id, command_id, user_id, device_id, visible_region_id, wheel_id, coin_spent, rule_version, rtp_window_index, selected_tier_id, reward_type, reward_id, reward_count, reward_coins, rtp_value_coins, candidate_tiers_json, rtp_snapshot_json, prize_metadata_json, reward_status, paid_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, appCode, result.DrawID, cmd.CommandID, cmd.UserID, cmd.DeviceID, cmd.VisibleRegionID, result.WheelID, cmd.CoinSpent, result.RuleVersion, result.RTPWindowIndex, result.SelectedTierID, result.RewardType, result.RewardID, result.RewardCount, result.RewardCoins, result.RTPValueCoins, string(candidateJSON), string(rtpJSON), wheelMetadataJSON(result.MetadataJSON), result.RewardStatus, cmd.PaidAtMS, nowMS, nowMS, ) return err } func (r *Repository) insertWheelRewardOutbox(ctx context.Context, tx *sql.Tx, appCode string, result domain.DrawResult, cmd domain.DrawCommand, nowMS int64) error { payload, _ := json.Marshal(map[string]any{ "app_code": appCode, "draw_id": result.DrawID, "draw_ids": result.DrawIDs, "command_id": cmd.CommandID, "user_id": cmd.UserID, "wheel_id": cmd.WheelID, "selected_tier_id": result.SelectedTierID, "reward_type": result.RewardType, "reward_id": result.RewardID, "reward_count": result.RewardCount, "reward_coins": result.RewardCoins, "rewards": result.Rewards, "visible_region_id": cmd.VisibleRegionID, "created_at_ms": nowMS, }) return r.insertActivityOutbox(ctx, tx, appCode, "wheel_reward_"+result.DrawID, domain.EventTypeWheelRewardSettlement, payload, nowMS) } func wheelDrawUnitSpend(totalSpent int64, drawCount int32, drawIndex int32) int64 { if drawCount <= 1 { return totalSpent } // 多连抽在同一扣费 receipt 下推进多次 RTP 观察;余数按前序 draw 分摊,保证每个子 draw 的金额之和严格等于钱包扣费事实。 base := totalSpent / int64(drawCount) remainder := totalSpent % int64(drawCount) if int64(drawIndex) <= remainder { return base + 1 } return base } func (r *Repository) insertActivityOutbox(ctx context.Context, tx *sql.Tx, appCode string, outboxID string, eventType string, payload []byte, nowMS int64) error { // activity_outbox 现在只承载 activity-service 自己的异步副作用;wheel 使用自己的 event_type, // 不能继续调用旧 lucky gift helper,否则会把已迁出的 lucky owner 边界重新引入 activity-service。 _, err := tx.ExecContext(ctx, ` INSERT INTO activity_outbox ( app_code, outbox_id, event_type, payload, status, retry_count, next_retry_at_ms, locked_by, lock_until_ms, last_error, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, 'pending', 0, ?, '', 0, '', ?, ?)`, appCode, outboxID, eventType, payload, nowMS, nowMS, nowMS, ) return err } func (r *Repository) collectWheelDrawsByCommand(ctx context.Context, tx *sql.Tx, appCode, commandID string, drawCount int32) ([]domain.DrawResult, bool, error) { if drawCount <= 0 { drawCount = 1 } row := tx.QueryRowContext(ctx, ` SELECT draw_id, command_id, wheel_id, rule_version, selected_tier_id, reward_type, reward_id, reward_count, reward_coins, rtp_value_coins, reward_status, reward_transaction_id, rtp_window_index, created_at_ms, COALESCE(CAST(prize_metadata_json AS CHAR), '{}') FROM wheel_draw_records WHERE app_code = ? AND command_id = ?`, appCode, strings.TrimSpace(commandID), ) var result domain.DrawResult if err := row.Scan(&result.DrawID, &result.CommandID, &result.WheelID, &result.RuleVersion, &result.SelectedTierID, &result.RewardType, &result.RewardID, &result.RewardCount, &result.RewardCoins, &result.RTPValueCoins, &result.RewardStatus, &result.WalletTransactionID, &result.RTPWindowIndex, &result.CreatedAtMS, &result.MetadataJSON); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, false, nil } return nil, false, err } result.DrawIDs = []string{result.DrawID} result.Rewards = wheelRewardsFromMetadata(result.MetadataJSON) return []domain.DrawResult{result}, true, nil } func aggregateWheelDrawResults(cmd domain.DrawCommand, results []domain.DrawResult) domain.DrawResult { if len(results) == 0 { return domain.DrawResult{} } aggregate := results[0] aggregate.CommandID = cmd.CommandID aggregate.DrawIDs = []string{aggregate.DrawID} aggregate.Rewards = make([]domain.DrawReward, 0, len(results)) if len(results) == 1 { if len(results[0].Rewards) > 0 { aggregate.Rewards = append([]domain.DrawReward(nil), results[0].Rewards...) return aggregate } aggregate.Rewards = []domain.DrawReward{wheelDrawRewardFromResult(aggregate)} aggregate.MetadataJSON = wheelAggregateMetadataJSON(aggregate.Rewards) return aggregate } aggregate.SelectedTierID = "batch" aggregate.RewardType = "batch" aggregate.RewardID = "" aggregate.RewardCount = 0 aggregate.RewardCoins = 0 aggregate.RTPValueCoins = 0 aggregate.RewardStatus = domain.StatusGranted aggregate.MetadataJSON = "" for _, result := range results { aggregate.Rewards = append(aggregate.Rewards, wheelDrawRewardFromResult(result)) aggregate.RewardCount += result.RewardCount aggregate.RewardCoins += result.RewardCoins aggregate.RTPValueCoins += result.RTPValueCoins if result.RewardStatus == domain.StatusPending { aggregate.RewardStatus = domain.StatusPending } else if aggregate.RewardStatus != domain.StatusPending && result.RewardStatus == domain.StatusFailed { aggregate.RewardStatus = domain.StatusFailed } } aggregate.MetadataJSON = wheelAggregateMetadataJSON(aggregate.Rewards) return aggregate } func wheelDrawRewardFromResult(result domain.DrawResult) domain.DrawReward { return domain.DrawReward{ SelectedTierID: result.SelectedTierID, RewardType: result.RewardType, RewardID: result.RewardID, RewardCount: result.RewardCount, RewardCoins: result.RewardCoins, RTPValueCoins: result.RTPValueCoins, RewardStatus: result.RewardStatus, MetadataJSON: result.MetadataJSON, } } func wheelAggregateMetadataJSON(rewards []domain.DrawReward) string { items := make([]map[string]any, 0, len(rewards)) for _, reward := range rewards { items = append(items, map[string]any{ "selected_tier_id": reward.SelectedTierID, "reward_type": reward.RewardType, "reward_id": reward.RewardID, "reward_count": reward.RewardCount, "reward_coins": reward.RewardCoins, "rtp_value_coins": reward.RTPValueCoins, "reward_status": reward.RewardStatus, "metadata_json": wheelMetadataJSON(reward.MetadataJSON), }) } payload, _ := json.Marshal(map[string]any{"rewards": items}) return string(payload) } func wheelRewardsFromMetadata(metadataJSON string) []domain.DrawReward { var payload struct { Rewards []struct { SelectedTierID string `json:"selected_tier_id"` RewardType string `json:"reward_type"` RewardID string `json:"reward_id"` RewardCount int64 `json:"reward_count"` RewardCoins int64 `json:"reward_coins"` RTPValueCoins int64 `json:"rtp_value_coins"` RewardStatus string `json:"reward_status"` MetadataJSON string `json:"metadata_json"` } `json:"rewards"` } if err := json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &payload); err != nil || len(payload.Rewards) == 0 { return nil } rewards := make([]domain.DrawReward, 0, len(payload.Rewards)) for _, item := range payload.Rewards { rewards = append(rewards, domain.DrawReward{ SelectedTierID: item.SelectedTierID, RewardType: item.RewardType, RewardID: item.RewardID, RewardCount: item.RewardCount, RewardCoins: item.RewardCoins, RTPValueCoins: item.RTPValueCoins, RewardStatus: item.RewardStatus, MetadataJSON: wheelMetadataJSON(item.MetadataJSON), }) } return rewards } func wheelSubCommandID(commandID string, drawIndex int32, drawCount int32) string { if drawCount <= 1 { return strings.TrimSpace(commandID) } suffix := fmt.Sprintf("#%06d", drawIndex) const maxCommandIDLength = 128 commandID = strings.TrimSpace(commandID) if len(commandID)+len(suffix) <= maxCommandIDLength { return commandID + suffix } sum := sha1.Sum([]byte(commandID)) hash := hex.EncodeToString(sum[:])[:10] hashSuffix := "#" + hash + suffix headLen := maxCommandIDLength - len(hashSuffix) if headLen < 0 { headLen = 0 } if len(commandID) > headLen { commandID = commandID[:headLen] } return commandID + hashSuffix } func normalizeWheelID(wheelID string) string { wheelID = strings.TrimSpace(wheelID) if wheelID == "" { return "default" } return wheelID } func normalizeWheelTiers(tiers []domain.Tier) []domain.Tier { out := make([]domain.Tier, 0, len(tiers)) for _, tier := range tiers { tier.TierID = strings.TrimSpace(tier.TierID) tier.RewardType = lotteryengine.NormalizePrizeKind(tier.RewardType) tier.RTPValueCoins = lotteryengine.NormalizeRTPValueCoins(tier.RewardType, tier.RTPValueCoins) tier.MetadataJSON = wheelMetadataJSON(tier.MetadataJSON) if tier.RewardCount <= 0 { tier.RewardCount = 1 } out = append(out, tier) } return out } func wheelDrawNeedsFulfillment(tier domain.Tier) bool { return tier.RewardCoins > 0 || strings.TrimSpace(tier.RewardID) != "" } func wheelMetadataJSON(raw string) string { raw = strings.TrimSpace(raw) if raw == "" || !json.Valid([]byte(raw)) { return "{}" } return raw } func wheelRTPPPM(wager, payout int64) int64 { if wager <= 0 { return 0 } return payout * wheelPPMScale / wager } func wheelDrawWhereClause(appCode string, query domain.DrawQuery) (string, []any) { where := []string{"app_code = ?"} args := []any{appCode} if query.WheelID != "" { where = append(where, "wheel_id = ?") args = append(args, normalizeWheelID(query.WheelID)) } if query.UserID > 0 { where = append(where, "user_id = ?") args = append(args, query.UserID) } if query.Status != "" { where = append(where, "reward_status = ?") args = append(args, strings.TrimSpace(query.Status)) } return strings.Join(where, " AND "), args } func normalizeWheelPage(page int32, pageSize int32) (int32, int32) { if page <= 0 { page = 1 } if pageSize <= 0 { pageSize = 20 } if pageSize > 100 { pageSize = 100 } return page, pageSize }