Merge remote-tracking branch 'origin/main'

# Conflicts:
#	services/lucky-gift-service/internal/storage/mysql/external_repository.go
#	services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository.go
This commit is contained in:
zhx 2026-07-17 01:38:56 +08:00
commit 3ca36d2c8d
5 changed files with 68 additions and 31 deletions

View File

@ -106,7 +106,9 @@ CREATE TABLE IF NOT EXISTS lucky_gift_rule_versions (
user_24h_ordinary_win_factor_ppm BIGINT NOT NULL DEFAULT 1000000 COMMENT '触发后普通非零奖档保留权重ppm1000000表示不降权',
jackpot_multiplier_ppms JSON NOT NULL DEFAULT (JSON_ARRAY()) COMMENT '动态大奖倍率列表1x=1000000',
jackpot_global_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖全局 RTP 上限ppm',
jackpot_user_day_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '兼容旧实例的用户日 RTP 上限影子列;新代码与轮次列双写',
jackpot_user_round_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户已结算轮次 RTP 严格上限ppm',
jackpot_user_72h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '兼容旧实例的用户 72 小时 RTP 上限影子列;新代码与 48 小时列双写',
jackpot_user_48h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户滚动 48 小时 RTP 严格上限ppm',
jackpot_spend_threshold_coins BIGINT NOT NULL DEFAULT 0 COMMENT '规则版本动态配置的用户 UTC 日累计消费大奖门槛金币',
max_jackpot_hits_per_user_day BIGINT NOT NULL DEFAULT 0 COMMENT '单用户 UTC 日动态大奖命中上限',

View File

@ -2,43 +2,48 @@ SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_lucky_gift;
-- 生产 main 的 006 已用于 AB 实验;本迁移编号调整为 010 以保证发布目录内版本唯一。
-- 性能边界:
-- 1. 规则版本表只保存低频发布的不可变配置,两个 CHANGE 仅改列名/注释且保持 BIGINT/NOT NULL/DEFAULT 不变;
-- 显式要求 ALGORITHM=INSTANT若目标 MySQL 不支持即时 DDL 就直接失败,禁止静默退化为整表 COPY。
-- 只有旧、新列异常并存时才扫描这张小配置表把旧非零值补到新列,再即时删除旧列;不会扫描任何抽奖事实。
-- 1. 规则版本表只保存低频发布的不可变配置。生产使用双实例滚动,旧实例仍硬编码 day/72h 列,
-- 因此本次只能执行 expand补齐 old/new 四列并双向回填,绝不 CHANGE/DROP 旧列。
-- 新版本仓储会双写两套列;确认所有旧实例与回滚窗口都结束后,才能用独立 contract migration 删除影子列。
-- 所有加列显式要求 ALGORITHM=INSTANT若目标 MySQL 不支持则直接失败,禁止退化为整表 COPY。
-- 2. lucky_rtp_windows 是热状态表。三个常量默认列分别使用即时 DDL不回写历史行每条 ALTER 仍需短暂 MDL
-- 上线前应确认没有长事务占用该表,否则迁移会等待元数据锁,但不会扫描 lucky_draw_records 等明细大表。
-- 3. lucky_user_rtp_windows 是全新空表,主键与待资格索引随建表一次完成;迁移不做历史资格回填,避免全表聚合。
-- 旧库保留 day/72h 列,新库可能已由 initdb 创建 round/48h 列;逐列探测保证两种环境和重复执行都安全。
-- CHANGE 保持列类型、空值属性与默认值完全不变,只把唯一业务口径改成“已结算轮次”和“滚动 48 小时”。
-- 旧库只有 day/72h新测试库可能只有 round/48h逐列补齐四列让新旧二进制在发布和回滚窗口内都能工作。
SET @has_old_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_day_rtp_max_ppm');
SET @has_new_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_round_rtp_max_ppm');
SET @ddl := CASE
WHEN @has_old_column > 0 AND @has_new_column = 0 THEN 'ALTER TABLE lucky_gift_rule_versions CHANGE COLUMN jackpot_user_day_rtp_max_ppm jackpot_user_round_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''动态大奖用户已结算轮次 RTP 严格上限ppm'', ALGORITHM=INSTANT'
WHEN @has_old_column = 0 AND @has_new_column = 0 THEN 'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_user_round_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''动态大奖用户已结算轮次 RTP 严格上限ppm'', ALGORITHM=INSTANT'
ELSE 'SELECT 1'
END;
SET @ddl := IF(@has_old_column = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_user_day_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''兼容旧实例的用户日 RTP 上限影子列;新代码与轮次列双写'' AFTER jackpot_global_rtp_max_ppm, ALGORITHM=INSTANT',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @has_new_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_round_rtp_max_ppm');
SET @ddl := IF(@has_new_column = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_user_round_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''动态大奖用户已结算轮次 RTP 严格上限ppm'' AFTER jackpot_user_day_rtp_max_ppm, ALGORITHM=INSTANT',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 历史 004 可能在全量重放时再次补出旧列。若旧、新列同时存在,以已发布的新列为准,仅在新值为 0 时承接旧非零值,
-- 随后删除旧列,保证仓储永远只面对一个口径;规则版本表数据量是发布次数级,不是送礼流水级。
SET @has_old_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_day_rtp_max_ppm');
SET @has_new_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_round_rtp_max_ppm');
SET @ddl := IF(@has_old_column > 0 AND @has_new_column > 0,
'UPDATE lucky_gift_rule_versions SET jackpot_user_round_rtp_max_ppm = jackpot_user_day_rtp_max_ppm WHERE jackpot_user_round_rtp_max_ppm = 0 AND jackpot_user_day_rtp_max_ppm <> 0', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF(@has_old_column > 0 AND @has_new_column > 0,
'ALTER TABLE lucky_gift_rule_versions DROP COLUMN jackpot_user_day_rtp_max_ppm, ALGORITHM=INSTANT', 'SELECT 1');
'UPDATE lucky_gift_rule_versions SET jackpot_user_day_rtp_max_ppm = jackpot_user_round_rtp_max_ppm WHERE jackpot_user_day_rtp_max_ppm = 0 AND jackpot_user_round_rtp_max_ppm <> 0', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @has_old_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_72h_rtp_max_ppm');
SET @ddl := IF(@has_old_column = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_user_72h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''兼容旧实例的用户 72 小时 RTP 上限影子列;新代码与 48 小时列双写'' AFTER jackpot_user_round_rtp_max_ppm, ALGORITHM=INSTANT',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @has_new_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_48h_rtp_max_ppm');
SET @ddl := CASE
WHEN @has_old_column > 0 AND @has_new_column = 0 THEN 'ALTER TABLE lucky_gift_rule_versions CHANGE COLUMN jackpot_user_72h_rtp_max_ppm jackpot_user_48h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''动态大奖用户滚动 48 小时 RTP 严格上限ppm'', ALGORITHM=INSTANT'
WHEN @has_old_column = 0 AND @has_new_column = 0 THEN 'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_user_48h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''动态大奖用户滚动 48 小时 RTP 严格上限ppm'', ALGORITHM=INSTANT'
ELSE 'SELECT 1'
END;
SET @ddl := IF(@has_new_column = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_user_48h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''动态大奖用户滚动 48 小时 RTP 严格上限ppm'' AFTER jackpot_user_72h_rtp_max_ppm, ALGORITHM=INSTANT',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @has_old_column := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_72h_rtp_max_ppm');
@ -47,7 +52,7 @@ SET @ddl := IF(@has_old_column > 0 AND @has_new_column > 0,
'UPDATE lucky_gift_rule_versions SET jackpot_user_48h_rtp_max_ppm = jackpot_user_72h_rtp_max_ppm WHERE jackpot_user_48h_rtp_max_ppm = 0 AND jackpot_user_72h_rtp_max_ppm <> 0', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF(@has_old_column > 0 AND @has_new_column > 0,
'ALTER TABLE lucky_gift_rule_versions DROP COLUMN jackpot_user_72h_rtp_max_ppm, ALGORITHM=INSTANT', 'SELECT 1');
'UPDATE lucky_gift_rule_versions SET jackpot_user_72h_rtp_max_ppm = jackpot_user_48h_rtp_max_ppm WHERE jackpot_user_72h_rtp_max_ppm = 0 AND jackpot_user_48h_rtp_max_ppm <> 0', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 历史窗口无法可靠推导首笔、末笔和闭窗业务时间,因此默认 0 明确表示 unknown新运行逻辑只对新窗口写真实 paid_at。

View File

@ -19,17 +19,31 @@ const luckyExperimentSelectColumns = `app_code, experiment_id, pool_id, name,
// resolveLuckyGiftRuleConfigForUser 是实验期的配置选择入口:
// 无未完成实验时保持"最新版本生效"的原始语义;有实验时按白名单和哈希分桶钉住组内版本。
// 实验行使用一致性快照读(不加锁):给它加锁会与创建/结束实验的"规则行→实验行"锁序构成 AB-BA。
// 已知取舍:创建实验提交瞬间,快照早于提交而规则行锁读晚于提交的在途抽奖,会按"最新版本=treatment"
// 开奖一次——毫秒级窗口,两组配置都是已校验可运行的版本,无资金风险,仅统计上可忽略的归组误差
// 事务开奖先锁最新规则行,再一致性读取实验:这与创建/结束实验保持"规则行→实验行"顺序,
// 同时确保 REPEATABLE READ 快照建立在前一笔同奖池结算提交之后,滚动 RTP 不会漏掉刚完成的抽奖。
// Check 等只读入口不加锁,仍使用普通一致性快照,避免把低频配置检查串行化
// 实验状态(放量/白名单/暂停)变更会让分组在 Check 与 Draw 之间漂移;创建入口强制两组同策略,漂移无害。
// 钉住的版本行缺失表示不可变事实被外部破坏,必须 fail-close不能静默回落最新版本改变实验语义。
func (r *Repository) resolveLuckyGiftRuleConfigForUser(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, userKey string, forUpdate bool) (domain.RuleConfig, bool, error) {
var lockedLatest domain.RuleConfig
var lockedLatestExists bool
var err error
if forUpdate {
// 最新规则行是同奖池开奖和实验管理共享的串行化入口。先锁它再读取实验,
// 既固定事务快照边界,也避免 control/treatment 两个版本按用户分组产生反向锁序。
lockedLatest, lockedLatestExists, err = r.getLuckyGiftRuleConfig(ctx, q, appCode, poolID, true)
if err != nil {
return domain.RuleConfig{}, false, err
}
}
experiment, exists, err := r.getActiveLuckyGiftExperiment(ctx, q, appCode, poolID, false)
if err != nil {
return domain.RuleConfig{}, false, err
}
if !exists {
if forUpdate {
return lockedLatest, lockedLatestExists, nil
}
return r.getLuckyGiftRuleConfig(ctx, q, appCode, poolID, forUpdate)
}
override := ""

View File

@ -79,14 +79,18 @@ func (r *Repository) publishLuckyGiftRuleConfigTx(ctx context.Context, tx *sql.T
if config.JackpotMultiplierPPMs == nil {
config.JackpotMultiplierPPMs = []int64{}
}
// 旧的进程内调用方可能仍填写 day/72h 别名;仓储只持久化轮次/48h 两个唯一口径
// 兼容赋值必须在生成不可变版本前完成,避免同一规则出现两套可漂移的数据库字段
// 旧的进程内调用方可能仍填写 day/72h 别名;业务口径只认轮次/48h
// 兼容赋值必须在生成不可变版本前完成,随后再把同一值双写到旧影子列供滚动升级和回滚读取
if config.JackpotUserRoundRTPMaxPPM == 0 {
config.JackpotUserRoundRTPMaxPPM = config.JackpotUserDayRTPMaxPPM
}
if config.JackpotUser48hRTPMaxPPM == 0 {
config.JackpotUser48hRTPMaxPPM = config.JackpotUser72hRTPMaxPPM
}
// 生产双实例需要允许新旧二进制短时共存和安全回滚。兼容窗口内新代码同时写入
// day/round 与 72h/48h 两组列;旧代码只读取影子列,新代码只把新语义暴露到领域层。
config.JackpotUserDayRTPMaxPPM = config.JackpotUserRoundRTPMaxPPM
config.JackpotUser72hRTPMaxPPM = config.JackpotUser48hRTPMaxPPM
jackpotMultiplierPPMsJSON, err := json.Marshal(config.JackpotMultiplierPPMs)
if err != nil {
return domain.RuleConfig{}, fmt.Errorf("marshal lucky gift jackpot multipliers: %w", err)
@ -125,11 +129,13 @@ func (r *Repository) publishLuckyGiftRuleConfigTx(ctx context.Context, tx *sql.T
v2_high_water_boost_threshold_coins, v2_high_water_boost_factor_ppm, v2_high_water_boost_recover_coins,
recharge_boost_window_ms, recharge_boost_factor_ppm,
user_24h_rtp_threshold_ppm, user_24h_ordinary_win_factor_ppm, jackpot_multiplier_ppms,
jackpot_global_rtp_max_ppm, jackpot_user_round_rtp_max_ppm, jackpot_user_48h_rtp_max_ppm,
jackpot_global_rtp_max_ppm,
jackpot_user_day_rtp_max_ppm, jackpot_user_round_rtp_max_ppm,
jackpot_user_72h_rtp_max_ppm, jackpot_user_48h_rtp_max_ppm,
jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day,
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
config.AppCode, config.PoolID, config.RuleVersion, config.Enabled, config.StrategyVersion, config.TargetRTPPPM, config.PoolRatePPM,
config.ProfitRatePPM, config.AnchorRatePPM,
config.SettlementWindowWager, config.ControlBandPPM, config.GiftPriceReference,
@ -141,7 +147,9 @@ func (r *Repository) publishLuckyGiftRuleConfigTx(ctx context.Context, tx *sql.T
config.V2HighWaterBoostThresholdCoins, config.V2HighWaterBoostFactorPPM, config.V2HighWaterBoostRecoverCoins,
config.RechargeBoostWindowMS, config.RechargeBoostFactorPPM,
config.User24HourRTPThresholdPPM, config.User24HourOrdinaryWinFactorPPM, jackpotMultiplierPPMsJSON,
config.JackpotGlobalRTPMaxPPM, config.JackpotUserRoundRTPMaxPPM, config.JackpotUser48hRTPMaxPPM,
config.JackpotGlobalRTPMaxPPM,
config.JackpotUserDayRTPMaxPPM, config.JackpotUserRoundRTPMaxPPM,
config.JackpotUser72hRTPMaxPPM, config.JackpotUser48hRTPMaxPPM,
config.JackpotSpendThresholdCoins, config.MaxJackpotHitsPerUserDay,
config.MaxSinglePayout, config.UserHourlyPayoutCap, config.UserDailyPayoutCap,
config.DeviceDailyPayoutCap, config.RoomHourlyPayoutCap, config.AnchorDailyPayoutCap,
@ -205,7 +213,11 @@ func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode strin
v.v2_high_water_boost_threshold_coins, v.v2_high_water_boost_factor_ppm, v.v2_high_water_boost_recover_coins,
v.recharge_boost_window_ms, v.recharge_boost_factor_ppm,
v.user_24h_rtp_threshold_ppm, v.user_24h_ordinary_win_factor_ppm, v.jackpot_multiplier_ppms,
v.jackpot_global_rtp_max_ppm, v.jackpot_user_round_rtp_max_ppm, v.jackpot_user_48h_rtp_max_ppm,
v.jackpot_global_rtp_max_ppm,
CASE WHEN v.jackpot_user_round_rtp_max_ppm = 0 AND v.jackpot_user_day_rtp_max_ppm <> 0
THEN v.jackpot_user_day_rtp_max_ppm ELSE v.jackpot_user_round_rtp_max_ppm END,
CASE WHEN v.jackpot_user_48h_rtp_max_ppm = 0 AND v.jackpot_user_72h_rtp_max_ppm <> 0
THEN v.jackpot_user_72h_rtp_max_ppm ELSE v.jackpot_user_48h_rtp_max_ppm END,
v.jackpot_spend_threshold_coins, v.max_jackpot_hits_per_user_day,
v.max_single_payout, v.user_hourly_payout_cap, v.user_daily_payout_cap,
v.device_daily_payout_cap, v.room_hourly_payout_cap, v.anchor_daily_payout_cap
@ -251,7 +263,11 @@ const luckyRuleConfigSelectSQL = `
v2_high_water_boost_threshold_coins, v2_high_water_boost_factor_ppm, v2_high_water_boost_recover_coins,
recharge_boost_window_ms, recharge_boost_factor_ppm,
user_24h_rtp_threshold_ppm, user_24h_ordinary_win_factor_ppm, jackpot_multiplier_ppms,
jackpot_global_rtp_max_ppm, jackpot_user_round_rtp_max_ppm, jackpot_user_48h_rtp_max_ppm,
jackpot_global_rtp_max_ppm,
CASE WHEN jackpot_user_round_rtp_max_ppm = 0 AND jackpot_user_day_rtp_max_ppm <> 0
THEN jackpot_user_day_rtp_max_ppm ELSE jackpot_user_round_rtp_max_ppm END,
CASE WHEN jackpot_user_48h_rtp_max_ppm = 0 AND jackpot_user_72h_rtp_max_ppm <> 0
THEN jackpot_user_72h_rtp_max_ppm ELSE jackpot_user_48h_rtp_max_ppm END,
jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day,
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap
@ -415,7 +431,7 @@ func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) {
if config.JackpotMultiplierPPMs == nil {
config.JackpotMultiplierPPMs = []int64{}
}
// 数据库只有轮次/48h 两个真实字段;回填旧别名仅用于同进程源码兼容,运行策略仍由 marker 选择新语义
// 数据库兼容期保留旧影子列,但查询已收敛到轮次/48h 两个业务口径;旧别名仅供同进程源码兼容
config.JackpotUserDayRTPMaxPPM = config.JackpotUserRoundRTPMaxPPM
config.JackpotUser72hRTPMaxPPM = config.JackpotUser48hRTPMaxPPM
config.SettledRoundRTPEligibility = true