# 幸运礼物 AB 实验（灰度放量）

> 2026-07 引入。允许对同一 app + 奖池发布一个"实验组"配置版本，只对按用户哈希圈定的部分用户生效，
> 其余用户继续使用原版本；支持白名单验证、放量调整、暂停与选择获胜方收口。

## 一、动机与总体设计

幸运礼物配置是不可变版本流（`lucky_gift_rule_versions`，PK = app_code + pool_id + rule_version）：
发布 = 追加 `max(rule_version)+1` 的新行，运行时永远取最新版本。因此**发布即全量**，没有灰度空间。

AB 实验在其上加一层"版本钉住"语义，不改变版本流本身：

- 实验（`lucky_gift_experiments`）为一个奖池钉住两个已存在的不可变版本：
  `control_rule_version`（对照组 = 创建实验时的最新启用版本）与 `treatment_rule_version`（实验组 = 随实验**同事务原子发布**的新版本）。
- 有未完成实验时，抽奖/预检查按用户解析组并加载对应版本；无实验时保持"最新版本生效"原语义。
- 同一奖池同时最多一个未完成实验（`active` 生成列 + 唯一键兜底）。

### 为什么"创建实验 = 原子发布 + 登记"

若先正常发布 v10 再建实验，发布提交到实验生效之间存在窗口：此时 v10 已是最新版本，**全量用户瞬间切到 v10**。
因此 `CreateLuckyGiftExperiment` 在一个 MySQL 事务里完成 treatment 发布与实验登记（`lucky_gift_experiment_repository.go`）。
同理，实验进行中常规发布被拒绝（409）：实验期两组各自钉住版本，新发布的"最新版本"不会生效，只会造成困惑。

### 分流算法

`FNV-1a 64(salt ":" userKey) % 1,000,000 < treatment_traffic_ppm → treatment`（`internal/domain/luckygift/experiment.go`）。

- userKey：内部用户为十进制 user_id；外部 App 用户为 `ext:` + external_user_id。两条抽奖链路共用同一分流。
  白名单 user_key 列使用 utf8mb4_bin（字节精确），与 FNV 分桶口径一致。
- **确定性**：实验状态不变时，同一用户在 Check 与 Draw 解析到同一组（room-service 送礼 saga 在 CheckLuckyGift
  固化 strategy_version、恢复阶段不重新 Check）。放量/白名单/暂停操作会让分组在两次解析间漂移。
- **允许跨策略对照**：control 与 treatment 可以分别使用 fixed_v2、dynamic_v3，用于真实比较两代策略。旧客户端
  缺少可信 device_id 时，dynamic_v3 只跳过设备日返奖上限，用户/房间/主播等其他风控照常执行；paid_at_ms
  始终来自扣费后的 wallet 回执，所以分组在 Check 与 Draw 之间漂移也不会造成已扣费命令无法开奖。
- **单调放量**：`bucket < traffic` 的比较方式保证调大流量时已在实验组的用户全部保留，只有新用户迁入；
  调小流量会把部分用户迁回对照组（用户状态跨版本共享，见下）。
- 白名单（`lucky_gift_experiment_overrides`）优先于哈希，可强制任一组，用于放量前的真机验证（流量 0% + 白名单）；
  单实验累计上限 500。
- `paused` 是紧急停闸：全部流量（**含白名单**）回落对照组；恢复 running 后原分组不变。

### 与既有机制的关系（为什么这套结构对实验天然安全）

| 机制 | 维度 | 实验语义 |
|---|---|---|
| RTP 窗口 `lucky_rtp_windows` | `pool:v{rule_version}` | 天然按组隔离，两组 RTP 观察互不污染 |
| 资金池 `lucky_pools` | fixed_v2: `pool`+poolID；dynamic_v3: `pool_dynamic_v3`+poolID | 同策略组共享对应资金账本；跨策略实验分别使用 V2 与 V3 独立账本，按组核算走抽奖事实 |
| 用户状态 `lucky_user_states`、日/72h RTP、六维风控 | 按用户/设备/房间，不含版本 | 跨组共享；用户粘在一组内，状态连续，不因实验重置保底/风控额度 |
| 抽奖事实 `lucky_draw_records` | 每抽落 `rule_version` | 分组统计免费：按 rule_version 聚合即得两组口径 |

### 结束实验（收口）

`EndLuckyGiftExperiment(winner_arm)` 在一个事务里：把获胜组钉住的配置**快照重发布**为新的最新版本
（`final_rule_version`），并把实验标记 completed。结束瞬间"最新版本生效"的默认语义恰好收敛到获胜配置，无中间态：

- treatment 胜 → 全量放开新配置（等效全量上线）；
- control 胜 → 回滚，等效撤销本次变更（treatment 版本留在版本流里成为历史）。

注意：若 treatment 是 dynamic_v3 而 control 胜出，实验期物化的 dynamic 账本及其人工注资仍在，需要用现有"扣减水位"人工调账收回。

