2026-06-18 17:13:56 +08:00

818 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 := luckyDrawUnitSpend(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(ctx, tx, appCode, cmd, subCommand, unitSpent, config, &window, &pool, nowMS)
if err != nil {
return domain.DrawResult{}, err
}
totalPoolOut += poolOut
results = append(results, result)
}
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
}
if err := tx.Commit(); err != nil {
return domain.DrawResult{}, err
}
return aggregateWheelDrawResults(cmd, results), 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}
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(ctx context.Context, tx *sql.Tx, appCode string, 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
if _, 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, config.WheelID, window.WindowIndex,
); err != nil {
return domain.DrawResult{}, 0, err
}
drawID := idgen.New("wheel_draw")
status := domain.StatusGranted
if wheelDrawNeedsFulfillment(tier) {
status = domain.StatusPending
}
candidateJSON, _ := json.Marshal(map[string]any{"selected": tier.TierID, "limited": limited})
rtpJSON, _ := json.Marshal(map[string]any{"window_index": window.WindowIndex, "wager_coins": window.WagerCoins, "actual_rtp_value_coins": window.ActualRTPValue})
if _, 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, drawID, commandID, cmd.UserID, cmd.DeviceID, cmd.VisibleRegionID, config.WheelID, unitSpent,
config.RuleVersion, window.WindowIndex, tier.TierID, tier.RewardType, tier.RewardID, tier.RewardCount,
tier.RewardCoins, tier.RTPValueCoins, string(candidateJSON), string(rtpJSON), wheelMetadataJSON(tier.MetadataJSON),
status, cmd.PaidAtMS, nowMS, nowMS,
); err != nil {
return domain.DrawResult{}, 0, err
}
if status == domain.StatusPending {
if err := r.insertWheelRewardOutbox(ctx, tx, appCode, drawID, commandID, cmd, tier, nowMS); err != nil {
return domain.DrawResult{}, 0, err
}
}
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 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) insertWheelRewardOutbox(ctx context.Context, tx *sql.Tx, appCode, drawID, commandID string, cmd domain.DrawCommand, tier domain.Tier, nowMS int64) error {
payload, _ := json.Marshal(map[string]any{
"app_code": appCode,
"draw_id": drawID,
"command_id": commandID,
"user_id": cmd.UserID,
"wheel_id": cmd.WheelID,
"selected_tier_id": tier.TierID,
"reward_type": tier.RewardType,
"reward_id": tier.RewardID,
"reward_count": tier.RewardCount,
"reward_coins": tier.RewardCoins,
"visible_region_id": cmd.VisibleRegionID,
"created_at_ms": nowMS,
})
return r.insertLuckyDrawOutbox(ctx, tx, appCode, "wheel_reward_"+drawID, domain.EventTypeWheelRewardSettlement, payload, nowMS)
}
func (r *Repository) collectWheelDrawsByCommand(ctx context.Context, tx *sql.Tx, appCode, commandID string, drawCount int32) ([]domain.DrawResult, bool, error) {
if drawCount <= 0 {
drawCount = 1
}
results := make([]domain.DrawResult, 0, drawCount)
for i := int32(1); i <= drawCount; i++ {
sub := wheelSubCommandID(commandID, i, drawCount)
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, sub,
)
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) {
continue
}
return nil, false, err
}
results = append(results, result)
}
return results, int32(len(results)) == drawCount, 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 = make([]string, 0, len(results))
if len(results) == 1 {
aggregate.DrawIDs = []string{aggregate.DrawID}
return aggregate
}
aggregate.SelectedTierID = "batch"
aggregate.RewardType = "batch"
aggregate.RewardID = ""
aggregate.RewardCount = 0
aggregate.RewardCoins = 0
aggregate.RTPValueCoins = 0
aggregate.RewardStatus = domain.StatusGranted
for _, result := range results {
aggregate.DrawIDs = append(aggregate.DrawIDs, result.DrawID)
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
}
}
return aggregate
}
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
}