## 二、数据模型

`services/lucky-gift-service/deploy/mysql/migrations/006_ab_experiments.sql`（initdb 001 已同步）：

- `lucky_gift_experiments`：PK (app_code, experiment_id)；`active TINYINT AS (IF(status IN ('running','paused'),1,NULL)) STORED`，
  `UNIQUE (app_code, pool_id, active)` ⇒ 每池至多一个未完成实验，completed 历史不限量。
- `lucky_gift_experiment_overrides`：PK (app_code, experiment_id, user_key)，arm ∈ {control, treatment}。白名单上限 500（service 校验）。

## 三、运行时接入点（lucky-gift-service）

`resolveLuckyGiftRuleConfigForUser`（`lucky_gift_experiment_repository.go`）替换了四处配置加载：

1. `CheckLuckyGift`（送礼预检查，非锁读）
2. `executeSingleLuckyGiftDraw`（单抽，事务内 FOR UPDATE）
3. `executeOptimizedLuckyGiftDrawBatch`（批量抽，事务内 FOR UPDATE）
4. `ExecuteExternalGiftDraw`（外部 App，ext: 前缀 userKey）

实验行用一致性快照读（不加锁），钉住的版本行按原语义加锁；两组锁不同版本行但仍在共享资金池行上串行，
锁序（规则行 → 实验行）在常规发布、创建实验、结束实验三个入口保持一致，无交叉死锁。
钉住版本行缺失 = 不可变事实被外部破坏，fail-close 报错，不静默回落最新版本。

已知取舍（均无资金风险）：
- 创建实验提交瞬间，个别在途抽奖事务可能按"最新版本=treatment"开奖一次（毫秒级窗口，两组配置都已校验可运行）。
- 不同奖池并发创建实验可能在唯一索引间隙锁上触发一次 InnoDB 死锁回滚——管理端低频操作，报错重试即可。
- 结束实验以获胜方快照重发布时，dynamic_v3 遗留的 initial_pool_coins 会被清零（与运行时读取口径一致）。

## 四、管理 API

gRPC `luckygift.v1.AdminLuckyGiftService` 新增：CreateLuckyGiftExperiment / ListLuckyGiftExperiments（with_stats 返回分组聚合）/
UpdateLuckyGiftExperiment（放量、暂停恢复）/ SetLuckyGiftExperimentOverrides / EndLuckyGiftExperiment。

admin HTTP（前缀 /api/v1/admin/ops-center，权限：查看 `lucky-gift:view`、管理 `lucky-gift:experiment`）：

| Method | Path | 说明 |
|---|---|---|
| GET | /lucky-gifts/experiments?app_code&pool_id&status&with_stats | 实验列表（含分组统计） |
| POST | /lucky-gifts/experiments?app_code | 创建（body: name + treatment_traffic_percent + overrides + config） |
| PATCH | /lucky-gifts/experiments/{id}?app_code | 放量 / 暂停 / 恢复 |
| PUT | /lucky-gifts/experiments/{id}/overrides?app_code | 白名单增删 |
| POST | /lucky-gifts/experiments/{id}/end?app_code | 结束并选择获胜方 |

ops-center 前端在幸运礼物配置页集成：实验中奖池带徽章与"实验详情"，行操作可"创建 AB 实验"（复用配置表单 + 实验字段），
管理弹窗支持放量滑调、白名单、分组指标对比与结束实验二次确认。

## 五、标准操作流程（以 lalu super_lucky 升级为例）

1. 配置页 super_lucky 行 → "创建 AB 实验"：填新配置内容、实验名，流量先设 0%，白名单加测试账号（arm=treatment）。
2. 测试账号真机验证：送幸运礼物应命中新版本（抽奖记录 rule_version = treatment 版本）。
3. 实验详情 → 放量 5% → 观察两组 RTP/消耗/独立用户（结算口径差异需累计足够流水才有意义）→ 20% → 50%。
4. 异常随时"暂停"（全量回对照组，不销毁分组）；修复后恢复。
5. 数据达标 → "结束实验"，选获胜方；系统重发布获胜配置为新版本并全量收敛。

## 六、发布与排障

- **迁移**：生产按既有流程手动执行 `006_ab_experiments.sql`（`mysql_auto_migrate` 不建表）；本地 docker 新库由 initdb 覆盖，
  旧库需手动执行 006。测试环境同理。
- 分组核对 SQL：
  `SELECT rule_version, COUNT(*), SUM(coin_spent), SUM(effective_reward_coins) FROM lucky_draw_records
   WHERE app_code=? AND pool_id=? AND created_at_ms>=? GROUP BY rule_version;`
- 某用户实际在哪组：查 `lucky_gift_experiment_overrides`；无 override 时按 salt 哈希（域函数 `ExperimentBucketPPM`，
  可用 strategy-sim 或临时 Go 片段复算）。
- 真库回归：`LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN=... go test ./services/lucky-gift-service/internal/storage/mysql/ -run Experiment`。
