feat(lucky-gift): implement dynamic v3 strategy

This commit is contained in:
zhx 2026-07-15 14:49:39 +08:00
parent 902cf46516
commit 8b6105c730
67 changed files with 19104 additions and 8462 deletions

File diff suppressed because it is too large Load Diff

View File

@ -31,6 +31,12 @@ message LuckyGiftMeta {
int32 gift_count = 12; int32 gift_count = 12;
int64 visible_region_id = 13; int64 visible_region_id = 13;
int64 country_id = 14; int64 country_id = 14;
//
int64 recharge_7d_coins = 15;
int64 recharge_30d_coins = 16;
int64 last_recharged_at_ms = 17;
// gift_income_coins wallet dynamic_v3
int64 gift_income_coins = 18;
} }
message LuckyGiftRuleTier { message LuckyGiftRuleTier {
@ -45,6 +51,9 @@ message LuckyGiftRuleTier {
message LuckyGiftRuleStage { message LuckyGiftRuleStage {
string stage = 1; string stage = 1;
repeated LuckyGiftRuleTier tiers = 2; repeated LuckyGiftRuleTier tiers = 2;
// dynamic_v3 novice 0/0
int64 min_recharge_7d_coins = 3;
int64 min_recharge_30d_coins = 4;
} }
message LuckyGiftRuleConfig { message LuckyGiftRuleConfig {
@ -63,6 +72,30 @@ message LuckyGiftRuleConfig {
int64 created_by_admin_id = 13; int64 created_by_admin_id = 13;
int64 created_at_ms = 14; int64 created_at_ms = 14;
repeated LuckyGiftRuleStage stages = 15; repeated LuckyGiftRuleStage stages = 15;
// strategy_version fixed_v2 dynamic_v3
string strategy_version = 16;
int64 profit_rate_ppm = 17;
int64 anchor_rate_ppm = 18;
int64 initial_pool_coins = 19;
int64 loss_streak_guarantee = 20;
int64 low_watermark_coins = 21;
int64 low_water_nonzero_factor_ppm = 22;
int64 high_watermark_coins = 23;
int64 high_water_nonzero_factor_ppm = 24;
int64 recharge_boost_window_ms = 25;
int64 recharge_boost_factor_ppm = 26;
repeated int64 jackpot_multiplier_ppms = 27;
int64 jackpot_global_rtp_max_ppm = 28;
int64 jackpot_user_day_rtp_max_ppm = 29;
int64 jackpot_user_72h_rtp_max_ppm = 30;
int64 jackpot_spend_threshold_coins = 31;
int64 max_jackpot_hits_per_user_day = 32;
int64 max_single_payout = 33;
int64 user_hourly_payout_cap = 34;
int64 user_daily_payout_cap = 35;
int64 device_daily_payout_cap = 36;
int64 room_hourly_payout_cap = 37;
int64 anchor_daily_payout_cap = 38;
} }
message CheckLuckyGiftRequest { message CheckLuckyGiftRequest {
@ -82,6 +115,8 @@ message CheckLuckyGiftResponse {
int64 target_rtp_ppm = 6; int64 target_rtp_ppm = 6;
string experience_pool = 7; string experience_pool = 7;
string pool_id = 8; string pool_id = 8;
// strategy_version lucky-gift owner room saga fixed_v2 dynamic_v3
string strategy_version = 9;
} }
message LuckyGiftDrawResult { message LuckyGiftDrawResult {
@ -141,6 +176,8 @@ message ExternalGiftDrawRequest {
int64 paid_at_ms = 10; int64 paid_at_ms = 10;
string metadata_json = 11; string metadata_json = 11;
string pool_id = 12; string pool_id = 12;
// dynamic_v3 luck-gateway /fixed_v2
string device_id = 13;
} }
message ExternalGiftDrawResponse { message ExternalGiftDrawResponse {

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,8 @@ message RequestMeta {
string session_id = 6; string session_id = 6;
int64 sent_at_ms = 7; int64 sent_at_ms = 7;
string app_code = 8; string app_code = 8;
// device_id gateway access JWT claim JWT session_id/command_id
string device_id = 9;
} }
// CommandResult // CommandResult

File diff suppressed because it is too large Load Diff

View File

@ -92,6 +92,15 @@ message DebitGiftResponse {
string host_point_template_version = 27; string host_point_template_version = 27;
// balance_version sender command_id // balance_version sender command_id
int64 balance_version = 28; int64 balance_version = 28;
// recharge_seven_day_coins / recharge_thirty_day_coins wallet owner UTC
// lucky-gift-service
int64 recharge_seven_day_coins = 29;
int64 recharge_thirty_day_coins = 30;
// last_recharged_at_ms lucky/super_lucky 0 30 UTC
int64 last_recharged_at_ms = 31;
// paid_at_ms wallet_transactions.created_at_ms command_id
// room/lucky wallet owner UTC saga worker
int64 paid_at_ms = 32;
} }
// DebitGiftTarget // DebitGiftTarget

View File

@ -0,0 +1,305 @@
# 幸运礼物动态策略 V3实现边界与模拟验收
## 1. 版本边界
本文只描述 `dynamic_v3` 的规则、持久化契约和可重复模拟口径,不改变已经发布的 `fixed_v2` 规则语义,也不代表该策略已在线上自动启用。
- `strategy_version=fixed_v2`:继续按历史不可变规则执行;旧请求或旧记录缺少 `strategy_version` 时必须归一为 `fixed_v2`
- `strategy_version=dynamic_v3`:只有显式发布并启用的规则才进入动态水位、充值加权、保底、大奖和六维风控。不能因为服务升级而把存量奖池隐式切换到 V3。
- 新奖池未配置时返回 `dynamic_v3``disabled` 草稿。各 App 必须先补齐 50 美元等值金币门槛及六项金额上限,才允许启用。
- 启用前还必须确认 user/gateway/room 已升级到由 `auth_sessions.device_id` 签入 access JWT再通过 `room.RequestMeta` 和可恢复 command 原样传递设备作用域。旧 JWT 缺 `device_id``fixed_v2` 仍兼容,`dynamic_v3` 必须 fail-close不得用 `session_id`/`command_id` 伪造设备。
- 启用前还必须确认 user/gateway/room/wallet 已按顺序升级access JWT 和 room meta 携带 auth session 绑定的可信 `device_id`wallet 回执携带交易 `paid_at_ms`、充值快照和 `gift_income_coins`。任一动态字段缺失或真实收礼返币比例与规则 `anchor_rate_ppm` 不一致时运行侧 fail-close。
- 比例、概率和倍率统一使用 ppm`1_000_000=100%=1x`;钱只使用整数金币;业务时间只使用 UTC epoch milliseconds。
- 离线模拟使用固定 seed 的 `math/rand`;生产抽奖必须注入不可预测的安全随机源,不能复用模拟 RNG。
## 2. 已确认的产品口径
### 2.1 资金拆分采用 98/1/1
每笔已成功扣费的幸运礼物按整数守恒拆分:
| 去向 | 默认比例 | 作用 |
| --- | ---: | --- |
| 公共奖金池 `P` | 98% | 在本抽开奖前先入池,承担用户返奖 |
| 平台盈利 | 1% | 只记盈利拆账,不进入可返奖余额 |
| 收礼主播收益 | 1% | 规则计算值必须与钱包已落账回执完全一致,不允许缺字段或其他返币比例静默覆盖规则 |
原图同一行同时出现“抽取 98%”和“92% 可灵活配置”两者冲突。V3 采用能够与 1%+1% 合计为 100% 的 **98/1/1**“92%”视为原图笔误,不进入默认配置、代码或模拟。比例可发布为其他值时,三项仍必须非负且合计 100%,不允许出现去向不明的余额。
公共池与主播份额分别向下取整,无法再拆的整币余数归平台盈利,任何价格都满足:
```text
public_pool_coins + profit_coins + anchor_return_coins = coin_spent
```
### 2.2 RTP 是观察目标,不是透支承诺
- 发布目标为 98%,正常观察带为 95%101%98%±3 个百分点)。
- RTP 分子是已经开奖形成的不可撤销返奖负债,分母是对应已扣费流水;分母为 0 时是“无有效样本”,不能当成 0% 去触发补偿大奖。
- 95%101% 是资金充足且样本量足够时的运营观察目标,不是每抽、每用户、每窗口的硬保证。
- 当奖池、单次限额或任一风控窗口不足时,安全约束优先;允许窗口 `underpaid` 或短期偏离 95%101%,禁止为了追 RTP 透支奖池。
- `settlement_window_wager` 是当前不可变规则版本的真实金币流水阈值。单抽整体计入它开始时所在窗口;若本抽使累计流水达到或超过阈值,下一抽才开启新窗口。`gift_price_reference` 只用于体验阶段的等价抽数,不参与 V3 滚窗。
### 2.3 原图概率表不能作为生产默认概率
原图示例为:
| 倍率 | 概率 | EV 贡献 |
| ---: | ---: | ---: |
| 0x | 60% | 0x |
| 5x | 20% | 1x |
| 50x | 15% | 7.5x |
| 200x | 4% | 8x |
| 1000x | 1% | 10x |
| 合计 | 100% | **26.5x** |
因此该表的名义 `EV=26.5x`,即 `RTP=2650%`,不可能同时是 98% RTP。它只用于验证原图的 `P/W` 删档、删 0 和重抽分支,不能写入生产默认基础奖档。
`dynamic_v3` 的安全发布草稿采用独立的 98% 静态 EV`0x@5% + 0.5x@4% + 1x@86% + 2x@5% = 0.98x``200x/500x/1000x` 作为大奖集合单独配置,不能在缺少经过成本校验的概率时照搬进基础概率表。
## 3. 一次抽奖的确定顺序
一次真实抽奖按以下顺序完成;同一事务内锁住的状态必须与 draw record 一起提交:
1. `user-service``auth_sessions.device_id` 签入 access JWTgateway 验签后写入 room `RequestMeta`room saga 固化该设备快照,禁止用可轮换的 session 或 command ID 代替。
2. `wallet-service` 完成真实扣费,并在幂等回执中固化 `coin_spent`、交易 `paid_at_ms`、最近 7/30 个 UTC 自然日充值额、最后充值时间及主播收益金额。
3. `lucky-gift-service` 只消费上述可信事实不直连钱包库不按当前余额、IP、服务时区、恢复时间或客户端自报值推断充值画像和时间归属。
4. 本抽金币按 98/1/1 拆分;公共池份额先计入 `P`,再比较本抽奖金 `W`
5. 按 `app_code + pool_id + rule_version` 读取不可变规则锁定公共池、用户日状态、72 小时桶/边界事件、连 0 状态和六维风控计数。
6. 用钱包快照选择充值层级,再对所有非 0 基础档应用水位和最近充值因子0 档接收剩余概率。
7. 优先处理已持久化的“消费里程碑大奖 token”其次判断 RTP 补偿大奖,最后进入普通概率抽奖。
8. 对选中的 `W` 依次执行奖池、单用户日大奖次数和六维风控检查;任何一项不满足都不能发奖。
9. 写入抽奖事实、决策快照、奖池/窗口/风控/用户状态及 outbox。钱包返奖状态独立收敛为 `pending/granted/failed`
这套顺序的核心约束是:先有已落账的扣费事实,再入池,再开奖;任何体验规则都不能制造不存在的资金。
## 4. 动态概率、分层和保底
### 4.1 充值分层
V3 固定有 `novice/normal/advanced` 三层,但两个门槛由每个不可变规则版本配置:
- 最近 7 日和最近 30 日均按 UTC 自然日统计且包含当天;分别覆盖“今天及前 6 个 UTC 日”和“今天及前 29 个 UTC 日”,上界为钱包扣费时刻。
- 用户进入同时满足两个最低充值门槛的最高层;不能只满足 7 日或只满足 30 日门槛就越级。
- `novice` 门槛必须是 `0/0``normal` 至少一维高于 novice`advanced` 两维均不得低于 normal且至少一维严格更高。
- 阶段阈值相等时算已达到,即使用 `>=`;所有金额门槛按 App 金币口径显式发布。
充值快照由 `wallet-service` 在礼物扣费事务中查询并写入交易 metadata同一 `command_id` 重放返回首次快照,避免重试时跨层或丢失五分钟加成。
### 4.2 水位和最近充值因子
设某层的非 0 档基础权重为 `base_weight`,则:
```text
adjusted_weight = base_weight × water_factor × recharge_factor
```
因子是相对乘法,不是把百分点直接相加:
| 条件 | 默认因子 | 边界 |
| --- | ---: | --- |
| `P < low_watermark` | 0.70 | 恰等于低水位时不下调 |
| `low_watermark <= P <= high_watermark` | 1.00 | 两个端点都属于中水位 |
| `P > high_watermark` | 1.30 | 恰等于高水位时不上调 |
| 有可信充值事实且处于充值后窗口 | 1.10 | `[last_recharged_at_ms, last_recharged_at_ms + 300000)` |
例如低水位并处于充值后五分钟时,非 0 权重为 `base × 0.70 × 1.10 = base × 0.77`,不是 `base × 0.80`。五分钟窗口包含起点和第 `299999ms`,不包含第 `300000ms`;未来时间、缺失充值事实或 `last_recharged_at_ms=0` 都不加权。
调整后:若正奖权重和不超过 100%0 档权重取剩余值;若正奖权重和超过 100%,按相同比例归一化正奖权重并把 0 档置为 0。该归一化只解决概率守恒不绕过资金和风控。
### 4.3 连续 5 次 0 后的第 6 抽
- `loss_streak` 只在最终可见返奖为 0 时加一,任意正返奖后清零。
- 前 5 次连续为 0 后,第 6 抽从普通候选中排除 0 档,选择可支付的非 0 档。
- “第 6 抽不出 0”是有资金、有剩余额度时的体验保证如果所有正奖都超过 `P` 或六维风控剩余额度,仍返回 0并记录 `miss_protection_blocked_no_payable_tier`。不得透支来兑现保底。
## 5. `P/W` 安全重抽
`P` 是本抽入池后的公共奖金池可用余额,`W=gift_price × multiplier`。普通抽奖执行:
1. 第一次按当前动态权重抽档。
2. `W <= P` 且所有风控均允许时直接支付;`W == P` 可以支付,支付后 `P=0`
3. 若 `W > P`,同时删除该中奖档和 0 档,再对剩余正奖档重抽。
4. 重抽仍有 `W > P` 时继续删除该中奖档0 档只在第一次资金不足时删除一次。
5. 若最终没有可支付的正奖档,安全回退 0不改变 `P` 为负数。
原图 `P=800`、单价 10 的脚本化示例只用于分支验收:先抽到 `200x(W=2000)` 时删除 `200x+0x`;若再抽到 `1000x(W=10000)`,再删除 `1000x`;随后只能在仍有权重且 `W<=800` 的正奖中选择。该示例证明重抽语义,不证明原图概率具有可用 RTP。
奖池不足、风险额度不足和个人大奖次数达到上限是不同的审计原因。只有 `W>P` 触发原图规定的“删中奖档并同时删 0”其他风控拒绝不能伪装成奖池不足。
## 6. 两种大奖机制
大奖集合由 `jackpot_multiplier_ppms` 配置,默认产品集合为 `200x/500x/1000x`。原图没有给出三个大奖之间的概率,因此当前不可变规则明确采用“在可支付集合中等权随机”;这不属于普通基础概率。若产品需要非等权,必须先新增显式权重契约和成本验收,不能从普通奖档概率猜算。
### 6.1 机制一RTP 亏损补偿大奖
本抽开始时同时满足以下条件,才允许从当前可支付大奖集合随机选择:
- 全局结算 RTP `<= jackpot_global_rtp_max_ppm`,默认 98%
- 用户当前 UTC 日 RTP `<= jackpot_user_day_rtp_max_ppm`,默认 96%
- 用户滚动 72 小时 RTP `<= jackpot_user_72h_rtp_max_ppm`,默认 96%
- 用户本 UTC 日大奖次数 `< max_jackpot_hits_per_user_day`,默认 5
- 候选 `W<=P` 且不超过六维风控的最小剩余额度。
任一 RTP 分母为 0、任一 RTP 高于阈值、奖池不足或风控不足,都只回到普通抽奖,不强发大奖。滚动 72 小时是 `[now_ms-72h, now_ms)` 的 UTC 业务窗口,不能替换成“最近 3 个本地自然日”。
### 6.2 机制二:消费里程碑大奖
- `jackpot_spend_threshold_coins` 是各 App 对“50 美元等值幸运礼物金币”的显式换算,服务不读取实时汇率推断。
- 用户 UTC 日累计消费每跨过一个完整门槛,持久化一个大奖 token同一流水重试不能重复发 token。消费进度和当日命中次数在 UTC 日切换时归零,但已赚到且因资金不足未消费的 token 保存在用户/奖池状态中,可跨日等待下一抽。
- 当前抽跨过的门槛只能影响下一抽,不能回头改变已经完成的当前抽。
- 下一抽优先消费已有 token若大奖集合因 `P`、日 5 次上限或风控不可支付token 保留,用户仍获得本次普通抽奖。
- 同一抽同时满足机制一和机制二时,已承诺的里程碑 token 优先;一个 draw 最多结算一个最终奖档。
原图写的是“定时任务”。实现改为在 owner 事务里按已结算送礼事实同步计算跨过的整数门槛并落 token结果仍只影响下一抽但不会出现“用户已经完成门槛、下一抽先于 cron 扫描到达”的竞态,也不需要 cron-service 回扫明细或直接拥有大奖状态。这个事件驱动实现是有意的工程等价替换;若产品需要固定延迟发放,必须另增明确生效时间字段,而不是依赖不确定的扫描周期。
## 7. 六维风控与时间口径
所有启用的 `dynamic_v3` 规则必须配置正数上限:
| 维度 | 配置字段 | UTC 窗口 |
| --- | --- | --- |
| 单次返奖 | `max_single_payout` | 当前 draw |
| 用户小时 | `user_hourly_payout_cap` | 当前 UTC 小时 |
| 用户日 | `user_daily_payout_cap` | 当前 UTC 自然日 |
| 设备日 | `device_daily_payout_cap` | 当前 UTC 自然日 |
| 房间小时 | `room_hourly_payout_cap` | 当前 UTC 小时 |
| 主播日 | `anchor_daily_payout_cap` | 当前 UTC 自然日 |
一次候选的有效容量是 `P` 和六项剩余额度的最小值。候选金额等于剩余额度时允许,超过任一维度即拒绝;计数更新与 draw record 必须在同一锁定事务内,避免并发抽奖分别看到同一份余额。
设备日 scope 只接受 user-service 已落库 session 的 `device_id`,由签名 JWT 贯通到 room 和 lucky旧 token/旧 command 缺字段时 `fixed_v2` 可继续重放,但 `dynamic_v3` 必须拒绝开奖,不能回退到 `session_id` 或每单变化的 `command_id`。外部 App 的 device ID 由已认证调用方负责稳定提供V3 缺失同样拒绝;服务只保存 App 内稳定 hash避免暴露原始标识。
所有时间范围遵守 `[start_ms,end_ms)`包含开始毫秒不包含结束毫秒。UTC 日、UTC 小时、充值 5 分钟和滚动 72 小时都不得使用 `time.Local`、容器时区或客户端时区切分。
## 8. 批量 N 次的语义
`gift_count=N` 表示按顺序执行 N 次抽奖,不是把总金额当成一次抽奖,也不是“只开奖一次但保证中奖”。例如 `gift_count=99` 必须产生 99 次状态推进。
- 总 `coin_spent` 拆成 N 个整数单份,余数从前往后每份加 1三资金桶使用联合累计配额保证每一抽 `public_i+profit_i+anchor_i=unit_spent_i`,同时整批三桶精确等于钱包回执总拆账。
- 第 `i` 抽看到第 `i-1` 抽后的 `P`、连 0、RTP、token、日大奖次数和风控计数不能并行抽 N 次后再合并。
- 若第 `i` 抽使当前 RTP 窗口真实累计流水达到或超过 `settlement_window_wager`,第 `i+1` 抽必须先滚到新窗口;不能按 `ceil(window_wager/gift_price_reference)` 折算抽数,也不能把整批强塞进旧窗口后再一次性关窗。
- 每个子抽用稳定子幂等键(现有约定为 `command_id#序号`)保留独立 draw record重放整批不能跳过、补写或重复支付其中一抽。
- 一批内共享状态只锁一次并按顺序在内存推进,最终批量持久化;性能优化不能改变顺序语义。
- 聚合钱包返奖和房间表现可以按送礼命令合并,但审计仍保留 N 条单抽事实。
外部 App 的 `/lucky-gifts/send` 也遵守同一语义:`dynamic_v3` 逐次执行最多 999 抽、写 `external_lucky_gift_draw_items`,最后只把总奖励聚合返回;外部调用方自行入账,因此不生成 HyApp wallet 返奖 outbox。外部 `dynamic_v3` 必须提交稳定 `device_id` 和真实扣费 `paid_at_ms`,同设备多账号共享设备日上限;但 luck-gateway 目前只有 App allowlist不能独立证明设备真实性所以调用方必须在自身认证/请求签名边界内绑定这些值owner 不得用 `external_user_id``request_id` 或请求到达时间兜底。当前外部协议没有可验证的充值 owner 快照,运行侧明确按 `novice` 且不加最近充值因子,不能从 `metadata` 猜充值额。`fixed_v2` 外部请求继续保持缺少新事实的历史兼容路径。
## 9. 接口和配置字段
### 9.1 事实输入
`wallet.v1.DebitGiftResponse` 新增以下幂等回执字段:
- `recharge_seven_day_coins`
- `recharge_thirty_day_coins`
- `last_recharged_at_ms`
- `paid_at_ms`
`luckygift.v1.LuckyGiftMeta` 接收:
- `device_id`(从已验签 access JWT 穿过 room command 恢复链路)
- `paid_at_ms`(逐目标 wallet transaction 的稳定创建时间)
- `recharge_7d_coins`
- `recharge_30d_coins`
- `last_recharged_at_ms`
- `gift_income_coins`
`room.v1.RequestMeta.device_id` 只由 gateway 从验签 JWT 的 `device_id` claim 写入;`ExternalGiftDrawRequest.device_id` 由外部认证调用方承担稳定性责任。V3 对两个入口都不提供 session/command fallback。其余结算字段只来自 wallet owner 的成功回执。`request_id` 只做链路追踪;抽奖幂等仍使用 `command_id`
### 9.2 不可变规则字段
`LuckyGiftRuleConfig` / `lucky_gift_rule_versions` 的 V3 字段分组如下:
| 决策 | 字段 |
| --- | --- |
| 版本和目标 | `strategy_version`, `target_rtp_ppm`, `settlement_window_wager`, `control_band_ppm`, `gift_price_reference` |
| 资金拆分 | `pool_rate_ppm`, `profit_rate_ppm`, `anchor_rate_ppm`, `initial_pool_coins` |
| 保底和水位 | `loss_streak_guarantee`, `low_watermark_coins`, `low_water_nonzero_factor_ppm`, `high_watermark_coins`, `high_water_nonzero_factor_ppm` |
| 最近充值 | `recharge_boost_window_ms`, `recharge_boost_factor_ppm` |
| 大奖 | `jackpot_multiplier_ppms`, `jackpot_global_rtp_max_ppm`, `jackpot_user_day_rtp_max_ppm`, `jackpot_user_72h_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` |
| 充值层级 | `stages[].min_recharge_7d_coins`, `stages[].min_recharge_30d_coins`, `stages[].tiers[]` |
后台沿用现有 `/admin/activity/lucky-gifts/v2/config` 配置入口URL 中的 `v2` 是接口兼容标识,不等于 `strategy_version`。发布时必须把上述字段完整映射到内部 protobuf、domain 和 MySQL不允许只在 HTTP JSON 中临时扩字段。
## 10. 持久化与审计
| 表 | V3 事实 |
| --- | --- |
| `lucky_gift_rule_versions` | `fixed_v2/dynamic_v3` 不可变版本、资金拆分、水位、大奖门槛和六项上限 |
| `lucky_gift_stage_tiers` | 三层充值门槛、基础倍率和基础概率 |
| `lucky_pools` | 公共池 `balance/total_in/total_out`,以及 `profit_total/anchor_income_total/initial_seed_total` |
| `lucky_rtp_windows` | 全局/奖池窗口的流水、目标返奖、实际返奖和 `open/closed/underpaid` 状态 |
| `lucky_user_rtp_hour_buckets` | 用户 UTC 小时流水/返奖桶,用于严格滚动 72 小时 |
| `lucky_user_rtp_boundary_events` | 只补齐 `[now-72h,now)` 首尾两个不足一小时的片段;与完整小时桶组合后不近似、不回扫全量 draw |
| `lucky_user_strategy_days` | 用户 UTC 日流水、返奖、消费和已命中大奖次数 |
| `lucky_user_states` | 累计抽数、等价流水、`loss_streak` 和可跨日保留的待消费 token |
| `lucky_risk_counters` | 用户/设备/房间/主播的 UTC 小时或日累计返奖 |
| `lucky_draw_records` | 单抽最终奖档、金额、规则版本、候选/奖池/RTP 决策快照和钱包状态 |
| `external_lucky_gift_draw_items` | 外部 App `gift_count=N` 的 N 条顺序子抽事实;外部聚合行只负责幂等响应 |
| `lucky_gift_command_locks` | 内部 `app_code+command_id` 首次并发互斥;等待者在首事务提交后回读同一批 draw 事实 |
| `lucky_gift_outbox` | 钱包补偿与投递审计,不替代 draw 业务事实 |
每条 V3 draw 的审计快照至少要能还原:阶段、规则版本、抽前池余额、本抽入池、可用池、六维最小容量、各档基础/调整后权重及因子、原始档、每次删档/重抽、三项 RTP 条件、大奖机制、token 产生/消费/保留、最终原因和阻断原因。
状态边界:
- `reward_status=pending/granted/failed` 只表示自动钱包入账状态;抽奖一旦向用户展示就形成不可撤销负债,`failed` 表示仍欠用户、必须重放或人工补偿,不能反向释放公共池/RTP/风控额度,也不能取消奖励。已 `granted` 后 IM 失败不能把账务事实改成 `failed`
- outbox 使用 `pending/retryable/delivering/delivered/failed`;失败原因、重试次数和锁到期时间必须可查。
- 规则不可变发布;事故回放必须读取 draw 当时的 `rule_version` 和快照,不能套用最新配置重算。
## 11. 本地模拟矩阵
模拟必须调用与生产相同的纯策略内核,并使用固定 seed 输出人类摘要和 JSON。以下每项都是独立验收不用一个长跑 RTP 数字替代边界测试:
| 组 | 输入矩阵 | 必须证明 |
| --- | --- | --- |
| 名义 EV | 原图 0/5/50/200/1000 概率 | `26.5x/2650%``passes_target=false` 是预期成功 |
| 资金拆分 | 0、1、极小金额、普通金额 | 三份非负且逐笔整数守恒;冷启动只注资一次 |
| 原图 P/W | `P=800, price=10`,脚本依次命中 5x、200x、1000x | 5x 直付200x 删除自身+01000x 继续删除;最终不透支 |
| P/W 边界 | `W=P-1/P/P+1`、全部正奖不可付 | 等于可付,大于重抽,候选耗尽回 0`P` 永不为负 |
| 连 0 保底 | streak 05第 6 抽资金足/不足 | 第 6 抽排除 0无可支付正奖时安全回 0并留阻断原因 |
| 水位 | `low-1/low/low+1/high-1/high/high+1` | 只在 `<low` 下调、`>high` 上调,端点为中水位 |
| 充值窗口 | 起点、`+299999ms``+300000ms`、未来时间、无回执 | 严格 `[start,end)`,只有可信快照生效 |
| 因子组合 | 低/中/高水位 × 充值开/关 | 因子相乘;概率合计始终 100% |
| 充值分层 | 各 7/30 日门槛的下 1、等于、上 1 | 必须同时达到两维,选择最高满足层 |
| 批量 | N=1、N=99、金额有余数 | 恰好 N 次顺序推进,金额守恒,单抽事实与重放一致 |
| 结算窗口 | 阈值 99、参考价 10、已 10 抽但仅 98 流水,再执行 99 份共 100 金币 | 第 1 抽进入旧窗并使流水到 100后 98 抽进入新窗;证明不按抽数提前滚动 |
| 大奖机制一 | 全局/当日/72h 各自通过和失败、分母 0 | 三门同时通过才有资格,零样本不触发 |
| 大奖机制二 | 门槛前、恰好跨过、一次跨多个门槛、资金不足 | token 只影响下一抽,幂等产生,资金不足保留 |
| 大奖集合 | 200x/500x/1000x 分别可付/不可付 | 只在可付集合随机,不能选中后透支 |
| 日 5 次 | 已中 4 次、5 次UTC 日边界 | 第 5 次可中,第 6 次大奖被阻断,新 UTC 日重新计数 |
| 六维风控 | 每一维分别取 `W=remaining``W=remaining+1` | 等于允许,超过任一维拒绝,原因可审计 |
| 组合优先级 | token + RTP 补偿 + 连 0 + 低水位 + 充值 | token 优先,其后 RTP再普通安全门永远最后裁决 |
| 并发/幂等 | 同 command 重放、同用户并发状态版本 | 只结算一次;状态无丢失更新,奖池不为负 |
| 有资金长跑 | 固定 seed、足量冷启动资金、各充值层 | 报告实际 RTP/命中率/池底,不把 95%101%当资金不足时硬断言 |
### 11.1 可重复命令
```bash
go test ./services/lucky-gift-service/internal/domain/luckygift
go test ./services/wallet-service/internal/storage/mysql -run 'LuckyGiftRechargeSnapshot'
go run ./services/lucky-gift-service/cmd/strategy-sim \
-seed 20260712 \
-monte-carlo-draws 100000 \
-long-run-draws 100000 \
-group-long-run-draws 30000 \
-json-out /tmp/lucky-gift-dynamic-v3-simulation.json
```
策略模拟进程只有在全部场景满足断言时退出 0。总报告必须同时满足
- `nominal_probability_table.expected_multiplier=26.5`
- `nominal_probability_table.nominal_rtp_percent=2650`
- `nominal_probability_table.passes_target=false`
- `all_passed=true`
- 所有场景输出固定 seed、输入、最终奖档/状态、池底和阻断原因,不能只打印“通过”。
## 12. 上线前验收门
1. 存量规则空 `strategy_version` 读取和重放仍为 `fixed_v2`,且无需补 V3 金额字段。
2. 新 `dynamic_v3` 启用规则的三项拆分必须合计 100%,每层基础概率必须合计 100%,静态 EV 必须落在目标 RTP±控制带内。
3. 50 美元等值金币、低/高水位、三层充值门槛、大奖集合、日次数和六项风控都必须按 App 明确配置;不能依赖代码默认值直接上线。
4. user JWT、gateway/room meta、wallet 回执、lucky-gift 请求、protobuf 生成代码、配置读写和 MySQL schema 必须端到端包含新增字段;动态入口缺可信 `device_id` 或 owner `paid_at_ms` 必须 fail-close。
5. 本地单测、完整模拟、`make proto``make test` 通过后,仍需用隔离奖池灰度;灰度验收重点是奖池不为负、幂等不重复发奖、审计可回放,而不是短窗口 RTP 恰好等于 98%。

File diff suppressed because it is too large Load Diff

View File

@ -32,15 +32,38 @@ func New(luckyGift luckygiftadmin.Client, requestTimeout time.Duration, audit sh
type configRequest struct { type configRequest struct {
AppCode string `json:"app_code"` AppCode string `json:"app_code"`
PoolID string `json:"pool_id"` PoolID string `json:"pool_id"`
StrategyVersion string `json:"strategy_version"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
TargetRTPPercent float64 `json:"target_rtp_percent"` TargetRTPPercent float64 `json:"target_rtp_percent"`
PoolRatePercent float64 `json:"pool_rate_percent"` PoolRatePercent float64 `json:"pool_rate_percent"`
ProfitRatePercent float64 `json:"profit_rate_percent"`
AnchorRatePercent float64 `json:"anchor_rate_percent"`
SettlementWindowWager int64 `json:"settlement_window_wager"` SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPercent float64 `json:"control_band_percent"` ControlBandPercent float64 `json:"control_band_percent"`
GiftPriceReference int64 `json:"gift_price_reference"` GiftPriceReference int64 `json:"gift_price_reference"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"` NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"` NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
EffectiveFromMS int64 `json:"effective_from_ms"` EffectiveFromMS int64 `json:"effective_from_ms"`
InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPercent float64 `json:"low_water_nonzero_factor_percent"`
HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPercent float64 `json:"high_water_nonzero_factor_percent"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPercent float64 `json:"recharge_boost_factor_percent"`
JackpotMultipliers []float64 `json:"jackpot_multipliers"`
JackpotGlobalRTPMaxPercent float64 `json:"jackpot_global_rtp_max_percent"`
JackpotUserDayRTPMaxPercent float64 `json:"jackpot_user_day_rtp_max_percent"`
JackpotUser72hRTPMaxPercent float64 `json:"jackpot_user_72h_rtp_max_percent"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
MaxSinglePayout int64 `json:"max_single_payout"`
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
Stages []stageDTO `json:"stages"` Stages []stageDTO `json:"stages"`
} }
@ -53,6 +76,8 @@ type configDTO struct {
type stageDTO struct { type stageDTO struct {
Stage string `json:"stage"` Stage string `json:"stage"`
MinRecharge7DCoins int64 `json:"min_recharge_7d_coins"`
MinRecharge30DCoins int64 `json:"min_recharge_30d_coins"`
Tiers []tierDTO `json:"tiers"` Tiers []tierDTO `json:"tiers"`
} }
@ -306,21 +331,46 @@ func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig {
} }
stages = append(stages, &luckygiftv1.LuckyGiftRuleStage{ stages = append(stages, &luckygiftv1.LuckyGiftRuleStage{
Stage: stageName, Stage: stageName,
MinRecharge_7DCoins: stage.MinRecharge7DCoins,
MinRecharge_30DCoins: stage.MinRecharge30DCoins,
Tiers: tiers, Tiers: tiers,
}) })
} }
return &luckygiftv1.LuckyGiftRuleConfig{ return &luckygiftv1.LuckyGiftRuleConfig{
AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)), AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)),
PoolId: strings.TrimSpace(req.PoolID), PoolId: strings.TrimSpace(req.PoolID),
StrategyVersion: strings.ToLower(strings.TrimSpace(req.StrategyVersion)),
Enabled: req.Enabled, Enabled: req.Enabled,
TargetRtpPpm: percentToPPM(req.TargetRTPPercent), TargetRtpPpm: percentToPPM(req.TargetRTPPercent),
PoolRatePpm: percentToPPM(req.PoolRatePercent), PoolRatePpm: percentToPPM(req.PoolRatePercent),
ProfitRatePpm: percentToPPM(req.ProfitRatePercent),
AnchorRatePpm: percentToPPM(req.AnchorRatePercent),
SettlementWindowWager: req.SettlementWindowWager, SettlementWindowWager: req.SettlementWindowWager,
ControlBandPpm: percentToPPM(req.ControlBandPercent), ControlBandPpm: percentToPPM(req.ControlBandPercent),
GiftPriceReference: req.GiftPriceReference, GiftPriceReference: req.GiftPriceReference,
NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws, NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws,
NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws, NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws,
EffectiveFromMs: req.EffectiveFromMS, EffectiveFromMs: req.EffectiveFromMS,
InitialPoolCoins: req.InitialPoolCoins,
LossStreakGuarantee: req.LossStreakGuarantee,
LowWatermarkCoins: req.LowWatermarkCoins,
LowWaterNonzeroFactorPpm: percentToPPM(req.LowWaterNonzeroFactorPercent),
HighWatermarkCoins: req.HighWatermarkCoins,
HighWaterNonzeroFactorPpm: percentToPPM(req.HighWaterNonzeroFactorPercent),
RechargeBoostWindowMs: req.RechargeBoostWindowMS,
RechargeBoostFactorPpm: percentToPPM(req.RechargeBoostFactorPercent),
JackpotMultiplierPpms: multipliersToPPM(req.JackpotMultipliers),
JackpotGlobalRtpMaxPpm: percentToPPM(req.JackpotGlobalRTPMaxPercent),
JackpotUserDayRtpMaxPpm: percentToPPM(req.JackpotUserDayRTPMaxPercent),
JackpotUser_72HRtpMaxPpm: percentToPPM(req.JackpotUser72hRTPMaxPercent),
JackpotSpendThresholdCoins: req.JackpotSpendThresholdCoins,
MaxJackpotHitsPerUserDay: req.MaxJackpotHitsPerUserDay,
MaxSinglePayout: req.MaxSinglePayout,
UserHourlyPayoutCap: req.UserHourlyPayoutCap,
UserDailyPayoutCap: req.UserDailyPayoutCap,
DeviceDailyPayoutCap: req.DeviceDailyPayoutCap,
RoomHourlyPayoutCap: req.RoomHourlyPayoutCap,
AnchorDailyPayoutCap: req.AnchorDailyPayoutCap,
Stages: stages, Stages: stages,
} }
} }
@ -343,6 +393,8 @@ func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO {
} }
stages = append(stages, stageDTO{ stages = append(stages, stageDTO{
Stage: stage.GetStage(), Stage: stage.GetStage(),
MinRecharge7DCoins: stage.GetMinRecharge_7DCoins(),
MinRecharge30DCoins: stage.GetMinRecharge_30DCoins(),
Tiers: tiers, Tiers: tiers,
}) })
} }
@ -354,15 +406,38 @@ func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO {
configRequest: configRequest{ configRequest: configRequest{
AppCode: config.GetAppCode(), AppCode: config.GetAppCode(),
PoolID: poolID, PoolID: poolID,
StrategyVersion: config.GetStrategyVersion(),
Enabled: config.GetEnabled(), Enabled: config.GetEnabled(),
TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()), TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()),
PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()), PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()),
ProfitRatePercent: ppmToPercent(config.GetProfitRatePpm()),
AnchorRatePercent: ppmToPercent(config.GetAnchorRatePpm()),
SettlementWindowWager: config.GetSettlementWindowWager(), SettlementWindowWager: config.GetSettlementWindowWager(),
ControlBandPercent: ppmToPercent(config.GetControlBandPpm()), ControlBandPercent: ppmToPercent(config.GetControlBandPpm()),
GiftPriceReference: config.GetGiftPriceReference(), GiftPriceReference: config.GetGiftPriceReference(),
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(), NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(), NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
EffectiveFromMS: config.GetEffectiveFromMs(), EffectiveFromMS: config.GetEffectiveFromMs(),
InitialPoolCoins: config.GetInitialPoolCoins(),
LossStreakGuarantee: config.GetLossStreakGuarantee(),
LowWatermarkCoins: config.GetLowWatermarkCoins(),
LowWaterNonzeroFactorPercent: ppmToPercent(config.GetLowWaterNonzeroFactorPpm()),
HighWatermarkCoins: config.GetHighWatermarkCoins(),
HighWaterNonzeroFactorPercent: ppmToPercent(config.GetHighWaterNonzeroFactorPpm()),
RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(),
RechargeBoostFactorPercent: ppmToPercent(config.GetRechargeBoostFactorPpm()),
JackpotMultipliers: ppmsToMultipliers(config.GetJackpotMultiplierPpms()),
JackpotGlobalRTPMaxPercent: ppmToPercent(config.GetJackpotGlobalRtpMaxPpm()),
JackpotUserDayRTPMaxPercent: ppmToPercent(config.GetJackpotUserDayRtpMaxPpm()),
JackpotUser72hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_72HRtpMaxPpm()),
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
MaxSinglePayout: config.GetMaxSinglePayout(),
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(),
RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(),
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(),
Stages: stages, Stages: stages,
}, },
} }
@ -403,10 +478,26 @@ func multiplierToPPM(value float64) int64 {
return int64(math.Round(value * 1_000_000)) return int64(math.Round(value * 1_000_000))
} }
func multipliersToPPM(values []float64) []int64 {
out := make([]int64, 0, len(values))
for _, value := range values {
out = append(out, multiplierToPPM(value))
}
return out
}
func ppmToMultiplier(value int64) float64 { func ppmToMultiplier(value int64) float64 {
return float64(value) / 1_000_000 return float64(value) / 1_000_000
} }
func ppmsToMultipliers(values []int64) []float64 {
out := make([]float64, 0, len(values))
for _, value := range values {
out = append(out, ppmToMultiplier(value))
}
return out
}
func drawFromProto(draw *luckygiftv1.LuckyGiftDrawResult) drawDTO { func drawFromProto(draw *luckygiftv1.LuckyGiftDrawResult) drawDTO {
if draw == nil { if draw == nil {
return drawDTO{} return drawDTO{}

View File

@ -137,3 +137,73 @@ func TestConfigFromProtoConvertsPPMToBusinessUnits(t *testing.T) {
t.Fatalf("probability percent = %v, want 22", config.Stages[0].Tiers[0].ProbabilityPercent) t.Fatalf("probability percent = %v, want 22", config.Stages[0].Tiers[0].ProbabilityPercent)
} }
} }
func TestDynamicConfigAdminMappingRoundTrip(t *testing.T) {
protoConfig := configToProto(configRequest{
AppCode: " LALU ",
PoolID: " lucky ",
StrategyVersion: " DYNAMIC_V3 ",
Enabled: true,
TargetRTPPercent: 98,
PoolRatePercent: 98,
ProfitRatePercent: 1,
AnchorRatePercent: 1,
SettlementWindowWager: 1_000_000,
ControlBandPercent: 3,
GiftPriceReference: 100,
InitialPoolCoins: 1_000_000,
LossStreakGuarantee: 5,
LowWatermarkCoins: 10_000_000,
LowWaterNonzeroFactorPercent: 70,
HighWatermarkCoins: 20_000_000,
HighWaterNonzeroFactorPercent: 130,
RechargeBoostWindowMS: 300_000,
RechargeBoostFactorPercent: 110,
JackpotMultipliers: []float64{200, 500, 1000},
JackpotGlobalRTPMaxPercent: 98,
JackpotUserDayRTPMaxPercent: 96,
JackpotUser72hRTPMaxPercent: 96,
JackpotSpendThresholdCoins: 5_000,
MaxJackpotHitsPerUserDay: 5,
MaxSinglePayout: 10_000,
UserHourlyPayoutCap: 20_000,
UserDailyPayoutCap: 30_000,
DeviceDailyPayoutCap: 40_000,
RoomHourlyPayoutCap: 50_000,
AnchorDailyPayoutCap: 60_000,
Stages: []stageDTO{{
Stage: " normal ",
MinRecharge7DCoins: 7_000,
MinRecharge30DCoins: 30_000,
Tiers: []tierDTO{{Multiplier: 1, ProbabilityPercent: 100, Enabled: true}},
}},
})
if protoConfig.GetStrategyVersion() != "dynamic_v3" || protoConfig.GetProfitRatePpm() != 10_000 || protoConfig.GetAnchorRatePpm() != 10_000 {
t.Fatalf("strategy/fund split proto mismatch: %+v", protoConfig)
}
if protoConfig.GetLowWaterNonzeroFactorPpm() != 700_000 || protoConfig.GetHighWaterNonzeroFactorPpm() != 1_300_000 || protoConfig.GetRechargeBoostFactorPpm() != 1_100_000 {
t.Fatalf("dynamic factors lost precision in admin mapping: %+v", protoConfig)
}
if got := protoConfig.GetJackpotMultiplierPpms(); len(got) != 3 || got[0] != 200_000_000 || got[2] != 1_000_000_000 {
t.Fatalf("jackpot multiplier ppms=%v, want 200x/500x/1000x", got)
}
stage := protoConfig.GetStages()[0]
if stage.GetMinRecharge_7DCoins() != 7_000 || stage.GetMinRecharge_30DCoins() != 30_000 {
t.Fatalf("stage recharge thresholds were not mapped: %+v", stage)
}
dto := configFromProto(protoConfig)
if dto.StrategyVersion != "dynamic_v3" || dto.ProfitRatePercent != 1 || dto.AnchorRatePercent != 1 {
t.Fatalf("strategy/fund split DTO mismatch: %+v", dto)
}
if dto.LowWaterNonzeroFactorPercent != 70 || dto.HighWaterNonzeroFactorPercent != 130 || dto.RechargeBoostFactorPercent != 110 {
t.Fatalf("dynamic factor DTO mismatch: %+v", dto)
}
if len(dto.JackpotMultipliers) != 3 || dto.JackpotMultipliers[1] != 500 || dto.JackpotUser72hRTPMaxPercent != 96 {
t.Fatalf("jackpot DTO mismatch: %+v", dto)
}
if dto.MaxSinglePayout != 10_000 || dto.AnchorDailyPayoutCap != 60_000 || dto.Stages[0].MinRecharge30DCoins != 30_000 {
t.Fatalf("risk/stage DTO mismatch: %+v", dto)
}
}

View File

@ -14,6 +14,7 @@ type SendCommand struct {
AppCode string AppCode string
RequestID string RequestID string
ExternalUserID string ExternalUserID string
DeviceID string
GiftCount int64 GiftCount int64
UnitAmount int64 UnitAmount int64
TotalAmount int64 TotalAmount int64
@ -59,6 +60,7 @@ func (c *GRPCClient) SendLuckyGift(ctx context.Context, cmd SendCommand) (SendRe
}, },
AppCode: cmd.AppCode, AppCode: cmd.AppCode,
ExternalUserId: cmd.ExternalUserID, ExternalUserId: cmd.ExternalUserID,
DeviceId: cmd.DeviceID,
RequestId: cmd.RequestID, RequestId: cmd.RequestID,
GiftCount: cmd.GiftCount, GiftCount: cmd.GiftCount,
UnitAmount: cmd.UnitAmount, UnitAmount: cmd.UnitAmount,

View File

@ -17,20 +17,25 @@ type Handler struct {
client luckygiftclient.Client client luckygiftclient.Client
allowedApps map[string]struct{} allowedApps map[string]struct{}
requestTimeout time.Duration requestTimeout time.Duration
now func() time.Time
} }
func New(client luckygiftclient.Client, requestTimeout time.Duration, allowedApps map[string]struct{}) *Handler { func New(client luckygiftclient.Client, requestTimeout time.Duration, allowedApps map[string]struct{}) *Handler {
if requestTimeout <= 0 { if requestTimeout <= 0 {
requestTimeout = 3 * time.Second requestTimeout = 3 * time.Second
} }
return &Handler{client: client, allowedApps: cloneAllowedApps(allowedApps), requestTimeout: requestTimeout, now: time.Now} return &Handler{client: client, allowedApps: cloneAllowedApps(allowedApps), requestTimeout: requestTimeout}
} }
type sendRequest struct { type sendRequest struct {
AppCode string `json:"app_code"` AppCode string `json:"app_code"`
RequestID string `json:"request_id"` RequestID string `json:"request_id"`
ExternalUserID string `json:"external_user_id"` ExternalUserID string `json:"external_user_id"`
// DeviceID 由外部 App 在自己的认证/请求签名边界内绑定。
// luck-gateway 目前只做 App allowlist不能独立证明该值来自真实设备dynamic_v3 由 owner 对缺失值 fail-close。
DeviceID string `json:"device_id"`
// PaidAtMS 是外部 App 自己完成扣费的 UTC epoch ms调用方必须把它纳入自己的认证/签名边界。
// gateway 不用到达时间替代。fixed_v2 允许 0 兼容dynamic_v3 由 owner 读取规则后强制要求正值。
PaidAtMS int64 `json:"paid_at_ms"`
GiftCount int64 `json:"gift_count"` GiftCount int64 `json:"gift_count"`
UnitAmount int64 `json:"unit_amount"` UnitAmount int64 `json:"unit_amount"`
Currency string `json:"currency"` Currency string `json:"currency"`
@ -63,14 +68,14 @@ func (h *Handler) Send(w http.ResponseWriter, r *http.Request) {
AppCode: appCode, AppCode: appCode,
RequestID: strings.TrimSpace(req.RequestID), RequestID: strings.TrimSpace(req.RequestID),
ExternalUserID: strings.TrimSpace(req.ExternalUserID), ExternalUserID: strings.TrimSpace(req.ExternalUserID),
DeviceID: strings.TrimSpace(req.DeviceID),
GiftCount: req.GiftCount, GiftCount: req.GiftCount,
UnitAmount: req.UnitAmount, UnitAmount: req.UnitAmount,
TotalAmount: req.GiftCount * req.UnitAmount, TotalAmount: req.GiftCount * req.UnitAmount,
Currency: strings.TrimSpace(req.Currency), Currency: strings.TrimSpace(req.Currency),
MetadataJSON: metadataJSON(req.Metadata), MetadataJSON: metadataJSON(req.Metadata),
// 外部 HTTP 契约不暴露 pool_id/paid_at_mspool 由 owner service 默认规则选择,支付完成时间使用网关服务端 UTC 时间, // pool 仍由 owner 默认规则选择paid_at_ms 则必须保持外部账务事实,不能改写成 gateway 收包时间。
// 避免外部 App 通过请求体影响奖池分桶、预算日或风控窗口。 PaidAtMS: req.PaidAtMS,
PaidAtMS: normalizePaidAt(h.now),
}) })
if err != nil { if err != nil {
response.Fail(w, http.StatusInternalServerError, response.CodeServerError, requestID, "send lucky gift failed") response.Fail(w, http.StatusInternalServerError, response.CodeServerError, requestID, "send lucky gift failed")
@ -98,9 +103,20 @@ func (r sendRequest) validate() error {
if len(strings.TrimSpace(r.ExternalUserID)) > 128 { if len(strings.TrimSpace(r.ExternalUserID)) > 128 {
return errors.New("external_user_id is too long") return errors.New("external_user_id is too long")
} }
if len(strings.TrimSpace(r.DeviceID)) > 128 {
return errors.New("device_id is too long")
}
if r.PaidAtMS < 0 {
return errors.New("paid_at_ms is invalid")
}
// 不在这里统一要求 device_id网关无法获知本次命中的不可变规则是 fixed_v2 还是 dynamic_v3。
// owner 读到规则后对 dynamic_v3 权威拒绝空值fixed_v2 继续兼容旧调用方。
if r.GiftCount <= 0 || r.UnitAmount <= 0 { if r.GiftCount <= 0 || r.UnitAmount <= 0 {
return errors.New("gift_count and unit_amount must be positive") return errors.New("gift_count and unit_amount must be positive")
} }
if r.GiftCount > 999 {
return errors.New("gift_count cannot exceed 999")
}
// 网关只接受数量和单价total_amount 由服务端计算,避免外部 App SDK 或恶意调用方传入不一致账面金额。 // 网关只接受数量和单价total_amount 由服务端计算,避免外部 App SDK 或恶意调用方传入不一致账面金额。
if r.GiftCount > (1<<63-1)/r.UnitAmount { if r.GiftCount > (1<<63-1)/r.UnitAmount {
return errors.New("gift amount is too large") return errors.New("gift amount is too large")
@ -127,10 +143,6 @@ func metadataJSON(value map[string]any) string {
return string(body) return string(body)
} }
func normalizePaidAt(now func() time.Time) int64 {
return now().UTC().UnixMilli()
}
func (h *Handler) isAllowedApp(appCode string) bool { func (h *Handler) isAllowedApp(appCode string) bool {
if len(h.allowedApps) == 0 { if len(h.allowedApps) == 0 {
return false return false

View File

@ -30,7 +30,7 @@ func (f *fakeClient) SendLuckyGift(_ context.Context, cmd luckygiftclient.SendCo
func TestSendAcceptsAllowedAppAndComputesAmount(t *testing.T) { func TestSendAcceptsAllowedAppAndComputesAmount(t *testing.T) {
client := &fakeClient{} client := &fakeClient{}
handler := testRouter(client, "aslan,yumi") handler := testRouter(client, "aslan,yumi")
body := []byte(`{"app_code":"Aslan","request_id":"req-1","external_user_id":"u-1001","gift_count":3,"unit_amount":7,"currency":"COIN","metadata":{"gift":"rose"}}`) body := []byte(`{"app_code":"Aslan","request_id":"req-1","external_user_id":"u-1001","device_id":"device-attested-by-aslan","paid_at_ms":1700000000000,"gift_count":3,"unit_amount":7,"currency":"COIN","metadata":{"gift":"rose"}}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/lucky-gifts/send", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/api/v1/lucky-gifts/send", bytes.NewReader(body))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@ -41,12 +41,27 @@ func TestSendAcceptsAllowedAppAndComputesAmount(t *testing.T) {
if client.calls != 1 { if client.calls != 1 {
t.Fatalf("client calls=%d", client.calls) t.Fatalf("client calls=%d", client.calls)
} }
if client.got.AppCode != "aslan" || client.got.RequestID != "req-1" || client.got.TotalAmount != 21 || client.got.GiftCount != 3 { if client.got.AppCode != "aslan" || client.got.RequestID != "req-1" || client.got.DeviceID != "device-attested-by-aslan" || client.got.PaidAtMS != 1700000000000 || client.got.TotalAmount != 21 || client.got.GiftCount != 3 {
t.Fatalf("unexpected command: %+v", client.got) t.Fatalf("unexpected command: %+v", client.got)
} }
} }
func TestSendRejectsPoolAndPaidAtOverrides(t *testing.T) { func TestSendKeepsMissingDeviceCompatibleForFixedV2OwnerDecision(t *testing.T) {
client := &fakeClient{}
handler := testRouter(client, "aslan")
// luck-gateway 不读规则版本,因此旧 fixed_v2 调用方可继续缺省 device_id
// 如果本 App 已发布 dynamic_v3lucky-gift owner 会在规则事务内明确拒绝。
body := []byte(`{"app_code":"aslan","request_id":"req-fixed-legacy","external_user_id":"u-legacy","gift_count":1,"unit_amount":7}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/lucky-gifts/send", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || client.calls != 1 || client.got.DeviceID != "" {
t.Fatalf("fixed-compatible request status=%d calls=%d command=%+v body=%s", rec.Code, client.calls, client.got, rec.Body.String())
}
}
func TestSendRejectsPoolOverride(t *testing.T) {
client := &fakeClient{} client := &fakeClient{}
handler := testRouter(client, "aslan") handler := testRouter(client, "aslan")
body := []byte(`{"app_code":"aslan","request_id":"req-1","external_user_id":"u-1001","pool_id":"pool-1","paid_at_ms":1700000000000,"gift_count":1,"unit_amount":7}`) body := []byte(`{"app_code":"aslan","request_id":"req-1","external_user_id":"u-1001","pool_id":"pool-1","paid_at_ms":1700000000000,"gift_count":1,"unit_amount":7}`)
@ -62,6 +77,19 @@ func TestSendRejectsPoolAndPaidAtOverrides(t *testing.T) {
} }
} }
func TestSendRejectsNegativePaidAt(t *testing.T) {
client := &fakeClient{}
handler := testRouter(client, "aslan")
body := []byte(`{"app_code":"aslan","request_id":"req-negative-paid-at","external_user_id":"u-1001","paid_at_ms":-1,"gift_count":1,"unit_amount":7}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/lucky-gifts/send", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest || client.calls != 0 {
t.Fatalf("negative paid_at_ms status=%d calls=%d body=%s", rec.Code, client.calls, rec.Body.String())
}
}
func TestSendRejectsDisallowedAppBeforeClientCall(t *testing.T) { func TestSendRejectsDisallowedAppBeforeClientCall(t *testing.T) {
client := &fakeClient{} client := &fakeClient{}
handler := testRouter(client, "aslan,yumi") handler := testRouter(client, "aslan,yumi")
@ -100,6 +128,19 @@ func TestSendRejectsInvalidAmount(t *testing.T) {
} }
} }
func TestSendRejectsMoreThan999SequentialDraws(t *testing.T) {
client := &fakeClient{}
handler := testRouter(client, "aslan")
body := []byte(`{"app_code":"aslan","request_id":"req-1","external_user_id":"u-1","gift_count":1000,"unit_amount":1}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/lucky-gifts/send", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest || client.calls != 0 {
t.Fatalf("oversized sequential draw status=%d calls=%d body=%s", rec.Code, client.calls, rec.Body.String())
}
}
func TestSendRejectsTooLongExternalFields(t *testing.T) { func TestSendRejectsTooLongExternalFields(t *testing.T) {
client := &fakeClient{} client := &fakeClient{}
handler := testRouter(client, "aslan") handler := testRouter(client, "aslan")

View File

@ -23,6 +23,8 @@ type Claims struct {
AppCode string AppCode string
UserID int64 UserID int64
SessionID string SessionID string
// DeviceID 是 user-service 从 auth_session 签入 access token 的设备绑定;旧 token 缺字段时保持为空。
DeviceID string
ProfileCompleted bool ProfileCompleted bool
OnboardingStatus string OnboardingStatus string
AccessTokenExpiresAtMS int64 AccessTokenExpiresAtMS int64
@ -84,6 +86,7 @@ func (v *Verifier) Verify(header string) (Claims, error) {
profileCompleted, _ := claims["profile_completed"].(bool) profileCompleted, _ := claims["profile_completed"].(bool)
onboardingStatus, _ := claims["onboarding_status"].(string) onboardingStatus, _ := claims["onboarding_status"].(string)
sessionID, _ := claims["sid"].(string) sessionID, _ := claims["sid"].(string)
deviceID, _ := claims["device_id"].(string)
appCode, _ := claims["app_code"].(string) appCode, _ := claims["app_code"].(string)
expiresAtMS := claimUnixSecondsMS(claims["exp"]) expiresAtMS := claimUnixSecondsMS(claims["exp"])
@ -91,6 +94,7 @@ func (v *Verifier) Verify(header string) (Claims, error) {
AppCode: appcode.Normalize(appCode), AppCode: appcode.Normalize(appCode),
UserID: userID, UserID: userID,
SessionID: strings.TrimSpace(sessionID), SessionID: strings.TrimSpace(sessionID),
DeviceID: strings.TrimSpace(deviceID),
ProfileCompleted: profileCompleted, ProfileCompleted: profileCompleted,
OnboardingStatus: strings.TrimSpace(onboardingStatus), OnboardingStatus: strings.TrimSpace(onboardingStatus),
AccessTokenExpiresAtMS: expiresAtMS, AccessTokenExpiresAtMS: expiresAtMS,
@ -150,6 +154,13 @@ func SessionIDFromContext(ctx context.Context) string {
return strings.TrimSpace(claims.SessionID) return strings.TrimSpace(claims.SessionID)
} }
// DeviceIDFromContext 只返回已验签 access token 的设备 claim。
// 不从请求体、header 或 session_id 兜底,否则 dynamic_v3 设备日风控可被轻易分裂。
func DeviceIDFromContext(ctx context.Context) string {
claims, _ := ctx.Value(claimsContextKey).(Claims)
return strings.TrimSpace(claims.DeviceID)
}
// ProfileCompletedFromContext 返回 gateway profile gate 的判断依据。 // ProfileCompletedFromContext 返回 gateway profile gate 的判断依据。
func ProfileCompletedFromContext(ctx context.Context) bool { func ProfileCompletedFromContext(ctx context.Context) bool {
claims, _ := ctx.Value(claimsContextKey).(Claims) claims, _ := ctx.Value(claimsContextKey).(Claims)

View File

@ -1,6 +1,7 @@
package auth package auth
import ( import (
"context"
"strconv" "strconv"
"testing" "testing"
@ -14,6 +15,7 @@ func TestVerifierParsesLargeUserIDStringWithoutPrecisionLoss(t *testing.T) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": strconv.FormatInt(userID, 10), "user_id": strconv.FormatInt(userID, 10),
"app_code": "lalu", "app_code": "lalu",
"device_id": " device-auth-1 ",
}) })
signed, err := token.SignedString([]byte("secret")) signed, err := token.SignedString([]byte("secret"))
if err != nil { if err != nil {
@ -27,4 +29,30 @@ func TestVerifierParsesLargeUserIDStringWithoutPrecisionLoss(t *testing.T) {
if claims.UserID != userID { if claims.UserID != userID {
t.Fatalf("user_id lost precision: got %d want %d", claims.UserID, userID) t.Fatalf("user_id lost precision: got %d want %d", claims.UserID, userID)
} }
if claims.DeviceID != "device-auth-1" {
t.Fatalf("device_id was not normalized from signed claim: got %q", claims.DeviceID)
}
}
func TestVerifierKeepsLegacyTokenWithoutDeviceIDCompatible(t *testing.T) {
t.Parallel()
// 存量 access token 在滚动升级期不携带 device_idgateway 仍应允许其进入 fixed_v2
// 但绝不得把 sid 自动填为设备dynamic_v3 会在 owner 处对空设备 fail-close。
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": strconv.FormatInt(42, 10),
"sid": "sess-must-not-be-device",
})
signed, err := token.SignedString([]byte("secret"))
if err != nil {
t.Fatalf("sign legacy token failed: %v", err)
}
claims, err := NewVerifier("secret").Verify("Bearer " + signed)
if err != nil {
t.Fatalf("Verify legacy token failed: %v", err)
}
ctx := WithClaims(context.Background(), claims)
if got := DeviceIDFromContext(ctx); got != "" {
t.Fatalf("legacy token device_id=%q, want empty instead of sid fallback", got)
}
} }

View File

@ -241,6 +241,8 @@ func RoomMeta(request *http.Request, roomID string, commandID string) *roomv1.Re
AppCode: appcode.FromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
GatewayNodeId: "gateway-local", GatewayNodeId: "gateway-local",
SessionId: sessionID, SessionId: sessionID,
// 设备作用域只从已验签 JWT 读取;旧 JWT 返回空值,不用临时 session_id 兜底。
DeviceId: auth.DeviceIDFromContext(request.Context()),
SentAtMs: time.Now().UnixMilli(), SentAtMs: time.Now().UnixMilli(),
} }
} }

View File

@ -0,0 +1,29 @@
package httpkit
import (
"net/http/httptest"
"testing"
"hyapp/services/gateway-service/internal/auth"
)
func TestRoomMetaUsesOnlySignedJWTDeviceClaim(t *testing.T) {
request := httptest.NewRequest("POST", "/api/v1/rooms/gift/send", nil)
request = request.WithContext(auth.WithClaims(request.Context(), auth.Claims{
AppCode: "lalu", UserID: 42, SessionID: "sess-not-a-device", DeviceID: "device-auth-session-42",
}))
meta := RoomMeta(request, "room-1", "cmd-1")
if meta.GetDeviceId() != "device-auth-session-42" || meta.GetSessionId() != "sess-not-a-device" {
t.Fatalf("room meta auth snapshots mismatch: %+v", meta)
}
legacy := httptest.NewRequest("POST", "/api/v1/rooms/gift/send", nil)
legacy = legacy.WithContext(auth.WithClaims(legacy.Context(), auth.Claims{
AppCode: "lalu", UserID: 42, SessionID: "sess-legacy-must-not-be-device",
}))
legacyMeta := RoomMeta(legacy, "room-1", "cmd-legacy")
if legacyMeta.GetDeviceId() != "" {
// 存量 JWT 缺 device_id 时保留空值fixed_v2 兼容dynamic_v3 交给 owner fail-close。
t.Fatalf("legacy room meta derived fake device_id=%q", legacyMeta.GetDeviceId())
}
}

View File

@ -0,0 +1,143 @@
// Command strategy-sim runs the product matrix against the exact pure kernel used by
// production. It performs no database or network I/O; fixed seeds make every report
// reproducible in CI, during review, and after a rule-version change.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
)
const defaultSimulationSeed int64 = 20_260_712
type simulationOptions struct {
Seed int64
MonteCarloN int
LongRunN int
GroupLongRunN int
}
type simulationReport struct {
Seed int64 `json:"seed"`
MonteCarloDraws int `json:"monte_carlo_draws"`
LongRunDraws int `json:"long_run_draws"`
GroupLongRunDraws int `json:"group_long_run_draws"`
Nominal nominalEVReport `json:"nominal_probability_table"`
Scenarios []scenarioResult `json:"scenarios"`
AllPassed bool `json:"all_passed"`
}
type nominalEVReport struct {
ExpectedMultiplier float64 `json:"expected_multiplier"`
NominalRTPPercent float64 `json:"nominal_rtp_percent"`
TargetRTPPercent float64 `json:"target_rtp_percent"`
PassesTarget bool `json:"passes_target"`
Conclusion string `json:"conclusion"`
}
type scenarioResult struct {
Name string `json:"name"`
Passed bool `json:"passed"`
Summary string `json:"summary"`
Data any `json:"data,omitempty"`
}
func main() {
options := simulationOptions{}
jsonOut := ""
flag.Int64Var(&options.Seed, "seed", defaultSimulationSeed, "deterministic simulation seed")
flag.IntVar(&options.MonteCarloN, "monte-carlo-draws", 100_000, "draws for probability Monte Carlo")
flag.IntVar(&options.LongRunN, "long-run-draws", 100_000, "draws for funded RTP long run")
flag.IntVar(&options.GroupLongRunN, "group-long-run-draws", 30_000, "draws per recharge-stage group")
flag.StringVar(&jsonOut, "json-out", "", "optional path for the same JSON report printed to stdout")
flag.Parse()
if options.MonteCarloN <= 0 || options.LongRunN <= 0 || options.GroupLongRunN <= 0 {
fmt.Fprintln(os.Stderr, "all draw counts must be positive")
os.Exit(2)
}
report := runSimulation(options)
encoded, err := json.MarshalIndent(report, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "encode report: %v\n", err)
os.Exit(1)
}
printHumanSummary(report)
fmt.Println("\n--- JSON REPORT ---")
fmt.Println(string(encoded))
if jsonOut != "" {
if err := os.WriteFile(jsonOut, append(encoded, '\n'), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "write JSON report: %v\n", err)
os.Exit(1)
}
}
if !report.AllPassed {
os.Exit(1)
}
}
func runSimulation(options simulationOptions) simulationReport {
// The runtime default remains a safe 98% table. Only this simulator opts into
// the supplied image's 2650% table so the report can expose why it is unsafe.
config := imageExampleConfig(domain.DefaultLuckyGiftStrategyConfig())
nominal := calculateNominalEV(config)
scenarios := []scenarioResult{
simulateImage800Redraw(config),
simulatePWBoundaries(config),
simulateFundSplitAndColdStart(config),
simulateBatchCounts(config, options.Seed),
simulateSettlementWagerWindow(),
simulateWaterBoundaries(config),
simulateRechargeWindowBoundaries(config),
simulateProbabilityMonteCarlo(config, options.Seed+10, options.MonteCarloN),
simulateFundedRTPLongRun(config, options.Seed+20, options.LongRunN),
simulateMechanismOneTruthTable(config, options.Seed+30),
simulateMechanismTwo(config, options.Seed+40),
simulateJackpotSet(config, options.Seed+50),
simulateDailyJackpotLimit(config, options.Seed+60),
simulateSixRiskCapacities(config),
simulateRechargeStages(config),
simulateCombinationPriority(config, options.Seed+70),
simulateConcurrencyAndIdempotency(config, options.Seed+80),
simulateLongRunGroups(config, options.Seed+90, options.GroupLongRunN),
}
allPassed := !nominal.PassesTarget // 2650% must be explicitly rejected, not relabelled as a 98% pass.
for _, scenario := range scenarios {
allPassed = allPassed && scenario.Passed
}
return simulationReport{
Seed: options.Seed, MonteCarloDraws: options.MonteCarloN, LongRunDraws: options.LongRunN,
GroupLongRunDraws: options.GroupLongRunN, Nominal: nominal, Scenarios: scenarios, AllPassed: allPassed,
}
}
func calculateNominalEV(config domain.StrategyConfig) nominalEVReport {
var expected float64
for _, tier := range config.Tiers {
if tier.Enabled {
expected += float64(tier.BaseWeightPPM) / float64(domain.StrategyPPMScale) * float64(tier.MultiplierPPM) / float64(domain.StrategyPPMScale)
}
}
return nominalEVReport{
ExpectedMultiplier: expected, NominalRTPPercent: expected * 100, TargetRTPPercent: 98,
PassesTarget: expected <= 0.98,
Conclusion: "图示固定概率表的名义EV为26.5x2650%不能作为98% RTP方案只有P/W资金约束后的有资金长跑口径可验证不透支。",
}
}
func printHumanSummary(report simulationReport) {
fmt.Printf("幸运礼物策略模拟 seed=%d\n", report.Seed)
fmt.Printf("名义概率表: EV=%.2fx, RTP=%.2f%%, 98%%校验=%v预期必须为false\n", report.Nominal.ExpectedMultiplier, report.Nominal.NominalRTPPercent, report.Nominal.PassesTarget)
for _, scenario := range report.Scenarios {
status := "PASS"
if !scenario.Passed {
status = "FAIL"
}
fmt.Printf("[%s] %s: %s\n", status, scenario.Name, scenario.Summary)
}
fmt.Printf("总结果: all_passed=%v\n", report.AllPassed)
}

View File

@ -0,0 +1,763 @@
package main
import (
"fmt"
"math"
"sort"
"sync"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
)
type scriptedRandom struct {
indexes []int64
bounds []int64
position int
}
// imageExampleConfig is deliberately local to strategy-sim. Its ordinary weights
// have 26.5x EV and therefore must never be exposed as the domain's runtime default.
func imageExampleConfig(config domain.StrategyConfig) domain.StrategyConfig {
config.Tiers = []domain.StrategyTier{
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true},
{ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: 200_000, Enabled: true},
{ID: "50x", MultiplierPPM: 50_000_000, BaseWeightPPM: 150_000, Enabled: true},
{ID: "200x", MultiplierPPM: 200_000_000, BaseWeightPPM: 40_000, Jackpot: true, JackpotWeight: 4, Enabled: true},
{ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Jackpot: true, JackpotWeight: 2, Enabled: true},
{ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 10_000, Jackpot: true, JackpotWeight: 1, Enabled: true},
}
return config
}
func (r *scriptedRandom) Int63n(n int64) (int64, error) {
if r.position >= len(r.indexes) {
return 0, fmt.Errorf("scripted random exhausted at bound %d", n)
}
if len(r.bounds) > r.position && r.bounds[r.position] != n {
return 0, fmt.Errorf("scripted random call %d bound=%d want=%d", r.position, n, r.bounds[r.position])
}
value := r.indexes[r.position]
r.position++
return value, nil
}
// baseProbabilityConfig isolates the image's ordinary probability table. Each
// dynamic/special rule has a separate scenario, so scripted intervals remain exact.
func baseProbabilityConfig(config domain.StrategyConfig) domain.StrategyConfig {
config.LowWaterThresholdCoins = 0
config.HighWaterThresholdCoins = math.MaxInt64
config.LowWaterFactorPPM = domain.StrategyPPMScale
config.HighWaterFactorPPM = domain.StrategyPPMScale
config.RechargeFactorPPM = domain.StrategyPPMScale
config.JackpotMechanism1Enabled = false
config.JackpotMechanism2Enabled = false
config.DailyJackpotLimit = math.MaxInt64
config.MilestoneSpendCoins = 0
return config
}
func failedScenario(name string, err error) scenarioResult {
return scenarioResult{Name: name, Passed: false, Summary: err.Error()}
}
func simulateImage800Redraw(config domain.StrategyConfig) scenarioResult {
const name = "图示800重抽序列"
config = baseProbabilityConfig(config)
random := &scriptedRandom{indexes: []int64{950_000, 359_999, 0}, bounds: []int64{1_000_000, 360_000, 350_000}}
decision, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 800}, domain.StrategyInput{GiftPriceCoins: 10}, random)
if err != nil {
return failedScenario(name, err)
}
sequence := make([]string, 0, len(decision.Trace.Draws))
for _, draw := range decision.Trace.Draws {
sequence = append(sequence, draw.TierID)
}
passed := decision.PayoutCoins == 50 && decision.PoolAfterCoins == 750 && fmt.Sprint(sequence) == "[200x 1000x 5x]"
return scenarioResult{
Name: name, Passed: passed,
Summary: fmt.Sprintf("P=800固定脚本命中%s最终赔付=%d池后=%d", sequence, decision.PayoutCoins, decision.PoolAfterCoins),
Data: map[string]any{"sequence": sequence, "trace": decision.Trace},
}
}
func simulatePWBoundaries(config domain.StrategyConfig) scenarioResult {
const name = "P=W边界/候选穷尽"
config = baseProbabilityConfig(config)
equal, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 2_000}, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{950_000}, bounds: []int64{1_000_000}})
if err != nil {
return failedScenario(name, err)
}
exhausted, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 49}, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{999_999, 350_000, 349_999, 0}, bounds: []int64{1_000_000, 390_000, 350_000, 200_000}})
if err != nil {
return failedScenario(name, err)
}
passed := equal.PayoutCoins == 2_000 && equal.PoolAfterCoins == 0 && exhausted.PayoutCoins == 0 && exhausted.Trace.FinalReason == domain.StrategyReasonNoPayableTier && exhausted.PoolAfterCoins == 49
return scenarioResult{
Name: name, Passed: passed,
Summary: fmt.Sprintf("W=P赔付%d且池归零P=49时正奖候选穷尽安全回0池仍%d", equal.PayoutCoins, exhausted.PoolAfterCoins),
Data: map[string]any{"equality": equal.Trace, "exhaustion": exhausted.Trace},
}
}
func simulateFundSplitAndColdStart(config domain.StrategyConfig) scenarioResult {
const name = "拆账与冷启动"
split, err := domain.SplitLuckyGiftStrategyFunds(100, config)
if err != nil {
return failedScenario(name, err)
}
initial := domain.InitialLuckyGiftStrategyState(config)
small, err := domain.SplitLuckyGiftStrategyFunds(10, config)
if err != nil {
return failedScenario(name, err)
}
conserved := split.PublicPoolCoins+split.ProfitPoolCoins+split.AnchorReturnCoins == 100
passed := split.PublicPoolCoins == 98 && split.ProfitPoolCoins == 1 && split.AnchorReturnCoins == 1 && small.PublicPoolCoins == 9 && small.ProfitPoolCoins == 1 && small.AnchorReturnCoins == 0 && conserved && initial.PoolBalanceCoins == 1_000_000
return scenarioResult{
Name: name, Passed: passed,
Summary: fmt.Sprintf("100金币拆为%d/%d/%d10金币按公共池floor、余数归盈利拆为%d/%d/%d冷启动=%d", split.PublicPoolCoins, split.ProfitPoolCoins, split.AnchorReturnCoins, small.PublicPoolCoins, small.ProfitPoolCoins, small.AnchorReturnCoins, initial.PoolBalanceCoins),
Data: map[string]any{
"split_100": split, "split_10_rounding": small,
"rounding_policy": "public=floor, anchor=floor, residue=profit",
"conserved": conserved, "cold_start_state": initial,
},
}
}
type batchSummary struct {
Requested int `json:"requested"`
Executed int `json:"executed"`
Positive int `json:"positive"`
PayoutCoins int64 `json:"payout_coins"`
PoolAfter int64 `json:"pool_after"`
TierCounts map[string]int `json:"tier_counts"`
}
func simulateBatchCounts(config domain.StrategyConfig, seed int64) scenarioResult {
const name = "1/5/6/99批量"
config.JackpotMechanism1Enabled = false
config.JackpotMechanism2Enabled = false
config.MilestoneSpendCoins = 0
config.DailyJackpotLimit = math.MaxInt64
counts := []int{1, 5, 6, 99}
results := make([]batchSummary, 0, len(counts))
passed := true
for _, count := range counts {
state := domain.StrategyState{}
random := domain.NewSeededStrategyRandom(seed + int64(count))
summary := batchSummary{Requested: count, TierCounts: map[string]int{}}
for index := 0; index < count; index++ {
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 100, PoolContributionCoins: 98}, random)
if err != nil {
return failedScenario(name, err)
}
state = decision.NextState
summary.Executed++
summary.TierCounts[decision.SelectedTier.ID]++
summary.PayoutCoins += decision.PayoutCoins
if decision.PayoutCoins > 0 {
summary.Positive++
}
passed = passed && decision.PoolAfterCoins >= 0
}
summary.PoolAfter = state.PoolBalanceCoins
passed = passed && summary.Executed == count
results = append(results, summary)
}
return scenarioResult{Name: name, Passed: passed, Summary: "每份礼物独立推进一次状态1/5/6/99均完整执行且任一中间池余额不负", Data: results}
}
func simulateSettlementWagerWindow() scenarioResult {
const name = "真实流水结算窗口"
const (
settlementWager = int64(99)
referencePrice = int64(10)
totalSpend = int64(100)
drawCount = int64(99)
)
type window struct {
Index int64 `json:"index"`
Paid int64 `json:"paid_draws"`
Wager int64 `json:"wager_coins"`
}
// 10 抽但只有 98 流水是专门构造的反例ceil(99/10)=10 的旧抽数算法会提前关窗,真实流水算法仍允许下一抽进入窗口 1。
windows := []window{{Index: 1, Paid: 10, Wager: 98}}
drawWindows := make([]int64, 0, drawCount)
for index := int64(1); index <= drawCount; index++ {
current := &windows[len(windows)-1]
if current.Wager >= settlementWager {
windows = append(windows, window{Index: current.Index + 1})
current = &windows[len(windows)-1]
}
unitSpend := totalSpend / drawCount
if index <= totalSpend%drawCount {
unitSpend++
}
current.Paid++
current.Wager += unitSpend
drawWindows = append(drawWindows, current.Index)
}
firstCount, secondCount := 0, 0
for _, index := range drawWindows {
if index == 1 {
firstCount++
} else if index == 2 {
secondCount++
}
}
passed := len(windows) == 2 && windows[0].Paid == 11 && windows[0].Wager == 100 && windows[1].Paid == 98 && windows[1].Wager == 98 && firstCount == 1 && secondCount == 98
return scenarioResult{
Name: name, Passed: passed,
Summary: fmt.Sprintf("阈值99、参考价10、预置10抽/98流水99份共100金币后窗口流水=%d/%d子抽归属=%d/%d", windows[0].Wager, windows[1].Wager, firstCount, secondCount),
Data: map[string]any{
"settlement_window_wager": settlementWager,
"reference_price": referencePrice, "legacy_derived_draw_threshold": int64(math.Ceil(float64(settlementWager) / float64(referencePrice))),
"windows": windows, "draws_in_window_1": firstCount, "draws_in_window_2": secondCount,
},
}
}
func simulateWaterBoundaries(config domain.StrategyConfig) scenarioResult {
const name = "低中高水位边界"
config.JackpotMechanism1Enabled = false
config.JackpotMechanism2Enabled = false
type waterPoint struct {
Pool int64 `json:"pool"`
FiveWeight int64 `json:"five_x_weight_ppm"`
Factors []string `json:"factors"`
}
points := make([]waterPoint, 0, 4)
for _, pool := range []int64{9_999_999, 10_000_000, 20_000_000, 20_000_001} {
_, traces, err := domain.PreviewLuckyGiftStrategyWeights(config, domain.StrategyState{PoolBalanceCoins: pool}, domain.StrategyInput{GiftPriceCoins: 100})
if err != nil {
return failedScenario(name, err)
}
trace := strategyWeightTrace(traces, "5x")
factors := make([]string, 0, len(trace.Factors))
for _, factor := range trace.Factors {
factors = append(factors, factor.Name)
}
points = append(points, waterPoint{Pool: pool, FiveWeight: trace.AdjustedWeightPPM, Factors: factors})
}
passed := points[0].FiveWeight == 140_000 && points[1].FiveWeight == 200_000 && points[2].FiveWeight == 200_000 && points[3].FiveWeight == 260_000
return scenarioResult{Name: name, Passed: passed, Summary: "<1000万应用0.7等于1000万/2000万均为中水位>2000万应用1.3", Data: points}
}
func simulateRechargeWindowBoundaries(config domain.StrategyConfig) scenarioResult {
const name = "充值0/299999/300000ms"
config.JackpotMechanism1Enabled = false
config.JackpotMechanism2Enabled = false
const now = int64(1_000_000)
type rechargePoint struct {
AgeMS int64 `json:"age_ms"`
FiveWeight int64 `json:"five_x_weight_ppm"`
Boosted bool `json:"boosted"`
}
points := make([]rechargePoint, 0, 3)
for _, age := range []int64{0, 299_999, 300_000} {
input := domain.StrategyInput{GiftPriceCoins: 100, NowMS: now, HasRechargeFact: true, LastRechargeAtMS: now - age}
_, traces, err := domain.PreviewLuckyGiftStrategyWeights(config, domain.StrategyState{PoolBalanceCoins: 15_000_000}, input)
if err != nil {
return failedScenario(name, err)
}
trace := strategyWeightTrace(traces, "5x")
boosted := false
for _, factor := range trace.Factors {
boosted = boosted || factor.Name == "recent_recharge"
}
points = append(points, rechargePoint{AgeMS: age, FiveWeight: trace.AdjustedWeightPPM, Boosted: boosted})
}
passed := points[0].Boosted && points[1].Boosted && !points[2].Boosted && points[0].FiveWeight == 220_000 && points[2].FiveWeight == 200_000
return scenarioResult{Name: name, Passed: passed, Summary: "充值加成严格使用[0,300000ms)0和299999生效300000失效", Data: points}
}
func simulateProbabilityMonteCarlo(config domain.StrategyConfig, seed int64, draws int) scenarioResult {
const name = "概率Monte Carlo"
config = baseProbabilityConfig(config)
config.MissProtectionZeroDraws = math.MaxInt64
state := domain.StrategyState{PoolBalanceCoins: 1_000_000_000_000_000}
random := domain.NewSeededStrategyRandom(seed)
counts := map[string]int{}
var multiplierSum float64
for index := 0; index < draws; index++ {
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, random)
if err != nil {
return failedScenario(name, err)
}
state = decision.NextState
counts[decision.SelectedTier.ID]++
multiplierSum += float64(decision.SelectedTier.MultiplierPPM) / float64(domain.StrategyPPMScale)
}
expected := map[string]float64{"0x": 0.60, "5x": 0.20, "50x": 0.15, "200x": 0.04, "500x": 0, "1000x": 0.01}
empirical := map[string]float64{}
passed := true
for tier, probability := range expected {
empirical[tier] = float64(counts[tier]) / float64(draws)
// Five standard deviations makes the assertion scale with -draws instead
// of baking a tolerance that is too strict for a quick local smoke run.
tolerance := 5*math.Sqrt(probability*(1-probability)/float64(draws)) + 1/float64(draws)
passed = passed && math.Abs(empirical[tier]-probability) <= tolerance
}
empiricalEV := multiplierSum / float64(draws)
const multiplierVariance = 11_277.75 // E[X^2]=11980, E[X]^2=702.25.
evTolerance := 5 * math.Sqrt(multiplierVariance/float64(draws))
passed = passed && math.Abs(empiricalEV-26.5) <= evTolerance
return scenarioResult{
Name: name, Passed: passed,
Summary: fmt.Sprintf("%d抽经验EV=%.4fx只验证图示概率采样不声称通过98%%资金RTP", draws, empiricalEV),
Data: map[string]any{"counts": counts, "expected_probability": expected, "empirical_probability": empirical, "empirical_ev_multiplier": empiricalEV},
}
}
func simulateFundedRTPLongRun(config domain.StrategyConfig, seed int64, draws int) scenarioResult {
const name = "RTP长跑"
config.JackpotMechanism1Enabled = false
config.JackpotMechanism2Enabled = false
config.DailyJackpotLimit = math.MaxInt64
config.MilestoneSpendCoins = 0
state := domain.StrategyState{}
random := domain.NewSeededStrategyRandom(seed)
var contributed, payout int64
for index := 0; index < draws; index++ {
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 100, PoolContributionCoins: 98}, random)
if err != nil {
return failedScenario(name, err)
}
state = decision.NextState
contributed += 98
payout += decision.PayoutCoins
if state.PoolBalanceCoins < 0 {
return scenarioResult{Name: name, Passed: false, Summary: fmt.Sprintf("第%d抽资金为负", index+1)}
}
}
rtpPercent := float64(payout) / float64(draws*100) * 100
conserved := contributed-payout == state.PoolBalanceCoins
passed := conserved && payout <= contributed && rtpPercent <= 98
return scenarioResult{
Name: name, Passed: passed,
Summary: fmt.Sprintf("%d抽实付RTP=%.4f%%,投入=%d赔付=%d池余=%d", draws, rtpPercent, contributed, payout, state.PoolBalanceCoins),
Data: map[string]any{"contributed": contributed, "payout": payout, "pool_after": state.PoolBalanceCoins, "rtp_percent": rtpPercent, "fund_conserved": conserved},
}
}
func strategyWeightTrace(traces []domain.StrategyWeightTrace, tierID string) domain.StrategyWeightTrace {
for _, trace := range traces {
if trace.TierID == tierID {
return trace
}
}
return domain.StrategyWeightTrace{}
}
type mechanismOneRow struct {
GlobalPass bool `json:"global_pass"`
UserDayPass bool `json:"user_day_pass"`
User72HourPass bool `json:"user_72h_pass"`
Triggered bool `json:"triggered"`
SelectedTier string `json:"selected_tier"`
}
func simulateMechanismOneTruthTable(config domain.StrategyConfig, seed int64) scenarioResult {
const name = "大奖机制1真值表"
config = baseProbabilityConfig(config)
config.JackpotMechanism1Enabled = true
config.DailyJackpotLimit = 5
rows := make([]mechanismOneRow, 0, 8)
passed := true
rowIndex := int64(0)
for mask := 0; mask < 8; mask++ {
globalPass := mask&1 != 0
dayPass := mask&2 != 0
hour72Pass := mask&4 != 0
state := domain.StrategyState{
PoolBalanceCoins: 20_000,
GlobalRTP: rtpForTruth(globalPass, 98),
UserDayRTP: rtpForTruth(dayPass, 96),
User72HourRTP: rtpForTruth(hour72Pass, 96),
}
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+rowIndex))
if err != nil {
return failedScenario(name, err)
}
triggered := decision.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation
want := globalPass && dayPass && hour72Pass
passed = passed && triggered == want
rows = append(rows, mechanismOneRow{GlobalPass: globalPass, UserDayPass: dayPass, User72HourPass: hour72Pass, Triggered: triggered, SelectedTier: decision.SelectedTier.ID})
rowIndex++
}
// The denominator-zero row is separate from boolean “ratio above threshold” so
// the report proves an empty window never qualifies as an apparent 0% RTP.
zeroState := domain.StrategyState{
PoolBalanceCoins: 20_000, GlobalRTP: domain.StrategyRTP{},
UserDayRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
User72HourRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
}
zeroDecision, err := domain.DecideLuckyGiftStrategy(config, zeroState, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+99))
if err != nil {
return failedScenario(name, err)
}
zeroBlocked := zeroDecision.JackpotMechanism != domain.StrategyJackpotMechanismRTPCompensation
passed = passed && zeroBlocked
return scenarioResult{
Name: name, Passed: passed,
Summary: "global<=98%、用户日<=96%、用户72h<=96%三项全真才触发任一分母为0明确阻断",
Data: map[string]any{"truth_table": rows, "denominator_zero_blocked": zeroBlocked, "denominator_zero_conditions": zeroDecision.Trace.Conditions},
}
}
func rtpForTruth(pass bool, limitPercent int64) domain.StrategyRTP {
payout := limitPercent
if !pass {
payout++
}
return domain.StrategyRTP{WagerCoins: 100, PayoutCoins: payout}
}
func simulateMechanismTwo(config domain.StrategyConfig, seed int64) scenarioResult {
const name = "机制2里程碑/池不足"
config = baseProbabilityConfig(config)
config.JackpotMechanism2Enabled = true
// Simulator makes the product's USD 50 threshold explicit in its own demo unit;
// production must inject the region/version-specific USD-to-coin conversion.
config.MilestoneSpendCoins = 50
config.DailyJackpotLimit = 5
input := domain.StrategyInput{GiftPriceCoins: 10}
blocked, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 49, PendingMilestoneTokens: 1}, input, domain.NewSeededStrategyRandom(seed))
if err != nil {
return failedScenario(name, err)
}
payableState := blocked.NextState
payableState.PoolBalanceCoins = 20_000
paid, err := domain.DecideLuckyGiftStrategy(config, payableState, input, domain.NewSeededStrategyRandom(seed+1))
if err != nil {
return failedScenario(name, err)
}
crossed, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 1_000, UserDaySpendCoins: 40}, input, domain.NewSeededStrategyRandom(seed+2))
if err != nil {
return failedScenario(name, err)
}
passed := blocked.NextState.PendingMilestoneTokens == 1 && blocked.Trace.MilestoneTokenRetained && paid.ConsumedMilestoneToken && paid.NextState.PendingMilestoneTokens == 0 && crossed.NextState.PendingMilestoneTokens == 1 && !crossed.ConsumedMilestoneToken
return scenarioResult{
Name: name, Passed: passed,
Summary: fmt.Sprintf("49池余额时资格保留=%v补池后命中%s并消费40→50本抽新增token=%d供下一抽", blocked.Trace.MilestoneTokenRetained, paid.SelectedTier.ID, crossed.Trace.MilestoneTokensEarned),
Data: map[string]any{"milestone_crossings_49_to_101": domain.MilestoneTokensEarned(49, 101, 50), "blocked": blocked, "paid": paid, "earned_for_next_draw": crossed},
}
}
func simulateJackpotSet(config domain.StrategyConfig, seed int64) scenarioResult {
const name = "大奖集合"
config = baseProbabilityConfig(config)
config.JackpotMechanism2Enabled = true
config.DailyJackpotLimit = 5
all := map[string]int{}
for index := int64(0); index < 120; index++ {
decision, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 10_000, PendingMilestoneTokens: 1}, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+index))
if err != nil {
return failedScenario(name, err)
}
all[decision.SelectedTier.ID]++
}
limited := map[string]int{}
for index := int64(0); index < 40; index++ {
decision, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 4_999, PendingMilestoneTokens: 1}, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+1_000+index))
if err != nil {
return failedScenario(name, err)
}
limited[decision.SelectedTier.ID]++
}
_, has200 := all["200x"]
_, has500 := all["500x"]
_, has1000 := all["1000x"]
passed := has200 && has500 && has1000 && len(limited) == 1 && limited["200x"] == 40
return scenarioResult{
Name: name, Passed: passed,
Summary: fmt.Sprintf("池足时大奖集合覆盖200/500/1000x=%vP=4999时只可能200x=%v", has200 && has500 && has1000, limited),
Data: map[string]any{"pool_10000_counts": all, "pool_4999_counts": limited},
}
}
func simulateDailyJackpotLimit(config domain.StrategyConfig, seed int64) scenarioResult {
const name = "日5次上限"
config = baseProbabilityConfig(config)
config.JackpotMechanism2Enabled = true
config.DailyJackpotLimit = 5
state := domain.StrategyState{PoolBalanceCoins: 1_000_000}
for index := 0; index < 5; index++ {
state.PendingMilestoneTokens = 1
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+int64(index)))
if err != nil {
return failedScenario(name, err)
}
state = decision.NextState
if !decision.Jackpot {
return scenarioResult{Name: name, Passed: false, Summary: fmt.Sprintf("第%d次未按资格出大奖", index+1)}
}
}
state.PendingMilestoneTokens = 1
// Force ordinary 200x after the special path is blocked, then force 0x on the
// retry. This proves the daily ceiling also constrains accidental base jackpots.
sixth, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{950_000, 0}, bounds: []int64{1_000_000, 960_000}})
if err != nil {
return failedScenario(name, err)
}
passed := state.UserDailyJackpotWins == 5 && !sixth.Jackpot && sixth.NextState.UserDailyJackpotWins == 5 && sixth.NextState.PendingMilestoneTokens == 1
return scenarioResult{
Name: name, Passed: passed,
Summary: fmt.Sprintf("前5次大奖计数=%d第6次特殊与基础大奖均被硬阻断且资格保留", state.UserDailyJackpotWins),
Data: map[string]any{"sixth": sixth},
}
}
func simulateSixRiskCapacities(config domain.StrategyConfig) scenarioResult {
const name = "六维risk capacity代表边界"
config = baseProbabilityConfig(config)
config.Tiers = []domain.StrategyTier{
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 0, Enabled: true},
{ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: domain.StrategyPPMScale, Enabled: true},
}
type dimension struct {
name string
set func(*domain.StrategyRiskCapacity, int64)
}
dimensions := []dimension{
{name: "single_draw", set: func(c *domain.StrategyRiskCapacity, v int64) { c.SingleDrawCoins = v }},
{name: "user_hour", set: func(c *domain.StrategyRiskCapacity, v int64) { c.UserHourCoins = v }},
{name: "user_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.UserDayCoins = v }},
{name: "device_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.DeviceDayCoins = v }},
{name: "room_hour", set: func(c *domain.StrategyRiskCapacity, v int64) { c.RoomHourCoins = v }},
{name: "anchor_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.AnchorDayCoins = v }},
}
rows := map[string]map[string]any{}
passed := true
for _, item := range dimensions {
capacity := domain.StrategyRiskCapacity{Enabled: true, SingleDrawCoins: 1_000, UserHourCoins: 1_000, UserDayCoins: 1_000, DeviceDayCoins: 1_000, RoomHourCoins: 1_000, AnchorDayCoins: 1_000}
item.set(&capacity, 50)
allowed, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 50}, domain.StrategyInput{GiftPriceCoins: 10, RiskCapacity: capacity}, domain.NewSeededStrategyRandom(1))
if err != nil {
return failedScenario(name, err)
}
item.set(&capacity, 49)
blocked, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 50}, domain.StrategyInput{GiftPriceCoins: 10, RiskCapacity: capacity}, domain.NewSeededStrategyRandom(1))
if err != nil {
return failedScenario(name, err)
}
rowPass := allowed.PayoutCoins == 50 && blocked.PayoutCoins == 0 && blocked.PoolAfterCoins == 50
passed = passed && rowPass
rows[item.name] = map[string]any{"equal_capacity_payout": allowed.PayoutCoins, "below_capacity_payout": blocked.PayoutCoins, "pass": rowPass}
}
return scenarioResult{Name: name, Passed: passed, Summary: "单次/用户小时/用户日/设备日/房间小时/主播日六维均验证W=capacity可赔、W=capacity+1硬阻断", Data: rows}
}
func simulateRechargeStages(config domain.StrategyConfig) scenarioResult {
const name = "充值分层"
type row struct {
Recharge7D int64 `json:"recharge_7d"`
Recharge30D int64 `json:"recharge_30d"`
Stage string `json:"stage"`
}
inputs := [][2]int64{{0, 0}, {0, 1}, {1, 1}}
wants := []string{domain.StageNovice, domain.StageNormal, domain.StageAdvanced}
rows := make([]row, 0, len(inputs))
passed := true
for index, input := range inputs {
stage := domain.SelectLuckyGiftRechargeStage(config, input[0], input[1])
passed = passed && stage == wants[index]
rows = append(rows, row{Recharge7D: input[0], Recharge30D: input[1], Stage: stage})
}
return scenarioResult{Name: name, Passed: passed, Summary: "三阶段同时要求7日与30日显式下限选择满足条件的最高阶段", Data: map[string]any{"thresholds": config.RechargeStages, "boundary_rows": rows}}
}
func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scenarioResult {
const name = "组合优先级"
config = baseProbabilityConfig(config)
config.LowWaterThresholdCoins = 20_000
config.HighWaterThresholdCoins = 30_000
config.LowWaterFactorPPM = 700_000
config.HighWaterFactorPPM = 1_300_000
config.RechargeFactorPPM = 1_100_000
config.JackpotMechanism1Enabled = true
config.JackpotMechanism2Enabled = true
config.DailyJackpotLimit = 5
config.MilestoneSpendCoins = 50
state := domain.StrategyState{
PoolBalanceCoins: 10_000, ConsecutiveZeroDraws: 5, PendingMilestoneTokens: 1,
GlobalRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
UserDayRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
User72HourRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
}
input := domain.StrategyInput{
GiftPriceCoins: 10, NowMS: 1_000_000, HasRechargeFact: true,
LastRechargeAtMS: 700_001, Recharge7DCoins: 1, Recharge30DCoins: 1,
}
priority, err := domain.DecideLuckyGiftStrategy(config, state, input, domain.NewSeededStrategyRandom(seed))
if err != nil {
return failedScenario(name, err)
}
// When both jackpot mechanisms are eligible, the persisted token is contractual
// state and therefore wins before RTP compensation or sixth-draw protection.
priorityPass := priority.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && priority.ConsumedMilestoneToken && priority.Stage == domain.StageAdvanced
blockedState := state
blockedState.PoolBalanceCoins = 50
blocked, err := domain.DecideLuckyGiftStrategy(config, blockedState, input, domain.NewSeededStrategyRandom(seed+1))
if err != nil {
return failedScenario(name, err)
}
// Neither special mechanism may overdraw the pool. The token survives and the
// ordinary sixth-draw protection may pay only the affordable 5x boundary.
blockedPass := blocked.Trace.MilestoneTokenRetained && blocked.NextState.PendingMilestoneTokens == 1 && blocked.PayoutCoins == 50 && blocked.PoolAfterCoins == 0
return scenarioResult{
Name: name, Passed: priorityPass && blockedPass,
Summary: fmt.Sprintf("优先级=持久token>%s>普通P/W/第6抽特殊档池不足时资格保留并仅赔可支付%s", domain.StrategyJackpotMechanismRTPCompensation, blocked.SelectedTier.ID),
Data: map[string]any{"all_eligible": priority, "special_pool_blocked": blocked},
}
}
// simulationStore models the repository contract that the pure kernel expects:
// command idempotency and state locking wrap the draw in one critical section. The
// simulator does not claim an in-memory mutex is the production implementation; it
// verifies the state-machine invariants a row lock/CAS transaction must preserve.
type simulationStore struct {
mu sync.Mutex
config domain.StrategyConfig
state domain.StrategyState
random domain.StrategyRandomSource
seen map[string]domain.StrategyDecision
transitions int
}
func newSimulationStore(config domain.StrategyConfig, state domain.StrategyState, seed int64) *simulationStore {
return &simulationStore{config: config, state: state, random: domain.NewSeededStrategyRandom(seed), seen: map[string]domain.StrategyDecision{}}
}
func (s *simulationStore) execute(commandID string, input domain.StrategyInput) (domain.StrategyDecision, error) {
s.mu.Lock()
defer s.mu.Unlock()
if existing, ok := s.seen[commandID]; ok {
return existing, nil
}
decision, err := domain.DecideLuckyGiftStrategy(s.config, s.state, input, s.random)
if err != nil {
return domain.StrategyDecision{}, err
}
s.state = decision.NextState
s.seen[commandID] = decision
s.transitions++
return decision, nil
}
func simulateConcurrencyAndIdempotency(config domain.StrategyConfig, seed int64) scenarioResult {
const name = "并发/幂等模型"
config.JackpotMechanism1Enabled = false
config.JackpotMechanism2Enabled = false
config.DailyJackpotLimit = math.MaxInt64
config.MilestoneSpendCoins = 0
input := domain.StrategyInput{GiftPriceCoins: 100, PoolContributionCoins: 98}
duplicateStore := newSimulationStore(config, domain.StrategyState{}, seed)
duplicateErrors := runConcurrentCommands(duplicateStore, 32, func(int) string { return "same-command" }, input)
duplicatePass := len(duplicateErrors) == 0 && duplicateStore.transitions == 1 && duplicateStore.state.Version == 1 && len(duplicateStore.seen) == 1 && duplicateStore.state.PoolBalanceCoins >= 0
uniqueStore := newSimulationStore(config, domain.StrategyState{}, seed+1)
uniqueErrors := runConcurrentCommands(uniqueStore, 100, func(index int) string { return fmt.Sprintf("command-%03d", index) }, input)
uniquePass := len(uniqueErrors) == 0 && uniqueStore.transitions == 100 && uniqueStore.state.Version == 100 && len(uniqueStore.seen) == 100 && uniqueStore.state.PoolBalanceCoins >= 0
return scenarioResult{
Name: name, Passed: duplicatePass && uniquePass,
Summary: fmt.Sprintf("32个同幂等键仅1次状态迁移100个并发唯一键串行化为100个版本且池余=%d", uniqueStore.state.PoolBalanceCoins),
Data: map[string]any{
"duplicate": map[string]any{"requests": 32, "transitions": duplicateStore.transitions, "version": duplicateStore.state.Version, "errors": duplicateErrors},
"unique": map[string]any{"requests": 100, "transitions": uniqueStore.transitions, "version": uniqueStore.state.Version, "pool_after": uniqueStore.state.PoolBalanceCoins, "errors": uniqueErrors},
},
}
}
func runConcurrentCommands(store *simulationStore, count int, commandID func(int) string, input domain.StrategyInput) []string {
var wait sync.WaitGroup
errorsCh := make(chan string, count)
for index := 0; index < count; index++ {
wait.Add(1)
go func(index int) {
defer wait.Done()
if _, err := store.execute(commandID(index), input); err != nil {
errorsCh <- err.Error()
}
}(index)
}
wait.Wait()
close(errorsCh)
errorsFound := make([]string, 0, len(errorsCh))
for err := range errorsCh {
errorsFound = append(errorsFound, err)
}
sort.Strings(errorsFound)
return errorsFound
}
type groupLongRunSummary struct {
ExpectedStage string `json:"expected_stage"`
ObservedStage string `json:"observed_stage"`
Draws int `json:"draws"`
TierCounts map[string]int `json:"tier_counts"`
PayoutCoins int64 `json:"payout_coins"`
PoolAfter int64 `json:"pool_after"`
RTPPercent float64 `json:"rtp_percent"`
}
func simulateLongRunGroups(config domain.StrategyConfig, seed int64, draws int) scenarioResult {
const name = "长跑分群"
config.JackpotMechanism1Enabled = false
config.JackpotMechanism2Enabled = false
config.DailyJackpotLimit = math.MaxInt64
config.MilestoneSpendCoins = 0
// This grouping run uses a production-safe table (stage EV 85%/97.5%/98%),
// not the image's 2650% probability snapshot. That keeps distribution changes
// visible instead of letting P/W drain every group into the same 5x-only cycle.
config.LowWaterThresholdCoins = 0
config.HighWaterThresholdCoins = math.MaxInt64
config.LowWaterFactorPPM = domain.StrategyPPMScale
config.HighWaterFactorPPM = domain.StrategyPPMScale
config.RechargeFactorPPM = domain.StrategyPPMScale
config.MissProtectionZeroDraws = math.MaxInt64
config.Tiers = []domain.StrategyTier{
{ID: "0x", MultiplierPPM: 0, Enabled: true},
{ID: "0.5x", MultiplierPPM: 500_000, Enabled: true, StageWeightPPM: map[string]int64{domain.StageNovice: 100_000, domain.StageNormal: 50_000, domain.StageAdvanced: 40_000}},
{ID: "1x", MultiplierPPM: 1_000_000, Enabled: true, StageWeightPPM: map[string]int64{domain.StageNovice: 800_000, domain.StageNormal: 850_000, domain.StageAdvanced: 840_000}},
{ID: "2x", MultiplierPPM: 2_000_000, Enabled: true, StageWeightPPM: map[string]int64{domain.StageNovice: 0, domain.StageNormal: 50_000, domain.StageAdvanced: 60_000}},
}
groups := []struct {
stage string
recharge7D int64
recharge30D int64
}{
{stage: domain.StageNovice, recharge7D: 0, recharge30D: 0},
{stage: domain.StageNormal, recharge7D: 0, recharge30D: 1},
{stage: domain.StageAdvanced, recharge7D: 1, recharge30D: 1},
}
results := make([]groupLongRunSummary, 0, len(groups))
passed := true
for groupIndex, group := range groups {
state := domain.StrategyState{}
random := domain.NewSeededStrategyRandom(seed + int64(groupIndex))
summary := groupLongRunSummary{ExpectedStage: group.stage, Draws: draws, TierCounts: map[string]int{}}
for index := 0; index < draws; index++ {
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{
GiftPriceCoins: 100, PoolContributionCoins: 98,
Recharge7DCoins: group.recharge7D, Recharge30DCoins: group.recharge30D,
}, random)
if err != nil {
return failedScenario(name, err)
}
state = decision.NextState
summary.ObservedStage = decision.Stage
summary.TierCounts[decision.SelectedTier.ID]++
summary.PayoutCoins += decision.PayoutCoins
if state.PoolBalanceCoins < 0 {
passed = false
}
}
summary.PoolAfter = state.PoolBalanceCoins
summary.RTPPercent = float64(summary.PayoutCoins) / float64(draws*100) * 100
passed = passed && summary.ObservedStage == group.stage && summary.RTPPercent <= 98 && int64(draws*98)-summary.PayoutCoins == summary.PoolAfter
results = append(results, summary)
}
return scenarioResult{Name: name, Passed: passed, Summary: fmt.Sprintf("novice/normal/advanced各%d抽分层权重不同但三组资金RTP均<=98%%且守恒", draws), Data: results}
}

View File

@ -78,8 +78,11 @@ CREATE TABLE IF NOT EXISTS lucky_gift_rule_versions (
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
rule_version BIGINT NOT NULL COMMENT '不可变规则版本', rule_version BIGINT NOT NULL COMMENT '不可变规则版本',
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用', enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用',
strategy_version VARCHAR(32) NOT NULL DEFAULT 'fixed_v2' COMMENT 'fixed_v2/dynamic_v3旧版本固定按 fixed_v2 解释',
target_rtp_ppm BIGINT NOT NULL COMMENT '基础返奖目标ppm', target_rtp_ppm BIGINT NOT NULL COMMENT '基础返奖目标ppm',
pool_rate_ppm BIGINT NOT NULL COMMENT '每笔扣费进入基础奖池比例ppm', pool_rate_ppm BIGINT NOT NULL COMMENT '每笔扣费进入基础奖池比例ppm',
profit_rate_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '每笔扣费计入平台盈利比例ppm',
anchor_rate_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '每笔扣费计入主播收益比例ppm',
settlement_window_wager BIGINT NOT NULL COMMENT 'RTP 结算窗口流水,单位金币', settlement_window_wager BIGINT NOT NULL COMMENT 'RTP 结算窗口流水,单位金币',
control_band_ppm BIGINT NOT NULL COMMENT '正常波动带ppm', control_band_ppm BIGINT NOT NULL COMMENT '正常波动带ppm',
gift_price_reference BIGINT NOT NULL COMMENT '配置参考礼物价格', gift_price_reference BIGINT NOT NULL COMMENT '配置参考礼物价格',
@ -88,6 +91,26 @@ CREATE TABLE IF NOT EXISTS lucky_gift_rule_versions (
effective_from_ms BIGINT NOT NULL COMMENT '生效时间UTC epoch ms', effective_from_ms BIGINT NOT NULL COMMENT '生效时间UTC epoch ms',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '发布人', created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '发布人',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
initial_pool_coins BIGINT NOT NULL DEFAULT 0 COMMENT 'dynamic_v3 奖池首次物化的初始注资金币',
loss_streak_guarantee BIGINT NOT NULL DEFAULT 0 COMMENT '连续未中奖后的保底触发抽数',
low_watermark_coins BIGINT NOT NULL DEFAULT 0 COMMENT '低水位阈值金币',
low_water_nonzero_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '低水位非零奖档概率因子ppm',
high_watermark_coins BIGINT NOT NULL DEFAULT 0 COMMENT '高水位阈值金币',
high_water_nonzero_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '高水位非零奖档概率因子ppm',
recharge_boost_window_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最近充值概率加权窗口,毫秒',
recharge_boost_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '最近充值非零奖档概率因子ppm',
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 上限ppm',
jackpot_user_72h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户滚动 72 小时 RTP 上限ppm',
jackpot_spend_threshold_coins BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户消费门槛金币,由 App 显式换算 50 美元等值',
max_jackpot_hits_per_user_day BIGINT NOT NULL DEFAULT 0 COMMENT '单用户 UTC 日动态大奖命中上限',
max_single_payout BIGINT NOT NULL DEFAULT 0 COMMENT '单次返奖金币上限',
user_hourly_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT '单用户 UTC 小时返奖上限',
user_daily_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT '单用户 UTC 日返奖上限',
device_daily_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT '单设备 UTC 日返奖上限',
room_hourly_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT '单房间 UTC 小时返奖上限',
anchor_daily_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT '单主播 UTC 日返奖上限',
PRIMARY KEY (app_code, pool_id, rule_version), PRIMARY KEY (app_code, pool_id, rule_version),
KEY idx_lucky_gift_rule_versions_latest (app_code, pool_id, rule_version), KEY idx_lucky_gift_rule_versions_latest (app_code, pool_id, rule_version),
KEY idx_lucky_gift_rule_versions_enabled (app_code, enabled, created_at_ms) KEY idx_lucky_gift_rule_versions_enabled (app_code, enabled, created_at_ms)
@ -98,6 +121,8 @@ CREATE TABLE IF NOT EXISTS lucky_gift_stage_tiers (
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
rule_version BIGINT NOT NULL COMMENT '规则版本', rule_version BIGINT NOT NULL COMMENT '规则版本',
stage VARCHAR(32) NOT NULL COMMENT 'novice/normal/advanced', stage VARCHAR(32) NOT NULL COMMENT 'novice/normal/advanced',
min_recharge_7d_coins BIGINT NOT NULL DEFAULT 0 COMMENT '进入该阶段的最近 7 日充值金币门槛',
min_recharge_30d_coins BIGINT NOT NULL DEFAULT 0 COMMENT '进入该阶段的最近 30 日充值金币门槛',
tier_id VARCHAR(96) NOT NULL COMMENT '奖档 ID', tier_id VARCHAR(96) NOT NULL COMMENT '奖档 ID',
multiplier_ppm BIGINT NOT NULL COMMENT '倍率1x = 1000000', multiplier_ppm BIGINT NOT NULL COMMENT '倍率1x = 1000000',
base_weight_ppm BIGINT NOT NULL COMMENT '正常模式基础概率ppm', base_weight_ppm BIGINT NOT NULL COMMENT '正常模式基础概率ppm',
@ -110,10 +135,10 @@ CREATE TABLE IF NOT EXISTS lucky_gift_stage_tiers (
CREATE TABLE IF NOT EXISTS lucky_rtp_windows ( CREATE TABLE IF NOT EXISTS lucky_rtp_windows (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
scope_type VARCHAR(32) NOT NULL COMMENT 'global 或 gift', scope_type VARCHAR(32) NOT NULL COMMENT 'global 或 gift',
scope_id VARCHAR(96) NOT NULL COMMENT 'global 或规则作用域', scope_id VARCHAR(128) NOT NULL COMMENT '奖池+不可变规则版本作用域',
window_index BIGINT NOT NULL COMMENT 'RTP 窗口序号', window_index BIGINT NOT NULL COMMENT 'RTP 窗口序号',
target_rtp_ppm INT NOT NULL COMMENT '窗口目标 RTP', target_rtp_ppm INT NOT NULL COMMENT '窗口目标 RTP',
control_window_draws BIGINT NOT NULL COMMENT '窗口抽数', control_window_draws BIGINT NOT NULL COMMENT 'fixed_v2窗口抽数dynamic_v3仅兼容展示滚窗读取规则真实流水阈值',
paid_draws BIGINT NOT NULL DEFAULT 0 COMMENT '窗口已抽次数', paid_draws BIGINT NOT NULL DEFAULT 0 COMMENT '窗口已抽次数',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '窗口消耗金币', wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '窗口消耗金币',
target_payout_coins BIGINT NOT NULL COMMENT '窗口目标基础返奖', target_payout_coins BIGINT NOT NULL COMMENT '窗口目标基础返奖',
@ -134,11 +159,69 @@ CREATE TABLE IF NOT EXISTS lucky_pools (
reserve_floor BIGINT NOT NULL DEFAULT 0 COMMENT '安全水位', reserve_floor BIGINT NOT NULL DEFAULT 0 COMMENT '安全水位',
total_in BIGINT NOT NULL DEFAULT 0 COMMENT '累计入池', total_in BIGINT NOT NULL DEFAULT 0 COMMENT '累计入池',
total_out BIGINT NOT NULL DEFAULT 0 COMMENT '累计基础返奖支出', total_out BIGINT NOT NULL DEFAULT 0 COMMENT '累计基础返奖支出',
profit_total BIGINT NOT NULL DEFAULT 0 COMMENT 'dynamic_v3 累计平台盈利拆账金币',
anchor_income_total BIGINT NOT NULL DEFAULT 0 COMMENT 'dynamic_v3 累计主播收益拆账金币',
initial_seed_total BIGINT NOT NULL DEFAULT 0 COMMENT 'dynamic_v3 累计初始注资金币,只在首次物化时增加',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, scope_type, scope_id) PRIMARY KEY (app_code, scope_type, scope_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物单一基础奖池'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物单一基础奖池';
CREATE TABLE IF NOT EXISTS lucky_risk_counters (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
scope_type VARCHAR(32) NOT NULL COMMENT 'user/device/room/anchor',
scope_id VARCHAR(255) NOT NULL COMMENT 'pool_id+风控主体 ID容纳96字符奖池与128字符设备',
window_type VARCHAR(32) NOT NULL COMMENT 'hour/day',
bucket_key VARCHAR(32) NOT NULL COMMENT 'UTC 窗口键',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '窗口内已支出可见奖励',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, scope_type, scope_id, window_type, bucket_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物多维风控窗口计数';
CREATE TABLE IF NOT EXISTS lucky_user_rtp_hour_buckets (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
bucket_hour_ms BIGINT NOT NULL COMMENT 'UTC 小时开始时间epoch ms',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '小时内抽奖流水金币',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '小时内返奖金币',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, user_id, bucket_hour_ms),
KEY idx_lucky_user_rtp_hour_retention (app_code, bucket_hour_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户滚动72小时RTP完整小时聚合桶';
-- 小时桶负责完整小时,边界事件只用于精确补齐 [now-72h,now) 两端不足一小时的片段。
-- 这样热路径最多扫描 71 个小时桶和两个边界小时内的用户事件,不回扫全量抽奖事实。
CREATE TABLE IF NOT EXISTS lucky_user_rtp_boundary_events (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
draw_id VARCHAR(128) NOT NULL COMMENT '单抽事实 ID用于幂等',
occurred_at_ms BIGINT NOT NULL COMMENT '扣费事实时间UTC epoch ms',
wager_coins BIGINT NOT NULL COMMENT '本抽流水金币',
payout_coins BIGINT NOT NULL COMMENT '本抽已承诺返奖负债',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, user_id, draw_id),
KEY idx_lucky_user_rtp_boundary_range (app_code, pool_id, user_id, occurred_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户严格滚动72小时RTP边界事件';
CREATE TABLE IF NOT EXISTS lucky_user_strategy_days (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
stat_day DATE NOT NULL COMMENT 'UTC 自然日',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '当日抽奖流水金币',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '当日返奖金币',
spend_coins BIGINT NOT NULL DEFAULT 0 COMMENT '当日用于动态大奖资格的累计消费金币',
jackpot_hits BIGINT NOT NULL DEFAULT 0 COMMENT '当日动态大奖已命中次数',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, user_id, stat_day),
KEY idx_lucky_user_strategy_days_retention (app_code, stat_day)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户 dynamic_v3 UTC 日级消费、RTP 与大奖资格状态';
CREATE TABLE IF NOT EXISTS lucky_user_states ( CREATE TABLE IF NOT EXISTS lucky_user_states (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
user_id BIGINT NOT NULL COMMENT '用户 ID', user_id BIGINT NOT NULL COMMENT '用户 ID',
@ -147,11 +230,21 @@ CREATE TABLE IF NOT EXISTS lucky_user_states (
cumulative_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '该用户在当前奖池累计消耗金币', cumulative_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '该用户在当前奖池累计消耗金币',
equivalent_draws BIGINT NOT NULL DEFAULT 0 COMMENT '累计金币按规则参考价格折算后的等价抽数', equivalent_draws BIGINT NOT NULL DEFAULT 0 COMMENT '累计金币按规则参考价格折算后的等价抽数',
loss_streak BIGINT NOT NULL DEFAULT 0 COMMENT '连续未获得可见奖励次数,中奖后清零', loss_streak BIGINT NOT NULL DEFAULT 0 COMMENT '连续未获得可见奖励次数,中奖后清零',
pending_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '未消费大奖机会按UTC日消费产生资金不足时允许跨日保留',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, user_id, gift_id) PRIMARY KEY (app_code, user_id, gift_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户体验池状态'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户体验池状态';
-- draw 事实不存在时 SELECT ... FOR UPDATE 没有可锁记录;该锁行保证同一钱包 command_id 的
-- 首次并发请求串行执行,等待者在首事务提交后只回读原始结果,不重新随机或重复推进奖池。
CREATE TABLE IF NOT EXISTS lucky_gift_command_locks (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
command_id VARCHAR(128) NOT NULL COMMENT '钱包已扣费命令幂等 ID',
created_at_ms BIGINT NOT NULL COMMENT '首次请求时间UTC epoch ms',
PRIMARY KEY (app_code, command_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='内部幸运礼物并发幂等锁行';
CREATE TABLE IF NOT EXISTS lucky_draw_records ( CREATE TABLE IF NOT EXISTS lucky_draw_records (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
draw_id VARCHAR(128) NOT NULL COMMENT '抽奖 ID', draw_id VARCHAR(128) NOT NULL COMMENT '抽奖 ID',
@ -257,6 +350,13 @@ CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_cursors (
PRIMARY KEY (app_code, cursor_name) PRIMARY KEY (app_code, cursor_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物后台统计快照游标'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物后台统计快照游标';
CREATE TABLE IF NOT EXISTS external_lucky_gift_request_locks (
app_code VARCHAR(32) NOT NULL COMMENT '外部 App 应用编码',
request_id VARCHAR(128) NOT NULL COMMENT '外部业务幂等 ID',
created_at_ms BIGINT NOT NULL COMMENT '首次请求时间UTC epoch ms',
PRIMARY KEY (app_code, request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部幸运礼物并发幂等锁行';
CREATE TABLE IF NOT EXISTS external_lucky_gift_draws ( CREATE TABLE IF NOT EXISTS external_lucky_gift_draws (
app_code VARCHAR(32) NOT NULL COMMENT '外部 App 应用编码', app_code VARCHAR(32) NOT NULL COMMENT '外部 App 应用编码',
request_id VARCHAR(128) NOT NULL COMMENT '外部 App 业务幂等 ID', request_id VARCHAR(128) NOT NULL COMMENT '外部 App 业务幂等 ID',
@ -282,3 +382,28 @@ CREATE TABLE IF NOT EXISTS external_lucky_gift_draws (
KEY idx_external_lucky_pool (app_code, pool_id, created_at_ms), KEY idx_external_lucky_pool (app_code, pool_id, created_at_ms),
KEY idx_external_lucky_status (app_code, reward_status, created_at_ms) KEY idx_external_lucky_status (app_code, reward_status, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部 App 幸运礼物抽奖事实表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部 App 幸运礼物抽奖事实表';
CREATE TABLE IF NOT EXISTS external_lucky_gift_draw_items (
app_code VARCHAR(32) NOT NULL COMMENT '外部 App 应用编码',
request_id VARCHAR(128) NOT NULL COMMENT '聚合请求幂等 ID',
item_index INT NOT NULL COMMENT '从1开始的顺序子抽序号',
draw_id VARCHAR(128) NOT NULL COMMENT '子抽事实 ID',
command_id VARCHAR(128) NOT NULL COMMENT '稳定子命令 ID',
external_user_id VARCHAR(128) NOT NULL COMMENT '外部 App 用户 ID',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
rule_version BIGINT NOT NULL COMMENT '不可变规则版本',
unit_amount BIGINT NOT NULL COMMENT '本抽实际金额',
reward_amount BIGINT NOT NULL COMMENT '本抽奖励金额',
selected_tier_id VARCHAR(96) NOT NULL COMMENT '本抽最终奖档',
multiplier_ppm BIGINT NOT NULL COMMENT '本抽倍率',
stage VARCHAR(32) NOT NULL COMMENT '充值分层缺可信外部充值事实时为novice',
candidate_snapshot_json JSON NOT NULL COMMENT '动态权重、删除重抽及大奖条件快照',
pool_snapshot_json JSON NOT NULL COMMENT '本抽资金拆分和池快照',
rtp_snapshot_json JSON NOT NULL COMMENT '本抽RTP快照',
reward_status VARCHAR(32) NOT NULL DEFAULT 'granted' COMMENT '外部调用方自行结算',
paid_at_ms BIGINT NOT NULL COMMENT '外部扣费时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, request_id, item_index),
UNIQUE KEY uk_external_lucky_item_draw (app_code, draw_id),
KEY idx_external_lucky_item_user (app_code, external_user_id, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部dynamic_v3顺序子抽审计';

View File

@ -0,0 +1,226 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_lucky_gift;
-- 每列独立探测,兼容“曾运行旧 002、未运行旧 002、004 执行到一半”三种线上状态;
-- strategy_version 的默认值固定为 fixed_v2确保已有不可变版本不会被动态规则重新解释。
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'strategy_version') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN strategy_version VARCHAR(32) NOT NULL DEFAULT ''fixed_v2'' COMMENT ''fixed_v2/dynamic_v3旧版本固定按 fixed_v2 解释'' AFTER enabled', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'profit_rate_ppm') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN profit_rate_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''平台盈利比例ppm'' AFTER pool_rate_ppm', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'anchor_rate_ppm') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN anchor_rate_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''主播收益比例ppm'' AFTER profit_rate_ppm', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'initial_pool_coins') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN initial_pool_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''dynamic_v3 初始注资金币'' AFTER created_at_ms', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'loss_streak_guarantee') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN loss_streak_guarantee BIGINT NOT NULL DEFAULT 0 COMMENT ''连续未中奖保底抽数'' AFTER initial_pool_coins', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'low_watermark_coins') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN low_watermark_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''低水位阈值金币'' AFTER loss_streak_guarantee', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'low_water_nonzero_factor_ppm') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN low_water_nonzero_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''低水位非零概率因子ppm'' AFTER low_watermark_coins', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'high_watermark_coins') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN high_watermark_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''高水位阈值金币'' AFTER low_water_nonzero_factor_ppm', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'high_water_nonzero_factor_ppm') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN high_water_nonzero_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''高水位非零概率因子ppm'' AFTER high_watermark_coins', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'recharge_boost_window_ms') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN recharge_boost_window_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''充值加权窗口毫秒'' AFTER high_water_nonzero_factor_ppm', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'recharge_boost_factor_ppm') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN recharge_boost_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''充值加权因子ppm'' AFTER recharge_boost_window_ms', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_multiplier_ppms') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_multiplier_ppms JSON NOT NULL DEFAULT (JSON_ARRAY()) COMMENT ''动态大奖倍率列表'' AFTER recharge_boost_factor_ppm', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_global_rtp_max_ppm') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_global_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''全局大奖 RTP 上限'' AFTER jackpot_multiplier_ppms', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((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') = 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', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((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') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_user_72h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''用户滚动72小时大奖 RTP 上限'' AFTER jackpot_user_day_rtp_max_ppm', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'jackpot_spend_threshold_coins') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_spend_threshold_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''大奖消费门槛金币需显式换算50美元等值'' AFTER jackpot_user_72h_rtp_max_ppm', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'max_jackpot_hits_per_user_day') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN max_jackpot_hits_per_user_day BIGINT NOT NULL DEFAULT 0 COMMENT ''用户UTC日大奖命中上限'' AFTER jackpot_spend_threshold_coins', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'max_single_payout') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN max_single_payout BIGINT NOT NULL DEFAULT 0 COMMENT ''单次返奖上限'' AFTER max_jackpot_hits_per_user_day', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'user_hourly_payout_cap') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN user_hourly_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT ''用户UTC小时返奖上限'' AFTER max_single_payout', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'user_daily_payout_cap') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN user_daily_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT ''用户UTC日返奖上限'' AFTER user_hourly_payout_cap', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'device_daily_payout_cap') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN device_daily_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT ''设备UTC日返奖上限'' AFTER user_daily_payout_cap', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'room_hourly_payout_cap') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN room_hourly_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT ''房间UTC小时返奖上限'' AFTER device_daily_payout_cap', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'anchor_daily_payout_cap') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN anchor_daily_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT ''主播UTC日返奖上限'' AFTER room_hourly_payout_cap', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_stage_tiers' AND COLUMN_NAME = 'min_recharge_7d_coins') = 0,
'ALTER TABLE lucky_gift_stage_tiers ADD COLUMN min_recharge_7d_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''阶段最近7日充值金币门槛'' AFTER stage', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_stage_tiers' AND COLUMN_NAME = 'min_recharge_30d_coins') = 0,
'ALTER TABLE lucky_gift_stage_tiers ADD COLUMN min_recharge_30d_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''阶段最近30日充值金币门槛'' AFTER min_recharge_7d_coins', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_pools' AND COLUMN_NAME = 'profit_total') = 0,
'ALTER TABLE lucky_pools ADD COLUMN profit_total BIGINT NOT NULL DEFAULT 0 COMMENT ''累计平台盈利拆账金币'' AFTER total_out', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_pools' AND COLUMN_NAME = 'anchor_income_total') = 0,
'ALTER TABLE lucky_pools ADD COLUMN anchor_income_total BIGINT NOT NULL DEFAULT 0 COMMENT ''累计主播收益拆账金币'' AFTER profit_total', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_pools' AND COLUMN_NAME = 'initial_seed_total') = 0,
'ALTER TABLE lucky_pools ADD COLUMN initial_seed_total BIGINT NOT NULL DEFAULT 0 COMMENT ''累计初始注资金币'' AFTER anchor_income_total', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 消费门槛按 UTC 日累计,但已经赚到的机会在池不足时必须跨日保留;因此 token 属于用户/奖池状态,不能放在日表。
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_user_states' AND COLUMN_NAME = 'pending_jackpot_tokens') = 0,
'ALTER TABLE lucky_user_states ADD COLUMN pending_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT ''未消费大奖机会;资金不足时允许跨日保留'' AFTER loss_streak', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- pool_id 最长 96RTP 还拼接 :v<rule_version>,风控还拼接最长 128 的设备/房间主体,旧列宽会在合法输入上截断。
SET @ddl := IF((SELECT COALESCE(CHARACTER_MAXIMUM_LENGTH, 0) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_rtp_windows' AND COLUMN_NAME = 'scope_id') < 128,
'ALTER TABLE lucky_rtp_windows MODIFY COLUMN scope_id VARCHAR(128) NOT NULL COMMENT ''奖池+不可变规则版本作用域''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COALESCE(CHARACTER_MAXIMUM_LENGTH, 0) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_risk_counters' AND COLUMN_NAME = 'scope_id') < 255,
'ALTER TABLE lucky_risk_counters MODIFY COLUMN scope_id VARCHAR(255) NOT NULL COMMENT ''pool_id+风控主体 ID''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 三张运行状态表都是 owner 库内聚合/计数事实;热路径只锁这些小表,禁止回扫开奖结果明细计算 72h RTP 或风控额度。
CREATE TABLE IF NOT EXISTS lucky_risk_counters (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
scope_type VARCHAR(32) NOT NULL COMMENT 'user/device/room/anchor',
scope_id VARCHAR(255) NOT NULL COMMENT '风控主体 ID运行侧包含 pool 前缀',
window_type VARCHAR(32) NOT NULL COMMENT 'hour/day',
bucket_key VARCHAR(32) NOT NULL COMMENT 'UTC 窗口键',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '窗口内已支出可见奖励',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, scope_type, scope_id, window_type, bucket_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物多维风控窗口计数';
CREATE TABLE IF NOT EXISTS lucky_user_rtp_hour_buckets (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
bucket_hour_ms BIGINT NOT NULL COMMENT 'UTC 小时开始时间epoch ms',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '小时内抽奖流水金币',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '小时内返奖金币',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, user_id, bucket_hour_ms),
KEY idx_lucky_user_rtp_hour_retention (app_code, bucket_hour_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户滚动72小时RTP小时桶';
CREATE TABLE IF NOT EXISTS lucky_user_rtp_boundary_events (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
draw_id VARCHAR(128) NOT NULL COMMENT '单抽事实 ID用于幂等',
occurred_at_ms BIGINT NOT NULL COMMENT '扣费事实时间UTC epoch ms',
wager_coins BIGINT NOT NULL COMMENT '本抽流水金币',
payout_coins BIGINT NOT NULL COMMENT '本抽已承诺返奖负债',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, user_id, draw_id),
KEY idx_lucky_user_rtp_boundary_range (app_code, pool_id, user_id, occurred_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户严格滚动72小时RTP边界事件';
CREATE TABLE IF NOT EXISTS lucky_user_strategy_days (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
stat_day DATE NOT NULL COMMENT 'UTC 自然日',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '当日抽奖流水金币',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '当日返奖金币',
spend_coins BIGINT NOT NULL DEFAULT 0 COMMENT '当日大奖资格累计消费金币',
jackpot_hits BIGINT NOT NULL DEFAULT 0 COMMENT '当日大奖命中次数',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, user_id, stat_day),
KEY idx_lucky_user_strategy_days_retention (app_code, stat_day)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户dynamic_v3 UTC日级状态';
-- 旧库只有 lucky_draw_records 唯一键,两个尚无结果的首次请求仍可能同时穿过空读并让后到者失败。
-- 独立锁行先串行化原始 command_id后到请求再回读首次事务一次性提交的全部子抽事实。
CREATE TABLE IF NOT EXISTS lucky_gift_command_locks (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
command_id VARCHAR(128) NOT NULL COMMENT '钱包已扣费命令幂等 ID',
created_at_ms BIGINT NOT NULL COMMENT '首次请求时间UTC epoch ms',
PRIMARY KEY (app_code, command_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='内部幸运礼物并发幂等锁行';
CREATE TABLE IF NOT EXISTS external_lucky_gift_draw_items (
app_code VARCHAR(32) NOT NULL COMMENT '外部 App 应用编码',
request_id VARCHAR(128) NOT NULL COMMENT '聚合请求幂等 ID',
item_index INT NOT NULL COMMENT '从1开始的顺序子抽序号',
draw_id VARCHAR(128) NOT NULL COMMENT '子抽事实 ID',
command_id VARCHAR(128) NOT NULL COMMENT '稳定子命令 ID',
external_user_id VARCHAR(128) NOT NULL COMMENT '外部 App 用户 ID',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
rule_version BIGINT NOT NULL COMMENT '不可变规则版本',
unit_amount BIGINT NOT NULL COMMENT '本抽实际金额',
reward_amount BIGINT NOT NULL COMMENT '本抽奖励金额',
selected_tier_id VARCHAR(96) NOT NULL COMMENT '本抽最终奖档',
multiplier_ppm BIGINT NOT NULL COMMENT '本抽倍率',
stage VARCHAR(32) NOT NULL COMMENT '充值分层',
candidate_snapshot_json JSON NOT NULL COMMENT '动态权重、删除重抽及大奖条件快照',
pool_snapshot_json JSON NOT NULL COMMENT '本抽资金拆分和池快照',
rtp_snapshot_json JSON NOT NULL COMMENT '本抽RTP快照',
reward_status VARCHAR(32) NOT NULL DEFAULT 'granted' COMMENT '外部调用方自行结算',
paid_at_ms BIGINT NOT NULL COMMENT '外部扣费时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, request_id, item_index),
UNIQUE KEY uk_external_lucky_item_draw (app_code, draw_id),
KEY idx_external_lucky_item_user (app_code, external_user_id, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部dynamic_v3顺序子抽审计';
CREATE TABLE IF NOT EXISTS external_lucky_gift_request_locks (
app_code VARCHAR(32) NOT NULL COMMENT '外部 App 应用编码',
request_id VARCHAR(128) NOT NULL COMMENT '外部业务幂等 ID',
created_at_ms BIGINT NOT NULL COMMENT '首次请求时间UTC epoch ms',
PRIMARY KEY (app_code, request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部幸运礼物并发幂等锁行';

View File

@ -19,6 +19,10 @@ const (
StageNormal = "normal" StageNormal = "normal"
StageAdvanced = "advanced" StageAdvanced = "advanced"
// StrategyFixedV2 保留现网不可变规则的原始语义;只有显式发布 StrategyDynamicV3 才启用动态水位、充值加权和多维风控。
StrategyFixedV2 = "fixed_v2"
StrategyDynamicV3 = "dynamic_v3"
EventTypeLuckyGiftDrawn = "LuckyGiftDrawn" EventTypeLuckyGiftDrawn = "LuckyGiftDrawn"
EventTypeLuckyGiftRewardSettlement = "LuckyGiftRewardSettlement" EventTypeLuckyGiftRewardSettlement = "LuckyGiftRewardSettlement"
EventTypeExternalLuckyGiftDrawn = "ExternalLuckyGiftDrawn" EventTypeExternalLuckyGiftDrawn = "ExternalLuckyGiftDrawn"
@ -42,10 +46,15 @@ type Config struct {
PoolID string `json:"pool_id"` PoolID string `json:"pool_id"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
RuleVersion int64 `json:"rule_version"` RuleVersion int64 `json:"rule_version"`
StrategyVersion string `json:"strategy_version"`
GiftPrice int64 `json:"gift_price"` GiftPrice int64 `json:"gift_price"`
TargetRTPPPM int64 `json:"target_rtp_ppm"` TargetRTPPPM int64 `json:"target_rtp_ppm"`
PoolRatePPM int64 `json:"pool_rate_ppm"` PoolRatePPM int64 `json:"pool_rate_ppm"`
ProfitRatePPM int64 `json:"profit_rate_ppm"`
AnchorRatePPM int64 `json:"anchor_rate_ppm"`
ControlBandPPM int64 `json:"control_band_ppm"` ControlBandPPM int64 `json:"control_band_ppm"`
// SettlementWindowWager 是 dynamic_v3 结算窗口的真实金币流水阈值;不能按参考礼物价格换算成抽数。
SettlementWindowWager int64 `json:"settlement_window_wager"`
GlobalWindowDraws int64 `json:"global_window_draws"` GlobalWindowDraws int64 `json:"global_window_draws"`
GiftWindowDraws int64 `json:"gift_window_draws"` GiftWindowDraws int64 `json:"gift_window_draws"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"` NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
@ -54,7 +63,29 @@ type Config struct {
HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"` HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"`
InitialBasePool int64 `json:"initial_base_pool"` InitialBasePool int64 `json:"initial_base_pool"`
BasePoolReserve int64 `json:"base_pool_reserve"` BasePoolReserve int64 `json:"base_pool_reserve"`
InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"`
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"`
JackpotUserDayRTPMaxPPM int64 `json:"jackpot_user_day_rtp_max_ppm"`
JackpotUser72hRTPMaxPPM int64 `json:"jackpot_user_72h_rtp_max_ppm"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
MaxSinglePayout int64 `json:"max_single_payout"`
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
MultiplierPPMs []int64 `json:"multiplier_ppms"` MultiplierPPMs []int64 `json:"multiplier_ppms"`
// Stages 保留 dynamic_v3 的充值门槛Tiers 仍是 fixed_v2 候选选择使用的扁平运行结构。
Stages []RuleStage `json:"stages"`
Tiers []Tier `json:"tiers"` Tiers []Tier `json:"tiers"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"` UpdatedByAdminID int64 `json:"updated_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"` CreatedAtMS int64 `json:"created_at_ms"`
@ -74,17 +105,23 @@ type RuleTier struct {
// RuleStage 保存某个用户阶段的完整奖档概率。每个阶段的基础概率必须合计 1000000。 // RuleStage 保存某个用户阶段的完整奖档概率。每个阶段的基础概率必须合计 1000000。
type RuleStage struct { type RuleStage struct {
Stage string `json:"stage"` Stage string `json:"stage"`
MinRecharge7DCoins int64 `json:"min_recharge_7d_coins"`
MinRecharge30DCoins int64 `json:"min_recharge_30d_coins"`
Tiers []RuleTier `json:"tiers"` Tiers []RuleTier `json:"tiers"`
} }
// RuleConfig 是幸运礼物 v2 配置版本快照。比例和倍率均为 ppm业务单位转换发生在发布入口。 // RuleConfig 是幸运礼物不可变配置版本快照。fixed_v2 与 dynamic_v3 共用基础 RTP 契约,动态字段只由后者解释;
// 比例和倍率统一使用 ppm后台百分比/倍率业务单位只在发布入口转换。
type RuleConfig struct { type RuleConfig struct {
AppCode string `json:"app_code"` AppCode string `json:"app_code"`
PoolID string `json:"pool_id"` PoolID string `json:"pool_id"`
RuleVersion int64 `json:"rule_version"` RuleVersion int64 `json:"rule_version"`
StrategyVersion string `json:"strategy_version"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
TargetRTPPPM int64 `json:"target_rtp_ppm"` TargetRTPPPM int64 `json:"target_rtp_ppm"`
PoolRatePPM int64 `json:"pool_rate_ppm"` PoolRatePPM int64 `json:"pool_rate_ppm"`
ProfitRatePPM int64 `json:"profit_rate_ppm"`
AnchorRatePPM int64 `json:"anchor_rate_ppm"`
SettlementWindowWager int64 `json:"settlement_window_wager"` SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPPM int64 `json:"control_band_ppm"` ControlBandPPM int64 `json:"control_band_ppm"`
GiftPriceReference int64 `json:"gift_price_reference"` GiftPriceReference int64 `json:"gift_price_reference"`
@ -93,6 +130,26 @@ type RuleConfig struct {
EffectiveFromMS int64 `json:"effective_from_ms"` EffectiveFromMS int64 `json:"effective_from_ms"`
CreatedByAdminID int64 `json:"created_by_admin_id"` CreatedByAdminID int64 `json:"created_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"` CreatedAtMS int64 `json:"created_at_ms"`
InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"`
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"`
JackpotUserDayRTPMaxPPM int64 `json:"jackpot_user_day_rtp_max_ppm"`
JackpotUser72hRTPMaxPPM int64 `json:"jackpot_user_72h_rtp_max_ppm"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
MaxSinglePayout int64 `json:"max_single_payout"`
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
Stages []RuleStage `json:"stages"` Stages []RuleStage `json:"stages"`
} }
@ -117,6 +174,10 @@ type DrawCommand struct {
PaidAtMS int64 PaidAtMS int64
VisibleRegionID int64 VisibleRegionID int64
CountryID int64 CountryID int64
Recharge7DCoins int64
Recharge30DCoins int64
LastRechargedAtMS int64
GiftIncomeCoins int64
} }
type CheckResult struct { type CheckResult struct {
@ -126,6 +187,7 @@ type CheckResult struct {
GiftID string GiftID string
GiftPrice int64 GiftPrice int64
RuleVersion int64 RuleVersion int64
StrategyVersion string
TargetRTPPPM int64 TargetRTPPPM int64
ExperiencePool string ExperiencePool string
} }
@ -224,6 +286,8 @@ type DrawQuery struct {
type ExternalDrawCommand struct { type ExternalDrawCommand struct {
AppCode string AppCode string
ExternalUserID string ExternalUserID string
// DeviceID 是外部调用方在自己认证/签名边界内绑定的稳定设备标识dynamic_v3 必填。
DeviceID string
RequestID string RequestID string
GiftCount int64 GiftCount int64
UnitAmount int64 UnitAmount int64

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,442 @@
package luckygift
import (
"fmt"
"reflect"
"testing"
)
type scriptedStrategyRandom struct {
t *testing.T
indexes []int64
bounds []int64
position int
}
func (r *scriptedStrategyRandom) Int63n(n int64) (int64, error) {
r.t.Helper()
if r.position >= len(r.indexes) {
return 0, fmt.Errorf("scripted RNG exhausted at bound %d", n)
}
if len(r.bounds) > r.position && r.bounds[r.position] != n {
r.t.Fatalf("random call %d bound=%d want=%d", r.position, n, r.bounds[r.position])
}
value := r.indexes[r.position]
r.position++
return value, nil
}
func strategyTestConfig() StrategyConfig {
config := DefaultLuckyGiftStrategyConfig()
// The 0/5/50/200/1000 table is the supplied image's deliberately unsafe
// 2650% example. It is a test fixture only; the exported default stays at 98%.
config.Tiers = []StrategyTier{
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true},
{ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: 200_000, Enabled: true},
{ID: "50x", MultiplierPPM: 50_000_000, BaseWeightPPM: 150_000, Enabled: true},
{ID: "200x", MultiplierPPM: 200_000_000, BaseWeightPPM: 40_000, Jackpot: true, JackpotWeight: 4, Enabled: true},
{ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Jackpot: true, JackpotWeight: 2, Enabled: true},
{ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 10_000, Jackpot: true, JackpotWeight: 1, Enabled: true},
}
// Core P/W tests isolate the base distribution; dynamic factors have dedicated
// boundary tests below and must not silently move scripted intervals here.
config.LowWaterThresholdCoins = 0
config.HighWaterThresholdCoins = 1_000_000_000
config.LowWaterFactorPPM = StrategyPPMScale
config.HighWaterFactorPPM = StrategyPPMScale
config.RechargeFactorPPM = StrategyPPMScale
config.JackpotMechanism1Enabled = false
config.JackpotMechanism2Enabled = false
config.DailyJackpotLimit = 1_000_000
return config
}
func strategyTestInput(price int64) StrategyInput {
return StrategyInput{GiftPriceCoins: price}
}
func TestLuckyGiftStrategyDeletesUnaffordableTierAndZeroBeforeRedraw(t *testing.T) {
config := strategyTestConfig()
random := &scriptedStrategyRandom{
t: t,
// 950000 selects 200x from the original 1,000,000 range. After deleting
// 200x+0x, 359999 selects 1000x; the last zero selects affordable 5x.
indexes: []int64{950_000, 359_999, 0},
bounds: []int64{1_000_000, 360_000, 350_000},
}
decision, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 800}, strategyTestInput(10), random)
if err != nil {
t.Fatal(err)
}
if decision.SelectedTier.ID != "5x" || decision.PayoutCoins != 50 || decision.PoolAfterCoins != 750 {
t.Fatalf("decision=%+v", decision)
}
if got, want := decision.Trace.OriginalTierID, "200x"; got != want {
t.Fatalf("original tier=%s want=%s", got, want)
}
if got, want := decision.Trace.RedrawTierIDs, []string{"1000x", "5x"}; !reflect.DeepEqual(got, want) {
t.Fatalf("redraws=%v want=%v", got, want)
}
wantRemoved := []StrategyRemovalTrace{
{TierID: "200x", Reason: StrategyReasonPoolInsufficient},
{TierID: "0x", Reason: StrategyRemovalPWRedrawZero},
{TierID: "1000x", Reason: StrategyReasonPoolInsufficient},
}
if !reflect.DeepEqual(decision.Trace.Removed, wantRemoved) {
t.Fatalf("removed=%+v want=%+v", decision.Trace.Removed, wantRemoved)
}
}
func TestLuckyGiftStrategyPWEqualityAndCandidateExhaustion(t *testing.T) {
t.Run("W equals P is payable", func(t *testing.T) {
config := strategyTestConfig()
decision, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 2_000}, strategyTestInput(10), &scriptedStrategyRandom{t: t, indexes: []int64{950_000}, bounds: []int64{1_000_000}})
if err != nil {
t.Fatal(err)
}
if decision.SelectedTier.ID != "200x" || decision.PayoutCoins != 2_000 || decision.PoolAfterCoins != 0 {
t.Fatalf("P=W decision=%+v", decision)
}
})
t.Run("all positive tiers impossible falls back to zero", func(t *testing.T) {
config := strategyTestConfig()
random := &scriptedStrategyRandom{
t: t,
// Exhaust 1000x, 200x, 50x, then 5x. Zero is removed with the first
// failed P/W comparison, so no extra random call can hide exhaustion.
indexes: []int64{999_999, 350_000, 349_999, 0},
bounds: []int64{1_000_000, 390_000, 350_000, 200_000},
}
decision, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 49}, strategyTestInput(10), random)
if err != nil {
t.Fatal(err)
}
if decision.PayoutCoins != 0 || decision.SelectedTier.ID != "0x" || decision.Trace.FinalReason != StrategyReasonNoPayableTier {
t.Fatalf("exhausted decision=%+v", decision)
}
if decision.NextState.PoolBalanceCoins < 0 {
t.Fatalf("pool became negative: %d", decision.NextState.PoolBalanceCoins)
}
})
}
func TestLuckyGiftStrategyMissProtectionSixthDraw(t *testing.T) {
config := strategyTestConfig()
state := StrategyState{PoolBalanceCoins: 800, ConsecutiveZeroDraws: 5}
decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), &scriptedStrategyRandom{t: t, indexes: []int64{0}, bounds: []int64{400_000}})
if err != nil {
t.Fatal(err)
}
if decision.SelectedTier.ID != "5x" || decision.PayoutCoins != 50 || decision.NextState.ConsecutiveZeroDraws != 0 {
t.Fatalf("protected decision=%+v", decision)
}
if len(decision.Trace.Removed) == 0 || decision.Trace.Removed[0].Reason != StrategyRemovalMissProtection {
t.Fatalf("zero exclusion not traced: %+v", decision.Trace.Removed)
}
// Protection cannot manufacture money: when even 5x exceeds P, the strategy
// returns zero and leaves an explicit blocked reason instead of overdrawing.
blocked, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 49, ConsecutiveZeroDraws: 5}, strategyTestInput(10), &scriptedStrategyRandom{t: t})
if err != nil {
t.Fatal(err)
}
if blocked.PayoutCoins != 0 || blocked.Trace.FinalReason != StrategyReasonMissProtectionBlocked || blocked.NextState.PoolBalanceCoins != 49 {
t.Fatalf("blocked protection=%+v", blocked)
}
}
func TestLuckyGiftStrategyDynamicWeightBoundariesAndMultiplication(t *testing.T) {
config := strategyTestConfig()
config.LowWaterThresholdCoins = 1_000
config.HighWaterThresholdCoins = 2_000
config.LowWaterFactorPPM = 700_000
config.HighWaterFactorPPM = 1_300_000
config.RechargeFactorPPM = 1_100_000
config.JackpotMechanism1Enabled = false
config.JackpotMechanism2Enabled = false
tests := []struct {
name string
pool int64
rechargeAge int64
hasRecharge bool
want5x int64
wantFactorNames []string
}{
{name: "low below threshold", pool: 999, want5x: 140_000, wantFactorNames: []string{"low_water"}},
{name: "low equality is middle", pool: 1_000, want5x: 200_000, wantFactorNames: []string{"identity"}},
{name: "high equality is middle", pool: 2_000, want5x: 200_000, wantFactorNames: []string{"identity"}},
{name: "high above threshold", pool: 2_001, want5x: 260_000, wantFactorNames: []string{"high_water"}},
{name: "recharge starts inclusive and multiplies low", pool: 999, hasRecharge: true, rechargeAge: 0, want5x: 154_000, wantFactorNames: []string{"low_water", "recent_recharge"}},
{name: "recharge end minus one inclusive", pool: 1_500, hasRecharge: true, rechargeAge: 299_999, want5x: 220_000, wantFactorNames: []string{"recent_recharge"}},
{name: "recharge end exclusive", pool: 1_500, hasRecharge: true, rechargeAge: 300_000, want5x: 200_000, wantFactorNames: []string{"identity"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := strategyTestInput(10)
input.NowMS = 1_000_000
input.HasRechargeFact = tt.hasRecharge
input.LastRechargeAtMS = input.NowMS - tt.rechargeAge
_, traces, err := PreviewLuckyGiftStrategyWeights(config, StrategyState{PoolBalanceCoins: tt.pool}, input)
if err != nil {
t.Fatal(err)
}
trace := findWeightTrace(t, traces, "5x")
if trace.AdjustedWeightPPM != tt.want5x {
t.Fatalf("5x adjusted=%d want=%d full=%+v", trace.AdjustedWeightPPM, tt.want5x, trace)
}
factorNames := make([]string, 0, len(trace.Factors))
for _, factor := range trace.Factors {
factorNames = append(factorNames, factor.Name)
}
if !reflect.DeepEqual(factorNames, tt.wantFactorNames) {
t.Fatalf("factor names=%v want=%v", factorNames, tt.wantFactorNames)
}
var total int64
for _, item := range traces {
total += item.AdjustedWeightPPM
}
if total != StrategyPPMScale {
t.Fatalf("weights sum=%d", total)
}
})
}
}
func TestLuckyGiftStrategyJackpotMechanismOneRequiresAllNonZeroWindows(t *testing.T) {
config := strategyTestConfig()
config.JackpotMechanism1Enabled = true
config.DailyJackpotLimit = 5
base := StrategyState{
PoolBalanceCoins: 10_000,
GlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
UserDayRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
User72HourRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
}
tests := []struct {
name string
mutate func(*StrategyState)
wantPrize bool
}{
{name: "all inclusive boundaries pass", wantPrize: true},
{name: "global above 98", mutate: func(s *StrategyState) { s.GlobalRTP.PayoutCoins = 99 }},
{name: "day above 96", mutate: func(s *StrategyState) { s.UserDayRTP.PayoutCoins = 97 }},
{name: "72h above 96", mutate: func(s *StrategyState) { s.User72HourRTP.PayoutCoins = 97 }},
{name: "global denominator zero", mutate: func(s *StrategyState) { s.GlobalRTP = StrategyRTP{} }},
{name: "day denominator zero", mutate: func(s *StrategyState) { s.UserDayRTP = StrategyRTP{} }},
{name: "72h denominator zero", mutate: func(s *StrategyState) { s.User72HourRTP = StrategyRTP{} }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
state := base
if tt.mutate != nil {
tt.mutate(&state)
}
decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(7))
if err != nil {
t.Fatal(err)
}
got := decision.JackpotMechanism == StrategyJackpotMechanismRTPCompensation
if got != tt.wantPrize {
t.Fatalf("mechanism1=%v want=%v trace=%+v", got, tt.wantPrize, decision.Trace)
}
})
}
}
func TestLuckyGiftStrategyMilestoneTokenRetainedThenConsumed(t *testing.T) {
if got := MilestoneTokensEarned(49, 101, 50); got != 2 {
t.Fatalf("crossed tokens=%d want=2", got)
}
config := strategyTestConfig()
config.JackpotMechanism2Enabled = true
config.DailyJackpotLimit = 5
config.MilestoneSpendCoins = 50
state := StrategyState{PoolBalanceCoins: 49, PendingMilestoneTokens: 1}
blocked, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(11))
if err != nil {
t.Fatal(err)
}
if blocked.ConsumedMilestoneToken || blocked.NextState.PendingMilestoneTokens != 1 || !blocked.Trace.MilestoneTokenRetained {
t.Fatalf("blocked token decision=%+v", blocked)
}
if len(blocked.Trace.Removed) < 3 {
t.Fatalf("blocked jackpot removals=%+v want all 200/500/1000 tiers", blocked.Trace.Removed)
}
for _, removed := range blocked.Trace.Removed[:3] {
if removed.Reason != StrategyReasonPoolInsufficient {
t.Fatalf("blocked jackpot removal=%+v", removed)
}
}
state = blocked.NextState
state.PoolBalanceCoins = 10_000
paid, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(12))
if err != nil {
t.Fatal(err)
}
if !paid.ConsumedMilestoneToken || paid.NextState.PendingMilestoneTokens != 0 || !paid.Jackpot || paid.PayoutCoins <= 0 {
t.Fatalf("consumed token decision=%+v", paid)
}
// Crossing happens during finalize, after this draw has already been selected.
// The token must therefore be persisted for the next request, not consumed now.
crossingState := StrategyState{PoolBalanceCoins: 1_000, UserDaySpendCoins: 40}
crossing, err := DecideLuckyGiftStrategy(config, crossingState, strategyTestInput(10), &scriptedStrategyRandom{t: t, indexes: []int64{0}, bounds: []int64{1_000_000}})
if err != nil {
t.Fatal(err)
}
if crossing.ConsumedMilestoneToken || crossing.NextState.PendingMilestoneTokens != 1 || crossing.Trace.MilestoneTokensEarned != 1 {
t.Fatalf("crossing decision=%+v", crossing)
}
}
func TestLuckyGiftStrategyDailyJackpotLimitAlsoConstrainsBaseDraw(t *testing.T) {
config := strategyTestConfig()
config.JackpotMechanism2Enabled = true
config.DailyJackpotLimit = 5
state := StrategyState{PoolBalanceCoins: 20_000, PendingMilestoneTokens: 1, UserDailyJackpotWins: 5}
// Milestone is blocked first; ordinary draw then selects 200x, removes it for
// the same hard daily limit, removes zero, and redraws an allowed 5x.
random := &scriptedStrategyRandom{t: t, indexes: []int64{950_000, 600_000}, bounds: []int64{1_000_000, 960_000}}
decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), random)
if err != nil {
t.Fatal(err)
}
if decision.Jackpot || decision.SelectedTier.ID != "5x" || decision.NextState.PendingMilestoneTokens != 1 || decision.NextState.UserDailyJackpotWins != 5 {
t.Fatalf("daily-limit decision=%+v", decision)
}
if decision.Trace.Draws[0].RemovedReason != StrategyReasonDailyJackpotLimit {
t.Fatalf("daily-limit removal=%+v", decision.Trace.Draws)
}
}
func TestLuckyGiftStrategySixRiskCapacitiesAreHardInclusiveBounds(t *testing.T) {
config := strategyTestConfig()
config.Tiers = []StrategyTier{
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 0, Enabled: true},
{ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: StrategyPPMScale, Enabled: true},
}
setters := []struct {
name string
set func(*StrategyRiskCapacity, int64)
}{
{name: "single draw", set: func(c *StrategyRiskCapacity, v int64) { c.SingleDrawCoins = v }},
{name: "user hour", set: func(c *StrategyRiskCapacity, v int64) { c.UserHourCoins = v }},
{name: "user day", set: func(c *StrategyRiskCapacity, v int64) { c.UserDayCoins = v }},
{name: "device day", set: func(c *StrategyRiskCapacity, v int64) { c.DeviceDayCoins = v }},
{name: "room hour", set: func(c *StrategyRiskCapacity, v int64) { c.RoomHourCoins = v }},
{name: "anchor day", set: func(c *StrategyRiskCapacity, v int64) { c.AnchorDayCoins = v }},
}
for _, setter := range setters {
t.Run(setter.name, func(t *testing.T) {
capacity := StrategyRiskCapacity{Enabled: true, SingleDrawCoins: 1_000, UserHourCoins: 1_000, UserDayCoins: 1_000, DeviceDayCoins: 1_000, RoomHourCoins: 1_000, AnchorDayCoins: 1_000}
setter.set(&capacity, 50)
input := strategyTestInput(10)
input.RiskCapacity = capacity
allowed, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 50}, input, NewSeededStrategyRandom(1))
if err != nil {
t.Fatal(err)
}
if allowed.PayoutCoins != 50 || allowed.PoolAfterCoins != 0 {
t.Fatalf("boundary must pass: %+v", allowed)
}
setter.set(&capacity, 49)
input.RiskCapacity = capacity
blocked, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 50}, input, NewSeededStrategyRandom(1))
if err != nil {
t.Fatal(err)
}
if blocked.PayoutCoins != 0 || blocked.Trace.FinalReason != StrategyReasonNoPayableTier {
t.Fatalf("below boundary must block: %+v", blocked)
}
if len(blocked.Trace.Removed) != 1 || blocked.Trace.Removed[0].TierID != "5x" || blocked.Trace.Removed[0].Reason != StrategyReasonRiskCapacity {
t.Fatalf("risk retry must not remove zero: %+v", blocked.Trace.Removed)
}
})
}
}
func TestLuckyGiftStrategyRechargeStagesAndFundConservation(t *testing.T) {
config := DefaultLuckyGiftStrategyConfig()
stageCases := []struct {
seven, thirty int64
want string
}{
{seven: 0, thirty: 0, want: StageNovice},
{seven: 0, thirty: 1, want: StageNormal},
{seven: 1, thirty: 1, want: StageAdvanced},
}
for _, tt := range stageCases {
if got := SelectLuckyGiftRechargeStage(config, tt.seven, tt.thirty); got != tt.want {
t.Fatalf("stage(%d,%d)=%s want=%s", tt.seven, tt.thirty, got, tt.want)
}
}
split, err := SplitLuckyGiftStrategyFunds(100, config)
if err != nil {
t.Fatal(err)
}
if split != (StrategyFundSplit{PublicPoolCoins: 98, ProfitPoolCoins: 1, AnchorReturnCoins: 1}) {
t.Fatalf("split=%+v", split)
}
if split.PublicPoolCoins+split.ProfitPoolCoins+split.AnchorReturnCoins != 100 {
t.Fatal("fund split did not conserve money")
}
small, err := SplitLuckyGiftStrategyFunds(10, config)
if err != nil {
t.Fatal(err)
}
if small != (StrategyFundSplit{PublicPoolCoins: 9, ProfitPoolCoins: 1, AnchorReturnCoins: 0}) {
t.Fatalf("small split=%+v want public floor with residue in profit", small)
}
if initial := InitialLuckyGiftStrategyState(config); initial.PoolBalanceCoins != config.ColdStartPoolCoins || initial.PoolBalanceCoins < 0 {
t.Fatalf("initial=%+v", initial)
}
}
func TestDefaultLuckyGiftStrategyNominalEVIsExactlyNinetyEightPercent(t *testing.T) {
config := DefaultLuckyGiftStrategyConfig()
var weightedMultiplierMicros int64
for _, tier := range config.Tiers {
weightedMultiplierMicros += tier.BaseWeightPPM * tier.MultiplierPPM / StrategyPPMScale
}
if weightedMultiplierMicros != 980_000 {
t.Fatalf("default nominal EV=%dppm want=980000ppm", weightedMultiplierMicros)
}
for _, tier := range config.Tiers {
if tier.Jackpot && tier.BaseWeightPPM != 0 {
t.Fatalf("default jackpot tier %s leaked into ordinary probability", tier.ID)
}
}
}
func TestLuckyGiftStrategyCombinationPriority(t *testing.T) {
config := strategyTestConfig()
config.JackpotMechanism1Enabled = true
config.JackpotMechanism2Enabled = true
config.DailyJackpotLimit = 5
state := StrategyState{
PoolBalanceCoins: 10_000, ConsecutiveZeroDraws: 5, PendingMilestoneTokens: 1,
GlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
UserDayRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
User72HourRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
}
decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(8))
if err != nil {
t.Fatal(err)
}
if decision.JackpotMechanism != StrategyJackpotMechanismMilestone || !decision.ConsumedMilestoneToken {
t.Fatalf("milestone must outrank mechanism1/miss protection: %+v", decision)
}
}
func findWeightTrace(t *testing.T, traces []StrategyWeightTrace, tierID string) StrategyWeightTrace {
t.Helper()
for _, trace := range traces {
if trace.TierID == tierID {
return trace
}
}
t.Fatalf("weight trace %s not found", tierID)
return StrategyWeightTrace{}
}

View File

@ -13,40 +13,43 @@ const (
highTierMultiplierPPM int64 = 10_000_000 highTierMultiplierPPM int64 = 10_000_000
) )
// DefaultRuleConfig 返回 v2 配置草稿。草稿默认 disabled只有后台发布后才写入不可变版本表。 // DefaultRuleConfig 返回 dynamic_v3 配置草稿。金额型风控上限和 50 美元等值金币门槛没有跨 App 通用换算,
// 因而保留为 0 且草稿默认 disabled运营显式补齐这些值并启用后才允许发布可运行版本。
func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig { func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig {
poolID = normalizePoolID(poolID) poolID = normalizePoolID(poolID)
return domain.RuleConfig{ return domain.RuleConfig{
AppCode: appCode, AppCode: appCode,
PoolID: poolID, PoolID: poolID,
StrategyVersion: domain.StrategyDynamicV3,
Enabled: false, Enabled: false,
TargetRTPPPM: 950_000, TargetRTPPPM: 980_000,
PoolRatePPM: 960_000, PoolRatePPM: 980_000,
ProfitRatePPM: 10_000,
AnchorRatePPM: 10_000,
SettlementWindowWager: 1_000_000, SettlementWindowWager: 1_000_000,
ControlBandPPM: 10_000, ControlBandPPM: 30_000,
GiftPriceReference: 100, GiftPriceReference: 100,
InitialPoolCoins: 1_000_000,
LossStreakGuarantee: 5,
LowWatermarkCoins: 10_000_000,
LowWaterNonzeroFactorPPM: 700_000,
HighWatermarkCoins: 20_000_000,
HighWaterNonzeroFactorPPM: 1_300_000,
RechargeBoostWindowMS: 300_000,
RechargeBoostFactorPPM: 1_100_000,
JackpotMultiplierPPMs: []int64{200_000_000, 500_000_000, 1_000_000_000},
JackpotGlobalRTPMaxPPM: 980_000,
JackpotUserDayRTPMaxPPM: 960_000,
JackpotUser72hRTPMaxPPM: 960_000,
MaxJackpotHitsPerUserDay: 5,
// 用户阶段按累计金币流水折算的等价抽数推进;后台仍看到“抽数”,运行侧不会再被不同价格礼物误导。 // 用户阶段按累计金币流水折算的等价抽数推进;后台仍看到“抽数”,运行侧不会再被不同价格礼物误导。
NoviceMaxEquivalentDraws: 2_000, NoviceMaxEquivalentDraws: 2_000,
NormalMaxEquivalentDraws: 20_000, NormalMaxEquivalentDraws: 20_000,
Stages: []domain.RuleStage{ Stages: []domain.RuleStage{
defaultRuleStage(domain.StageNovice, []ruleTierSeed{ defaultRuleStage(domain.StageNovice, 0, 0, defaultSafeTierSeeds(domain.StageNovice)),
{id: "novice_none", multiplierPPM: 0, weightPPM: 100_000}, // 产品尚未给出具体充值金额时,用 (0,1) 与 (1,1) 保证三个阶段可执行且无重叠;运营可按 App 金币口径上调。
{id: "novice_0_5x", multiplierPPM: 500_000, weightPPM: 200_000}, defaultRuleStage(domain.StageNormal, 0, 1, defaultSafeTierSeeds(domain.StageNormal)),
{id: "novice_1x", multiplierPPM: 1_000_000, weightPPM: 550_000}, defaultRuleStage(domain.StageAdvanced, 1, 1, defaultSafeTierSeeds(domain.StageAdvanced)),
{id: "novice_2x", multiplierPPM: 2_000_000, weightPPM: 150_000},
}),
defaultRuleStage(domain.StageNormal, []ruleTierSeed{
{id: "normal_none", multiplierPPM: 0, weightPPM: 100_000},
{id: "normal_0_5x", multiplierPPM: 500_000, weightPPM: 200_000},
{id: "normal_1x", multiplierPPM: 1_000_000, weightPPM: 550_000},
{id: "normal_2x", multiplierPPM: 2_000_000, weightPPM: 150_000},
}),
defaultRuleStage(domain.StageAdvanced, []ruleTierSeed{
{id: "advanced_none", multiplierPPM: 0, weightPPM: 270_000},
{id: "advanced_1x", multiplierPPM: 1_000_000, weightPPM: 600_000},
{id: "advanced_2x", multiplierPPM: 2_000_000, weightPPM: 100_000},
{id: "advanced_5x", multiplierPPM: 5_000_000, weightPPM: 30_000},
}),
}, },
} }
} }
@ -57,8 +60,24 @@ type ruleTierSeed struct {
weightPPM int64 weightPPM int64
} }
func defaultRuleStage(stage string, tiers []ruleTierSeed) domain.RuleStage { // defaultSafeTierSeeds 保留明确的 0x 和少量 0.5x 小奖,并让每个阶段的静态期望精确为 98%。
out := domain.RuleStage{Stage: stage, Tiers: make([]domain.RuleTier, 0, len(tiers))} // 200x/500x/1000x 只登记为 jackpot 倍率,不在缺少产品概率时照搬图片示例塞进基础 tier。
func defaultSafeTierSeeds(stage string) []ruleTierSeed {
return []ruleTierSeed{
{id: stage + "_none", multiplierPPM: 0, weightPPM: 50_000},
{id: stage + "_0_5x", multiplierPPM: 500_000, weightPPM: 40_000},
{id: stage + "_1x", multiplierPPM: 1_000_000, weightPPM: 860_000},
{id: stage + "_2x", multiplierPPM: 2_000_000, weightPPM: 50_000},
}
}
func defaultRuleStage(stage string, minRecharge7DCoins, minRecharge30DCoins int64, tiers []ruleTierSeed) domain.RuleStage {
out := domain.RuleStage{
Stage: stage,
MinRecharge7DCoins: minRecharge7DCoins,
MinRecharge30DCoins: minRecharge30DCoins,
Tiers: make([]domain.RuleTier, 0, len(tiers)),
}
for _, tier := range tiers { for _, tier := range tiers {
out.Tiers = append(out.Tiers, domain.RuleTier{ out.Tiers = append(out.Tiers, domain.RuleTier{
Stage: stage, Stage: stage,
@ -74,6 +93,11 @@ func defaultRuleStage(stage string, tiers []ruleTierSeed) domain.RuleStage {
func normalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig { func normalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig {
config.PoolID = normalizePoolID(config.PoolID) config.PoolID = normalizePoolID(config.PoolID)
config.StrategyVersion = strings.ToLower(strings.TrimSpace(config.StrategyVersion))
if config.StrategyVersion == "" {
// 空值只可能来自升级前的客户端或旧持久行;归一为 fixed_v2 才不会让新动态校验改变历史发布语义。
config.StrategyVersion = domain.StrategyFixedV2
}
for stageIndex, stage := range config.Stages { for stageIndex, stage := range config.Stages {
stage.Stage = strings.TrimSpace(stage.Stage) stage.Stage = strings.TrimSpace(stage.Stage)
for tierIndex, tier := range stage.Tiers { for tierIndex, tier := range stage.Tiers {
@ -111,6 +135,13 @@ func validateRuleConfig(config domain.RuleConfig) error {
if config.NoviceMaxEquivalentDraws < 0 || config.NormalMaxEquivalentDraws < config.NoviceMaxEquivalentDraws { if config.NoviceMaxEquivalentDraws < 0 || config.NormalMaxEquivalentDraws < config.NoviceMaxEquivalentDraws {
return xerr.New(xerr.InvalidArgument, "equivalent draw stage thresholds are invalid") return xerr.New(xerr.InvalidArgument, "equivalent draw stage thresholds are invalid")
} }
strategyVersion := strings.ToLower(strings.TrimSpace(config.StrategyVersion))
if strategyVersion == "" {
strategyVersion = domain.StrategyFixedV2
}
if strategyVersion != domain.StrategyFixedV2 && strategyVersion != domain.StrategyDynamicV3 {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("unsupported lucky gift strategy version: %s", config.StrategyVersion))
}
stageMap := make(map[string]domain.RuleStage, len(config.Stages)) stageMap := make(map[string]domain.RuleStage, len(config.Stages))
for _, stage := range config.Stages { for _, stage := range config.Stages {
if !validRuleStage(stage.Stage) { if !validRuleStage(stage.Stage) {
@ -133,6 +164,121 @@ func validateRuleConfig(config domain.RuleConfig) error {
return err return err
} }
} }
if strategyVersion == domain.StrategyDynamicV3 {
if err := validateDynamicRuleConfig(config, stageMap); err != nil {
return err
}
}
return nil
}
// validateDynamicRuleConfig 只约束 dynamic_v3fixed_v2 的历史不可变版本即使没有这些字段也必须可以继续读取和重发。
func validateDynamicRuleConfig(config domain.RuleConfig, stages map[string]domain.RuleStage) error {
if config.ProfitRatePPM < 0 || config.AnchorRatePPM < 0 || config.PoolRatePPM+config.ProfitRatePPM+config.AnchorRatePPM != ppmScale {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 pool, profit and anchor rates must be non-negative and sum to 100%")
}
if config.InitialPoolCoins <= 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 initial pool coins must be positive")
}
if config.LossStreakGuarantee <= 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 loss streak guarantee must be positive")
}
if config.LowWatermarkCoins <= 0 || config.HighWatermarkCoins <= config.LowWatermarkCoins {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 watermarks must be positive and high watermark must exceed low watermark")
}
if config.LowWaterNonzeroFactorPPM <= 0 || config.LowWaterNonzeroFactorPPM >= ppmScale {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 low-water nonzero factor must be between 0% and 100%")
}
if config.HighWaterNonzeroFactorPPM <= ppmScale {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 high-water nonzero factor must exceed 100%")
}
if config.RechargeBoostWindowMS <= 0 || config.RechargeBoostFactorPPM <= ppmScale {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 recharge boost window must be positive and factor must exceed 100%")
}
if err := validateJackpotMultipliers(config.JackpotMultiplierPPMs); err != nil {
return err
}
if config.JackpotGlobalRTPMaxPPM <= 0 || config.JackpotGlobalRTPMaxPPM > ppmScale ||
config.JackpotUserDayRTPMaxPPM <= 0 || config.JackpotUserDayRTPMaxPPM > config.JackpotGlobalRTPMaxPPM ||
config.JackpotUser72hRTPMaxPPM <= 0 || config.JackpotUser72hRTPMaxPPM > config.JackpotGlobalRTPMaxPPM {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot RTP limits must be positive; user limits cannot exceed the global limit")
}
if config.MaxJackpotHitsPerUserDay <= 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 daily jackpot hit cap must be positive")
}
if config.JackpotSpendThresholdCoins < 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot spend threshold coins cannot be negative")
}
if err := validateDynamicPayoutCaps(config, false); err != nil {
return err
}
if err := validateDynamicStageThresholds(stages); err != nil {
return err
}
if !config.Enabled {
// disabled 草稿允许金额口径仍为 0启用前必须由各 App 按自身金币汇率和风控预算显式补齐。
return nil
}
if config.JackpotSpendThresholdCoins <= 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot spend threshold coins must be configured to the App's USD 50 equivalent coin amount before enabling")
}
return validateDynamicPayoutCaps(config, true)
}
func validateJackpotMultipliers(multiplierPPMs []int64) error {
if len(multiplierPPMs) == 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 must configure at least one jackpot multiplier")
}
var previous int64
for _, multiplierPPM := range multiplierPPMs {
if multiplierPPM <= ppmScale || multiplierPPM <= previous {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot multipliers must exceed 1x and be strictly increasing")
}
previous = multiplierPPM
}
return nil
}
func validateDynamicStageThresholds(stages map[string]domain.RuleStage) error {
novice := stages[domain.StageNovice]
normal := stages[domain.StageNormal]
advanced := stages[domain.StageAdvanced]
if novice.MinRecharge7DCoins != 0 || novice.MinRecharge30DCoins != 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 novice recharge thresholds must be 0/0")
}
if normal.MinRecharge7DCoins < 0 || normal.MinRecharge30DCoins < 0 || advanced.MinRecharge7DCoins < 0 || advanced.MinRecharge30DCoins < 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 recharge thresholds cannot be negative")
}
if normal.MinRecharge7DCoins == 0 && normal.MinRecharge30DCoins == 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 normal stage must exceed novice in at least one recharge threshold")
}
if advanced.MinRecharge7DCoins < normal.MinRecharge7DCoins || advanced.MinRecharge30DCoins < normal.MinRecharge30DCoins ||
(advanced.MinRecharge7DCoins == normal.MinRecharge7DCoins && advanced.MinRecharge30DCoins == normal.MinRecharge30DCoins) {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 advanced recharge thresholds must be component-wise monotonic and exceed normal in at least one dimension")
}
return nil
}
func validateDynamicPayoutCaps(config domain.RuleConfig, requirePositive bool) error {
caps := []struct {
name string
value int64
}{
{name: "max_single_payout", value: config.MaxSinglePayout},
{name: "user_hourly_payout_cap", value: config.UserHourlyPayoutCap},
{name: "user_daily_payout_cap", value: config.UserDailyPayoutCap},
{name: "device_daily_payout_cap", value: config.DeviceDailyPayoutCap},
{name: "room_hourly_payout_cap", value: config.RoomHourlyPayoutCap},
{name: "anchor_daily_payout_cap", value: config.AnchorDailyPayoutCap},
}
for _, cap := range caps {
if cap.value < 0 || (requirePositive && cap.value == 0) {
if !requirePositive {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("dynamic_v3 %s cannot be negative", cap.name))
}
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("dynamic_v3 %s must be positive before enabling", cap.name))
}
}
return nil return nil
} }

View File

@ -0,0 +1,116 @@
package luckygift
import (
"strings"
"testing"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
)
func TestDefaultRuleConfigUsesSafeDynamicV3Draft(t *testing.T) {
config := DefaultRuleConfig("lalu", "lucky")
if config.StrategyVersion != domain.StrategyDynamicV3 || config.Enabled {
t.Fatalf("default strategy=%q enabled=%v, want disabled dynamic_v3 draft", config.StrategyVersion, config.Enabled)
}
if config.TargetRTPPPM != 980_000 || config.PoolRatePPM != 980_000 || config.ProfitRatePPM != 10_000 || config.AnchorRatePPM != 10_000 {
t.Fatalf("default RTP/fund split mismatch: %+v", config)
}
if config.ControlBandPPM != 30_000 || config.InitialPoolCoins != 1_000_000 || config.LossStreakGuarantee != 5 {
t.Fatalf("default control values mismatch: %+v", config)
}
if config.LowWatermarkCoins != 10_000_000 || config.LowWaterNonzeroFactorPPM != 700_000 ||
config.HighWatermarkCoins != 20_000_000 || config.HighWaterNonzeroFactorPPM != 1_300_000 {
t.Fatalf("default water controls mismatch: %+v", config)
}
if config.RechargeBoostWindowMS != 300_000 || config.RechargeBoostFactorPPM != 1_100_000 {
t.Fatalf("default recharge boost mismatch: %+v", config)
}
if config.JackpotGlobalRTPMaxPPM != 980_000 || config.JackpotUserDayRTPMaxPPM != 960_000 ||
config.JackpotUser72hRTPMaxPPM != 960_000 || config.MaxJackpotHitsPerUserDay != 5 {
t.Fatalf("default jackpot controls mismatch: %+v", config)
}
if config.JackpotSpendThresholdCoins != 0 || config.MaxSinglePayout != 0 || config.AnchorDailyPayoutCap != 0 {
t.Fatalf("App-specific monetary limits must stay unset in disabled draft: %+v", config)
}
wantThresholds := [][2]int64{{0, 0}, {0, 1}, {1, 1}}
for index, stage := range config.Stages {
if got := [2]int64{stage.MinRecharge7DCoins, stage.MinRecharge30DCoins}; got != wantThresholds[index] {
t.Fatalf("stage %s thresholds=%v, want %v", stage.Stage, got, wantThresholds[index])
}
var expectedRTPPPM int64
for _, tier := range stage.Tiers {
if tier.Enabled {
expectedRTPPPM += tier.MultiplierPPM * tier.BaseWeightPPM / ppmScale
}
}
if expectedRTPPPM != 980_000 {
t.Fatalf("stage %s static expected RTP=%d, want safe 980000", stage.Stage, expectedRTPPPM)
}
}
if err := validateRuleConfig(config); err != nil {
t.Fatalf("disabled dynamic_v3 draft should validate before App-specific monetary limits are known: %v", err)
}
}
func TestValidateDynamicV3RequiresExplicitMonetaryRiskLimitsWhenEnabled(t *testing.T) {
config := DefaultRuleConfig("lalu", "lucky")
config.Enabled = true
if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), "USD 50 equivalent") {
t.Fatalf("enabled draft error=%v, want explicit USD 50 equivalent coin threshold guidance", err)
}
config.JackpotSpendThresholdCoins = 5_000
config.MaxSinglePayout = 10_000
config.UserHourlyPayoutCap = 20_000
config.UserDailyPayoutCap = 30_000
config.DeviceDailyPayoutCap = 40_000
config.RoomHourlyPayoutCap = 50_000
config.AnchorDailyPayoutCap = 60_000
if err := validateRuleConfig(config); err != nil {
t.Fatalf("fully configured dynamic_v3 should validate: %v", err)
}
config.RoomHourlyPayoutCap = 0
if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), "room_hourly_payout_cap") {
t.Fatalf("zero six-dimensional cap error=%v, want the missing cap name", err)
}
}
func TestValidateDynamicV3RechargeStagesAreStrictlyMonotonic(t *testing.T) {
config := DefaultRuleConfig("lalu", "lucky")
config.Stages[2].MinRecharge7DCoins = config.Stages[1].MinRecharge7DCoins
config.Stages[2].MinRecharge30DCoins = config.Stages[1].MinRecharge30DCoins
if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), "exceed normal") {
t.Fatalf("equal normal/advanced thresholds error=%v, want strict stage separation", err)
}
}
func TestValidateRuleConfigKeepsLegacyFixedV2Compatible(t *testing.T) {
config := DefaultRuleConfig("lalu", "lucky")
config.StrategyVersion = ""
config.Enabled = true
// 升级前请求没有任何 dynamic_v3 字段;空 strategy_version 必须按 fixed_v2 解释,不能要求 50 美元门槛或六维 cap。
config.ProfitRatePPM = 0
config.AnchorRatePPM = 0
config.InitialPoolCoins = 0
config.LossStreakGuarantee = 0
config.LowWatermarkCoins = 0
config.LowWaterNonzeroFactorPPM = 0
config.HighWatermarkCoins = 0
config.HighWaterNonzeroFactorPPM = 0
config.RechargeBoostWindowMS = 0
config.RechargeBoostFactorPPM = 0
config.JackpotMultiplierPPMs = nil
config.JackpotGlobalRTPMaxPPM = 0
config.JackpotUserDayRTPMaxPPM = 0
config.JackpotUser72hRTPMaxPPM = 0
config.MaxJackpotHitsPerUserDay = 0
if err := validateRuleConfig(config); err != nil {
t.Fatalf("legacy fixed_v2 config was broken by dynamic validation: %v", err)
}
normalized := normalizeRuleConfig(config)
if normalized.StrategyVersion != domain.StrategyFixedV2 {
t.Fatalf("legacy empty strategy normalized to %q, want fixed_v2", normalized.StrategyVersion)
}
}

View File

@ -43,6 +43,51 @@ func TestNormalizeDrawCommandRejectsOversizedInternalGiftWork(t *testing.T) {
if _, err := svc.normalizeDrawCommand(oversizedID); err == nil { if _, err := svc.normalizeDrawCommand(oversizedID); err == nil {
t.Fatal("command_id above 128 bytes was accepted") t.Fatal("command_id above 128 bytes was accepted")
} }
normalized, err := svc.normalizeDrawCommand(base)
if err != nil {
t.Fatalf("normalize valid command: %v", err)
}
if normalized.PaidAtMS != 0 {
t.Fatalf("service layer invented wallet paid_at_ms: %d", normalized.PaidAtMS)
}
}
func TestNormalizeDrawCommandKeepsLegacyMissingDeviceForRuleAwareValidation(t *testing.T) {
svc := &Service{}
normalized, err := svc.normalizeDrawCommand(domain.DrawCommand{
CommandID: "cmd-fixed-legacy-device", UserID: 1, TargetUserID: 2,
RoomID: "room-1", AnchorID: "2", GiftID: "rose", GiftCount: 1, CoinSpent: 10,
})
if err != nil || normalized.DeviceID != "" {
// normalize 尚未读到不可变规则;此处拒绝会破坏 fixed_v2 存量 JWT/command log 重放。
// 真正的 dynamic_v3 空设备拒绝在 MySQL 规则事务入口完成。
t.Fatalf("legacy missing device normalization got=%+v err=%v", normalized, err)
}
}
func TestNormalizeExternalDrawCommandRejectsMoreThan999SequentialDraws(t *testing.T) {
svc := &Service{}
_, err := svc.normalizeExternalDrawCommand(domain.ExternalDrawCommand{
AppCode: "aslan", ExternalUserID: "u-1", RequestID: "req-1",
GiftCount: 1_000, UnitAmount: 1, TotalAmount: 1_000, Currency: "COIN", PaidAtMS: 1,
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("external gift_count=1000 should be rejected, got %v", err)
}
}
func TestNormalizeExternalDrawCommandDoesNotInventPaidAt(t *testing.T) {
svc := &Service{}
normalized, err := svc.normalizeExternalDrawCommand(domain.ExternalDrawCommand{
AppCode: "aslan", ExternalUserID: "u-1", RequestID: "req-paid-at",
GiftCount: 1, UnitAmount: 10, TotalAmount: 10, Currency: "COIN",
})
if err != nil {
t.Fatalf("normalize external draw: %v", err)
}
if normalized.PaidAtMS != 0 {
t.Fatalf("service layer invented external paid_at_ms: %d", normalized.PaidAtMS)
}
} }
func TestDrawBatchRejectsAggregateWorkAcrossTargets(t *testing.T) { func TestDrawBatchRejectsAggregateWorkAcrossTargets(t *testing.T) {

View File

@ -168,6 +168,7 @@ func (s *Service) ExecuteExternalDraw(ctx context.Context, cmd domain.ExternalDr
func (s *Service) normalizeExternalDrawCommand(cmd domain.ExternalDrawCommand) (domain.ExternalDrawCommand, error) { func (s *Service) normalizeExternalDrawCommand(cmd domain.ExternalDrawCommand) (domain.ExternalDrawCommand, error) {
cmd.AppCode = appcode.Normalize(cmd.AppCode) cmd.AppCode = appcode.Normalize(cmd.AppCode)
cmd.ExternalUserID = strings.TrimSpace(cmd.ExternalUserID) cmd.ExternalUserID = strings.TrimSpace(cmd.ExternalUserID)
cmd.DeviceID = strings.TrimSpace(cmd.DeviceID)
cmd.RequestID = strings.TrimSpace(cmd.RequestID) cmd.RequestID = strings.TrimSpace(cmd.RequestID)
cmd.Currency = strings.ToUpper(strings.TrimSpace(cmd.Currency)) cmd.Currency = strings.ToUpper(strings.TrimSpace(cmd.Currency))
cmd.MetadataJSON = strings.TrimSpace(cmd.MetadataJSON) cmd.MetadataJSON = strings.TrimSpace(cmd.MetadataJSON)
@ -175,15 +176,17 @@ func (s *Service) normalizeExternalDrawCommand(cmd domain.ExternalDrawCommand) (
if cmd.AppCode == "" || cmd.ExternalUserID == "" || cmd.RequestID == "" { if cmd.AppCode == "" || cmd.ExternalUserID == "" || cmd.RequestID == "" {
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift identity is incomplete") return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift identity is incomplete")
} }
if len(cmd.AppCode) > 32 || len(cmd.ExternalUserID) > 128 || len(cmd.RequestID) > 128 || len(cmd.PoolID) > 96 { if len(cmd.AppCode) > 32 || len(cmd.ExternalUserID) > 128 || len(cmd.DeviceID) > 128 || len(cmd.RequestID) > 128 || len(cmd.PoolID) > 96 {
// 外部请求的 app 级业务键会进入唯一键和审计列;长度先在服务层失败,避免事务中途暴露 MySQL truncation/too long 错误。 // 外部请求的 app 级业务键会进入唯一键和审计列;长度先在服务层失败,避免事务中途暴露 MySQL truncation/too long 错误。
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift identity is too long") return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift identity is too long")
} }
if cmd.GiftCount <= 0 || cmd.UnitAmount <= 0 || cmd.TotalAmount <= 0 { if cmd.GiftCount <= 0 || cmd.UnitAmount <= 0 || cmd.TotalAmount <= 0 {
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift amount must be positive") return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift amount must be positive")
} }
if cmd.GiftCount > int64(^uint32(0)>>1) { // dynamic_v3 必须为每个礼物保存一条顺序子抽事实;与内部送礼统一限制 999
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift count is too large") // 避免外部请求用一个超大 count 制造不可控事务和审计行数。
if cmd.GiftCount > 999 {
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift count cannot exceed 999")
} }
if cmd.GiftCount > (1<<63-1)/cmd.UnitAmount || cmd.GiftCount*cmd.UnitAmount != cmd.TotalAmount { if cmd.GiftCount > (1<<63-1)/cmd.UnitAmount || cmd.GiftCount*cmd.UnitAmount != cmd.TotalAmount {
// 金额校验必须在 owner service 再做一遍gateway 是外部入口,不能成为唯一可信校验点。 // 金额校验必须在 owner service 再做一遍gateway 是外部入口,不能成为唯一可信校验点。
@ -202,10 +205,8 @@ func (s *Service) normalizeExternalDrawCommand(cmd domain.ExternalDrawCommand) (
if cmd.MetadataJSON != "" && !json.Valid([]byte(cmd.MetadataJSON)) { if cmd.MetadataJSON != "" && !json.Valid([]byte(cmd.MetadataJSON)) {
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift metadata_json is invalid") return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift metadata_json is invalid")
} }
if cmd.PaidAtMS <= 0 { // paid_at_ms 是否可兼容缺失取决于不可变规则版本service 层保留原值repository
// paid_at_ms 表示外部 App 扣费完成时间;缺失时用 UTC 服务端时间兜底,不能使用本地时区。 // 读取 fixed_v2 后才可使用 nowMSdynamic_v3 内外调用都必须提供真实扣费时间。
cmd.PaidAtMS = s.now().UTC().UnixMilli()
}
return cmd, nil return cmd, nil
} }
@ -217,9 +218,14 @@ func (s *Service) normalizeDrawCommand(cmd domain.DrawCommand) (domain.DrawComma
cmd.DeviceID = strings.TrimSpace(cmd.DeviceID) cmd.DeviceID = strings.TrimSpace(cmd.DeviceID)
cmd.RoomID = strings.TrimSpace(cmd.RoomID) cmd.RoomID = strings.TrimSpace(cmd.RoomID)
cmd.AnchorID = strings.TrimSpace(cmd.AnchorID) cmd.AnchorID = strings.TrimSpace(cmd.AnchorID)
if !giftlimits.ValidCommandID(cmd.CommandID) || cmd.UserID <= 0 || cmd.DeviceID == "" || cmd.RoomID == "" || cmd.AnchorID == "" || cmd.GiftID == "" || cmd.CoinSpent <= 0 { if !giftlimits.ValidCommandID(cmd.CommandID) || cmd.UserID <= 0 || cmd.RoomID == "" || cmd.AnchorID == "" || cmd.GiftID == "" || cmd.CoinSpent <= 0 {
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw command is incomplete") return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw command is incomplete")
} }
if len(cmd.DeviceID) > 128 {
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw device_id is too long")
}
// 这一层不能把空 device_id 一概拒绝:存量 fixed_v2 请求/恢复命令没有该字段。
// 仓储读到不可变规则后才能对 dynamic_v3 做权威 fail-close期间不用 session/command 兜底。
if cmd.TargetUserID < 0 { if cmd.TargetUserID < 0 {
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid") return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid")
} }
@ -228,10 +234,8 @@ func (s *Service) normalizeDrawCommand(cmd domain.DrawCommand) (domain.DrawComma
// The cap prevents a direct internal caller from allocating unbounded per-unit RNG/write work. // The cap prevents a direct internal caller from allocating unbounded per-unit RNG/write work.
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift count exceeds command limit") return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift count exceeds command limit")
} }
if cmd.PaidAtMS <= 0 { // 内部 wallet paid_at_ms 不能在 service 层用当前时钟补齐repository 必须先读取策略,
// paid_at_ms 是扣费事实时间,缺失时用 UTC 服务端时间补齐,避免本地时区污染 RTP 窗口。 // fixed_v2 才允许兼容降级dynamic_v3 则严格拒绝缺失 owner 事实。
cmd.PaidAtMS = s.now().UTC().UnixMilli()
}
return cmd, nil return cmd, nil
} }

View File

@ -48,9 +48,16 @@ func externalLuckyScopeID(appCode string) string {
return externalLuckyHashScope("external", appcode.Normalize(appCode)) return externalLuckyHashScope("external", appcode.Normalize(appCode))
} }
func externalLuckyDeviceID(appCode string, externalUserID string) string { func externalLuckyDeviceID(appCode string, deviceID string) string {
// 外部用户 ID 最长可到 128 字符hash 后写入审计字段,避免长字符串碰到内部设备字段长度边界。 // 外部设备 ID 最长可到 128 字符hash 后写入内部审计/风控字段,
return externalLuckyHashScope("external_device", appcode.Normalize(appCode), externalUserID) // 既保持同 App+同设备的稳定 scope也不把外部原始标识暴露到 draw 事实。
return externalLuckyHashScope("external_device", appcode.Normalize(appCode), strings.TrimSpace(deviceID))
}
func externalLuckyFixedCompatibleDeviceID(cmd domain.ExternalDrawCommand) string {
// fixed_v2 不解释新 device_id始终保留历史 app+external_user_id 作用域。
// 只有 dynamic_v3 才使用调用方绑定的设备,避免滚动升级改写 fixed 抽奖审计语义。
return externalLuckyDeviceID(cmd.AppCode, cmd.ExternalUserID)
} }
func externalLuckyAnchorID(appCode string) string { func externalLuckyAnchorID(appCode string) string {
@ -75,6 +82,9 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext
} }
defer func() { _ = tx.Rollback() }() defer func() { _ = tx.Rollback() }()
if err := r.lockExternalLuckyGiftRequest(ctx, tx, cmd.AppCode, cmd.RequestID, nowMS); err != nil {
return domain.ExternalDrawResult{}, err
}
if existing, ok, err := r.getExternalGiftDrawForUpdate(ctx, tx, cmd.AppCode, cmd.RequestID); err != nil || ok { if existing, ok, err := r.getExternalGiftDrawForUpdate(ctx, tx, cmd.AppCode, cmd.RequestID); err != nil || ok {
if err != nil { if err != nil {
return domain.ExternalDrawResult{}, err return domain.ExternalDrawResult{}, err
@ -88,6 +98,16 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext
if err != nil { if err != nil {
return domain.ExternalDrawResult{}, err return domain.ExternalDrawResult{}, err
} }
if exists && rule.StrategyVersion == domain.StrategyDynamicV3 {
if cmd.PaidAtMS <= 0 {
// 外部 dynamic_v3 同样只能消费调用方已完成扣费的事实时间;请求到达 lucky 的时间
// 不是支付时间不能用于充值短窗、UTC 日/小时或精确 72h 风控归属。
return domain.ExternalDrawResult{}, xerr.New(xerr.InvalidArgument, "external dynamic lucky gift paid_at_ms is required")
}
} else if cmd.PaidAtMS <= 0 {
// 未配置规则和历史 fixed_v2 保留滚动发布兼容dynamic_v3 永远不会进入这条降级。
cmd.PaidAtMS = nowMS
}
selected := externalDrawCandidate{TierID: "disabled", MultiplierPPM: 0} selected := externalDrawCandidate{TierID: "disabled", MultiplierPPM: 0}
rewardAmount := int64(0) rewardAmount := int64(0)
ruleVersion := rule.RuleVersion ruleVersion := rule.RuleVersion
@ -159,7 +179,27 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext
return externalDrawResultFromRow(row), nil return externalDrawResultFromRow(row), nil
} }
// lockExternalLuckyGiftRequest 为“尚不存在结果行”的并发幂等补上可锁主键。
// SELECT ... FOR UPDATE 无法锁住不存在的 external draw先幂等建锁行后第二个同 request_id 请求会等待首事务提交,再读回同一结果。
func (r *Repository) lockExternalLuckyGiftRequest(ctx context.Context, tx *sql.Tx, appCode, requestID string, nowMS int64) error {
if _, err := tx.ExecContext(ctx, `
INSERT INTO external_lucky_gift_request_locks (app_code, request_id, created_at_ms)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE created_at_ms = created_at_ms`, appCode, requestID, nowMS,
); err != nil {
return err
}
var lockedRequestID string
return tx.QueryRowContext(ctx, `
SELECT request_id FROM external_lucky_gift_request_locks
WHERE app_code = ? AND request_id = ? FOR UPDATE`, appCode, requestID,
).Scan(&lockedRequestID)
}
func (r *Repository) executeExternalGiftEconomy(ctx context.Context, tx *sql.Tx, cmd domain.ExternalDrawCommand, rule domain.RuleConfig, nowMS int64) (externalDrawCandidate, int64, error) { func (r *Repository) executeExternalGiftEconomy(ctx context.Context, tx *sql.Tx, cmd domain.ExternalDrawCommand, rule domain.RuleConfig, nowMS int64) (externalDrawCandidate, int64, error) {
if rule.StrategyVersion == domain.StrategyDynamicV3 {
return r.executeExternalDynamicGiftEconomy(ctx, tx, cmd, rule, nowMS)
}
baseConfig, err := luckyRuntimeConfigFromRuleConfig(rule) baseConfig, err := luckyRuntimeConfigFromRuleConfig(rule)
if err != nil { if err != nil {
return externalDrawCandidate{}, 0, err return externalDrawCandidate{}, 0, err
@ -172,7 +212,7 @@ func (r *Repository) executeExternalGiftEconomy(ctx context.Context, tx *sql.Tx,
CommandID: "external:" + cmd.AppCode + ":" + cmd.RequestID, CommandID: "external:" + cmd.AppCode + ":" + cmd.RequestID,
PoolID: cmd.PoolID, PoolID: cmd.PoolID,
UserID: externalUserID, UserID: externalUserID,
DeviceID: externalLuckyDeviceID(cmd.AppCode, cmd.ExternalUserID), DeviceID: externalLuckyFixedCompatibleDeviceID(cmd),
RoomID: roomScopeID, RoomID: roomScopeID,
AnchorID: externalLuckyAnchorID(cmd.AppCode), AnchorID: externalLuckyAnchorID(cmd.AppCode),
GiftID: config.GiftID, GiftID: config.GiftID,
@ -218,6 +258,66 @@ func (r *Repository) executeExternalGiftEconomy(ctx context.Context, tx *sql.Tx,
return externalDrawCandidate{TierID: candidate.TierID, MultiplierPPM: candidate.MultiplierPPM}, candidate.effectiveReward(), nil return externalDrawCandidate{TierID: candidate.TierID, MultiplierPPM: candidate.MultiplierPPM}, candidate.effectiveReward(), nil
} }
// executeExternalDynamicGiftEconomy 让外部 App 复用与房间送礼完全相同的 dynamic_v3 内核。
// 外部调用方自己负责余额和主播收益结算,因此本服务按配置拆账、返回聚合 reward且只写 external 子抽审计,绝不生成 HyApp wallet outbox。
func (r *Repository) executeExternalDynamicGiftEconomy(ctx context.Context, tx *sql.Tx, cmd domain.ExternalDrawCommand, rule domain.RuleConfig, nowMS int64) (externalDrawCandidate, int64, error) {
trustedDeviceID, err := normalizeLuckyDynamicDeviceID(cmd.DeviceID)
if err != nil {
// luck-gateway 目前无法独立验证外部设备dynamic_v3 至少强制调用方提供经其认证/签名绑定的稳定值。
// 缺失时不得用 external_user_id/request_id 伪造设备 scope。
return externalDrawCandidate{}, 0, err
}
config, err := luckyRuntimeConfigFromRuleConfig(rule)
if err != nil {
return externalDrawCandidate{}, 0, err
}
strategyConfig := luckyDynamicStrategyConfig(config)
split, err := domain.SplitLuckyGiftStrategyFunds(cmd.TotalAmount, strategyConfig)
if err != nil {
return externalDrawCandidate{}, 0, err
}
externalUserID := externalLuckyUserID(cmd.AppCode, cmd.ExternalUserID)
drawCommand := domain.DrawCommand{
CommandID: "external:" + cmd.AppCode + ":" + cmd.RequestID,
PoolID: config.PoolID,
UserID: externalUserID,
DeviceID: externalLuckyDeviceID(cmd.AppCode, trustedDeviceID),
RoomID: externalLuckyScopeID(cmd.AppCode),
AnchorID: externalLuckyAnchorID(cmd.AppCode),
GiftID: config.GiftID,
GiftCount: int32(cmd.GiftCount),
CoinSpent: cmd.TotalAmount,
PaidAtMS: cmd.PaidAtMS,
GiftIncomeCoins: split.AnchorReturnCoins,
}
// 当前外部协议没有可验证的充值 owner 快照;保持 0/0 会落 novice 且关闭五分钟加成,
// 比从 metadata 或客户端自报字段猜测更安全。后续若扩协议,必须把签名覆盖的充值事实显式入契约。
ctx = appcode.WithContext(ctx, cmd.AppCode)
results, err := r.executeDynamicLuckyGiftDrawBatch(ctx, tx, drawCommand, int32(cmd.GiftCount), rule, luckyDynamicPersistence{
ExternalRequestID: cmd.RequestID,
ExternalUserID: cmd.ExternalUserID,
}, nowMS)
if err != nil {
return externalDrawCandidate{}, 0, err
}
var reward int64
for _, result := range results {
if result.EffectiveRewardCoins < 0 || reward > int64(^uint64(0)>>1)-result.EffectiveRewardCoins {
return externalDrawCandidate{}, 0, xerr.New(xerr.Internal, "external dynamic lucky gift reward overflow")
}
reward += result.EffectiveRewardCoins
}
candidate := externalDrawCandidate{TierID: "dynamic_v3_batch"}
if len(results) == 1 {
candidate.TierID = results[0].SelectedTierID
candidate.MultiplierPPM = results[0].MultiplierPPM
} else if ratio, valid := (domain.StrategyRTP{WagerCoins: cmd.TotalAmount, PayoutCoins: reward}).RatioPPM(); valid {
// 批量可能命中多个不同倍率;聚合 multiplier 仅表达 reward/total逐抽真实倍率保存在 item 表。
candidate.MultiplierPPM = ratio
}
return candidate, reward, nil
}
func (r *Repository) applyExternalLuckyEconomy(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, cmd domain.DrawCommand, candidate luckyCandidate, globalWindow luckyRTPWindow, giftWindow luckyRTPWindow, basePool luckyPool, nextCumulativeWager int64, nextEquivalentDraws int64, nowMS int64) error { func (r *Repository) applyExternalLuckyEconomy(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, cmd domain.DrawCommand, candidate luckyCandidate, globalWindow luckyRTPWindow, giftWindow luckyRTPWindow, basePool luckyPool, nextCumulativeWager int64, nextEquivalentDraws int64, nowMS int64) error {
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
UPDATE lucky_rtp_windows UPDATE lucky_rtp_windows

View File

@ -0,0 +1,455 @@
package mysql
import (
"context"
"database/sql"
"fmt"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/xerr"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
)
type luckyDynamicBatchState struct {
PublicIn int64
ProfitIn int64
AnchorIn int64
Payout int64
DrawIDs []string
Records []luckyDrawRecordInput
Results []domain.DrawResult
BasePool luckyPool
GlobalWindow luckyRTPWindow
GiftWindow luckyRTPWindow
// *Windows 保存本批已经滚满的窗口;当前窗口单独保留,事务尾统一按绝对快照写回。
GlobalWindows []luckyRTPWindow
GiftWindows []luckyRTPWindow
UserState luckyUserState
RiskCounters map[string]luckyDynamicRiskCounter
DayState luckyUserStrategyDay
HourState luckyUserRTPHour
Strategy domain.StrategyState
}
// luckyDynamicPersistence 把内部钱包结算与外部 App 自行结算区分开。
// 外部模式仍推进同一套奖池/RTP/风控状态并保留 N 条子抽审计,但不能生成 HyApp wallet 返奖 outbox。
type luckyDynamicPersistence struct {
ExternalRequestID string
ExternalUserID string
}
func (p luckyDynamicPersistence) external() bool {
return p.ExternalRequestID != "" || p.ExternalUserID != ""
}
// executeDynamicLuckyGiftDrawBatch 在已经锁定规则、完成 command_id 幂等探测后执行 dynamic_v3。
// 所有共享行只锁一次gift_count=N 在内存里严格推进 N 次,事务尾再批量落 draw facts 和聚合钱包 outbox。
func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, drawCount int32, rule domain.RuleConfig, persistence luckyDynamicPersistence, nowMS int64) ([]domain.DrawResult, error) {
if cmd.PaidAtMS <= 0 {
// dynamic_v3 的充值后五分钟、UTC 日/小时和精确 72h 都必须归属 wallet owner 的扣费事实时间。
// 外部调用也必须传它自己的扣费事实;缺失时绝不能用 lucky 收到请求或 room saga/recovery 时钟补齐。
return nil, xerr.New(xerr.InvalidArgument, "dynamic lucky gift paid_at_ms is required")
}
// 设备日额度是 dynamic_v3 的硬风控;只有显式的可信 device_id 才能进入事务状态。
// 不允许用 session_id/command_id 填充,否则换 token 或换命令即可重置设备日上限。
deviceID, err := normalizeLuckyDynamicDeviceID(cmd.DeviceID)
if err != nil {
return nil, err
}
cmd.DeviceID = deviceID
appCode := appcode.FromContext(ctx)
config, err := luckyRuntimeConfigFromRuleConfig(rule)
if err != nil {
return nil, err
}
if !luckyUsesDynamicStrategy(config) {
return nil, xerr.New(xerr.Internal, "dynamic lucky gift path received a fixed strategy")
}
strategyConfig := luckyDynamicStrategyConfig(config)
referencePrice := config.GiftPrice
userState, err := r.getLuckyUserStateForUpdate(ctx, tx, appCode, cmd.UserID, config.GiftID, nowMS)
if err != nil {
return nil, err
}
windowScopeID := luckyRuleWindowScopeID(config)
globalWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool", windowScopeID, config.SettlementWindowWager, config.GlobalWindowDraws, config.TargetRTPPPM, nowMS)
if err != nil {
return nil, err
}
giftWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool_gift", windowScopeID, config.SettlementWindowWager, config.GiftWindowDraws, config.TargetRTPPPM, nowMS)
if err != nil {
return nil, err
}
basePool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, luckyBasePoolScopeType, luckyBasePoolScopeID(config), config.InitialPoolCoins, 0, nowMS)
if err != nil {
return nil, err
}
riskCounters, err := r.getLuckyDynamicRiskCounters(ctx, tx, appCode, config.PoolID, cmd, nowMS)
if err != nil {
return nil, err
}
dayState, err := r.getOrCreateLuckyUserStrategyDay(ctx, tx, appCode, config.PoolID, cmd.UserID, cmd, nowMS)
if err != nil {
return nil, err
}
hourState, err := r.getOrCreateLuckyUserRTPHour(ctx, tx, appCode, config.PoolID, cmd.UserID, cmd, nowMS)
if err != nil {
return nil, err
}
rolling72, err := r.getLuckyUserRolling72HourRTP(ctx, tx, appCode, config.PoolID, cmd.UserID, luckyPaidTime(cmd, nowMS).UnixMilli())
if err != nil {
return nil, err
}
fundSplit, err := luckyDynamicFundSplit(cmd.CoinSpent, cmd.GiftIncomeCoins, strategyConfig)
if err != nil {
return nil, err
}
unitSplits, err := luckyDynamicUnitFundSplits(cmd.CoinSpent, drawCount, fundSplit)
if err != nil {
return nil, err
}
state := luckyDynamicBatchState{
DrawIDs: make([]string, 0, drawCount),
Records: make([]luckyDrawRecordInput, 0, drawCount),
Results: make([]domain.DrawResult, 0, drawCount),
BasePool: basePool,
GlobalWindow: globalWindow,
GiftWindow: giftWindow,
GlobalWindows: make([]luckyRTPWindow, 0, 2),
GiftWindows: make([]luckyRTPWindow, 0, 2),
UserState: userState,
RiskCounters: riskCounters,
DayState: dayState,
HourState: hourState,
Strategy: domain.StrategyState{
PoolBalanceCoins: basePool.Balance,
ConsecutiveZeroDraws: userState.LossStreak,
PendingMilestoneTokens: userState.PendingJackpotTokens,
UserDailyJackpotWins: dayState.JackpotHits,
UserDaySpendCoins: dayState.SpendCoins,
GlobalRTP: domain.StrategyRTP{WagerCoins: globalWindow.WagerCoins, PayoutCoins: globalWindow.ActualPayoutCoins},
UserDayRTP: domain.StrategyRTP{WagerCoins: dayState.WagerCoins, PayoutCoins: dayState.PayoutCoins},
User72HourRTP: rolling72,
Version: userState.PaidDraws,
},
}
for index := int32(1); index <= drawCount; index++ {
// gift_count=N 的每一份都必须像独立命令一样跨 RTP 窗口;若上一抽正好填满,
// 本抽先创建下一窗口并让 mechanism1 使用新窗口 RTP不能等整批结束后才滚动。
if err := r.rollLuckyDynamicWindows(ctx, tx, appCode, config.SettlementWindowWager, &state, nowMS); err != nil {
return nil, err
}
unitCommand := cmd
unitCommand.CommandID = luckyDrawSubCommandID(cmd.CommandID, index, drawCount)
unitCommand.GiftCount = 1
unitCommand.CoinSpent = luckyDrawUnitSpend(cmd.CoinSpent, drawCount, index)
unitSplit := unitSplits[index-1]
unitCommand.GiftIncomeCoins = unitSplit.AnchorReturnCoins
if unitCommand.CoinSpent <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "dynamic lucky gift unit spend must be positive")
}
if unitSplit.PublicPoolCoins+unitSplit.ProfitPoolCoins+unitSplit.AnchorReturnCoins != unitCommand.CoinSpent {
return nil, xerr.New(xerr.Internal, "dynamic lucky gift unit fund split is not conserved")
}
decision, err := domain.DecideLuckyGiftStrategy(strategyConfig, state.Strategy, domain.StrategyInput{
GiftPriceCoins: unitCommand.CoinSpent,
PoolContributionCoins: unitSplit.PublicPoolCoins,
NowMS: luckyPaidTime(unitCommand, nowMS).UnixMilli(),
HasRechargeFact: unitCommand.LastRechargedAtMS > 0,
LastRechargeAtMS: unitCommand.LastRechargedAtMS,
Recharge7DCoins: unitCommand.Recharge7DCoins,
Recharge30DCoins: unitCommand.Recharge30DCoins,
RiskCapacity: luckyDynamicRiskCapacity(config, state.RiskCounters),
}, luckyCryptoStrategyRandom{})
if err != nil {
return nil, err
}
if err := r.stageLuckyDynamicDecision(appCode, config, referencePrice, unitCommand, unitSplit, decision, nowMS, &state); err != nil {
return nil, err
}
}
if persistence.external() {
if persistence.ExternalRequestID == "" || persistence.ExternalUserID == "" {
return nil, xerr.New(xerr.InvalidArgument, "external dynamic lucky gift persistence identity is incomplete")
}
for index := range state.Records {
state.Records[index].RewardStatus = domain.StatusGranted
state.Results[index].RewardStatus = domain.StatusGranted
}
}
if err := r.persistLuckyDynamicBatch(ctx, tx, appCode, cmd, config, state, persistence, nowMS); err != nil {
return nil, err
}
return state.Results, nil
}
func normalizeLuckyDynamicDeviceID(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", xerr.New(xerr.InvalidArgument, "dynamic lucky gift device_id is required")
}
if len(value) > 128 {
return "", xerr.New(xerr.InvalidArgument, "dynamic lucky gift device_id is too long")
}
return value, nil
}
func (r *Repository) rollLuckyDynamicWindows(ctx context.Context, tx *sql.Tx, appCode string, controlWager int64, state *luckyDynamicBatchState, nowMS int64) error {
if controlWager <= 0 {
return xerr.New(xerr.Internal, "dynamic lucky gift RTP window wager must be positive")
}
// 图中“结算窗口流水”是金币阈值,不是抽数。整笔单抽归属进入时所在窗口;本抽把流水推过阈值后,下一抽才开启新窗口,避免拆分一次中奖事实。
if state.GlobalWindow.WagerCoins >= controlWager {
closed := state.GlobalWindow
closed.Status = "closed"
state.GlobalWindows = append(state.GlobalWindows, closed)
next, err := r.createLuckyRTPWindow(
ctx, tx, appCode, closed.ScopeType, closed.ScopeID, closed.WindowIndex+1,
closed.CarryPPM, closed.ControlDraws, closed.TargetRTPPPM, nowMS,
)
if err != nil {
return err
}
state.GlobalWindow = next
// 大奖机制一只看当前结算窗口;窗口滚动后分母为 0必须先积累新样本不能沿用旧窗口资格。
state.Strategy.GlobalRTP = domain.StrategyRTP{
WagerCoins: next.WagerCoins,
PayoutCoins: next.ActualPayoutCoins,
}
}
if state.GiftWindow.WagerCoins >= controlWager {
closed := state.GiftWindow
closed.Status = "closed"
state.GiftWindows = append(state.GiftWindows, closed)
next, err := r.createLuckyRTPWindow(
ctx, tx, appCode, closed.ScopeType, closed.ScopeID, closed.WindowIndex+1,
closed.CarryPPM, closed.ControlDraws, closed.TargetRTPPPM, nowMS,
)
if err != nil {
return err
}
state.GiftWindow = next
}
return nil
}
func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Config, referencePrice int64, cmd domain.DrawCommand, split domain.StrategyFundSplit, decision domain.StrategyDecision, nowMS int64, state *luckyDynamicBatchState) error {
state.PublicIn += split.PublicPoolCoins
state.ProfitIn += split.ProfitPoolCoins
state.AnchorIn += split.AnchorReturnCoins
state.Payout += decision.PayoutCoins
state.Strategy = decision.NextState
state.BasePool.Balance = decision.PoolAfterCoins
state.BasePool.TotalIn += split.PublicPoolCoins
state.BasePool.TotalOut += decision.PayoutCoins
state.GlobalWindow = state.GlobalWindow.withCurrentWager(cmd.CoinSpent)
state.GlobalWindow.PaidDraws++
state.GlobalWindow.ActualPayoutCoins += decision.PayoutCoins
state.GiftWindow = state.GiftWindow.withCurrentWager(cmd.CoinSpent)
state.GiftWindow.PaidDraws++
state.GiftWindow.ActualPayoutCoins += decision.PayoutCoins
state.UserState.PaidDraws++
state.UserState.CumulativeWagerCoins += cmd.CoinSpent
state.UserState.EquivalentDraws = luckyEquivalentDraws(state.UserState.CumulativeWagerCoins, referencePrice)
state.UserState.LossStreak = decision.NextState.ConsecutiveZeroDraws
state.UserState.PendingJackpotTokens = decision.NextState.PendingMilestoneTokens
state.DayState.WagerCoins = decision.NextState.UserDayRTP.WagerCoins
state.DayState.PayoutCoins = decision.NextState.UserDayRTP.PayoutCoins
state.DayState.SpendCoins = decision.NextState.UserDaySpendCoins
state.DayState.JackpotHits = decision.NextState.UserDailyJackpotWins
state.HourState.WagerCoins += cmd.CoinSpent
state.HourState.PayoutCoins += decision.PayoutCoins
for key, counter := range state.RiskCounters {
counter.Payout += decision.PayoutCoins
state.RiskCounters[key] = counter
}
candidate := luckyCandidate{
TierID: decision.SelectedTier.ID,
Pool: decision.Stage,
Weight: luckyDynamicSelectedWeight(decision),
BaseReward: decision.PayoutCoins,
MultiplierPPM: decision.SelectedTier.MultiplierPPM,
HighMultiplier: decision.Jackpot || decision.SelectedTier.MultiplierPPM >= 10_000_000,
}
drawID := idgen.New("lucky_draw")
rewardStatus := luckyInitialRewardStatus(candidate, false)
limited := luckyDynamicLimitedReasons(decision.Trace)
decisionCopy := decision
poolSnapshot := state.BasePool
globalSnapshot := state.GlobalWindow
giftSnapshot := state.GiftWindow
record := luckyDrawRecordInput{
AppCode: appCode,
DrawID: drawID,
Command: cmd,
Config: config,
ExperiencePool: decision.Stage,
Candidate: candidate,
Limited: limited,
GlobalWindow: globalSnapshot,
GiftWindow: giftSnapshot,
BasePool: poolSnapshot,
StrategyDecision: &decisionCopy,
FundSplit: split,
UserDayRTP: decision.NextState.UserDayRTP,
User72HourRTP: decision.NextState.User72HourRTP,
RewardStatus: rewardStatus,
NowMS: nowMS,
}
state.DrawIDs = append(state.DrawIDs, drawID)
state.Records = append(state.Records, record)
globalRTP, _ := decision.NextState.GlobalRTP.RatioPPM()
giftRTP := luckyRTPPPM(giftSnapshot.WagerCoins, giftSnapshot.ActualPayoutCoins)
state.Results = append(state.Results, domain.DrawResult{
DrawID: drawID,
CommandID: cmd.CommandID,
PoolID: config.PoolID,
GiftID: cmd.GiftID,
RuleVersion: config.RuleVersion,
ExperiencePool: decision.Stage,
SelectedTierID: candidate.TierID,
MultiplierPPM: candidate.MultiplierPPM,
BaseRewardCoins: candidate.BaseReward,
EffectiveRewardCoins: candidate.effectiveReward(),
RewardStatus: rewardStatus,
RTPWindowIndex: globalSnapshot.WindowIndex,
GiftRTPWindowIndex: giftSnapshot.WindowIndex,
GlobalBaseRTPPPM: globalRTP,
GiftBaseRTPPPM: giftRTP,
StageFeedback: decision.Trace.FinalReason == domain.StrategyReasonPaid && decision.Trace.OriginalTierID == "",
HighMultiplier: candidate.HighMultiplier,
CreatedAtMS: nowMS,
})
return nil
}
func (r *Repository) persistLuckyDynamicBatch(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, config domain.Config, state luckyDynamicBatchState, persistence luckyDynamicPersistence, nowMS int64) error {
if err := validateLuckyDynamicBatchState(state, cmd.CoinSpent); err != nil {
return err
}
globalWindows := append(append([]luckyRTPWindow(nil), state.GlobalWindows...), state.GlobalWindow)
giftWindows := append(append([]luckyRTPWindow(nil), state.GiftWindows...), state.GiftWindow)
for _, window := range append(globalWindows, giftWindows...) {
if _, err := tx.ExecContext(ctx, `
UPDATE lucky_rtp_windows
SET paid_draws = ?, wager_coins = ?, target_payout_coins = ?,
actual_base_payout_coins = ?, carry_ppm = ?, status = ?, updated_at_ms = ?
WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`,
window.PaidDraws, window.WagerCoins, window.TargetPayoutCoins, window.ActualPayoutCoins, window.CarryPPM, window.Status, nowMS,
appCode, window.ScopeType, window.ScopeID, window.WindowIndex,
); err != nil {
return err
}
}
if err := r.updateLuckyDynamicPoolNet(ctx, tx, appCode, luckyBasePoolScopeID(config), state.PublicIn, state.Payout, state.ProfitIn, state.AnchorIn, nowMS); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
UPDATE lucky_user_states
SET paid_draws = ?, cumulative_wager_coins = ?, equivalent_draws = ?, loss_streak = ?,
pending_jackpot_tokens = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND gift_id = ?`,
state.UserState.PaidDraws, state.UserState.CumulativeWagerCoins, state.UserState.EquivalentDraws, state.UserState.LossStreak,
state.UserState.PendingJackpotTokens, nowMS, appCode, cmd.UserID, config.GiftID,
); err != nil {
return err
}
if err := r.addLuckyDynamicRiskPayout(ctx, tx, appCode, state.RiskCounters, state.Payout, nowMS); err != nil {
return err
}
if err := r.persistLuckyUserStrategyDay(ctx, tx, appCode, config.PoolID, cmd.UserID, state.DayState, nowMS); err != nil {
return err
}
if err := r.persistLuckyUserRTPHour(ctx, tx, appCode, config.PoolID, cmd.UserID, state.HourState, nowMS); err != nil {
return err
}
if err := r.insertLuckyUserRTPBoundaryEvents(ctx, tx, appCode, config.PoolID, cmd.UserID, state.Records, nowMS); err != nil {
return err
}
if persistence.external() {
return r.insertExternalLuckyGiftDrawItems(ctx, tx, appCode, persistence, state.Records, nowMS)
}
if err := r.insertLuckyDrawRecords(ctx, tx, state.Records); err != nil {
return err
}
if state.Payout <= 0 {
return nil
}
aggregate := luckyAggregateDrawResults(cmd, state.Results)
legacyState := luckyBatchDrawState{DrawIDs: state.DrawIDs, AggregateResult: aggregate, NeedsRewardSettlement: true}
return r.insertLuckyAggregateRewardSettlementOutbox(ctx, tx, appCode, cmd, legacyState, nowMS)
}
func luckyDynamicSelectedWeight(decision domain.StrategyDecision) int64 {
for _, item := range decision.Trace.Weights {
if item.TierID == decision.SelectedTier.ID {
return item.AdjustedWeightPPM
}
}
return decision.SelectedTier.JackpotWeight
}
func luckyDynamicLimitedReasons(trace domain.DecisionTrace) map[string]bool {
out := make(map[string]bool, len(trace.Removed)+1)
for _, removed := range trace.Removed {
out[removed.Reason] = true
}
if trace.BlockedReason != "" {
out[trace.BlockedReason] = true
}
return out
}
func validateLuckyDynamicBatchState(state luckyDynamicBatchState, expectedSpent int64) error {
if state.BasePool.Balance < 0 {
return fmt.Errorf("dynamic lucky gift pool became negative")
}
if state.PublicIn+state.ProfitIn+state.AnchorIn != expectedSpent {
return fmt.Errorf("dynamic lucky gift fund split does not conserve spend")
}
return nil
}
// insertExternalLuckyGiftDrawItems 给 external gift_count=N 保存 N 条独立决策;聚合响应仍是一行,
// 但 P/W 删除、token、六维风控和每抽池水位不能只藏在一个最终 multiplier 里。
func (r *Repository) insertExternalLuckyGiftDrawItems(ctx context.Context, tx *sql.Tx, appCode string, persistence luckyDynamicPersistence, records []luckyDrawRecordInput, nowMS int64) error {
const chunkSize = 200
for start := 0; start < len(records); start += chunkSize {
end := start + chunkSize
if end > len(records) {
end = len(records)
}
var query strings.Builder
query.WriteString(`
INSERT INTO external_lucky_gift_draw_items (
app_code, request_id, item_index, draw_id, command_id, external_user_id, pool_id,
rule_version, unit_amount, reward_amount, selected_tier_id, multiplier_ppm, stage,
candidate_snapshot_json, pool_snapshot_json, rtp_snapshot_json, reward_status,
paid_at_ms, created_at_ms
) VALUES `)
args := make([]any, 0, (end-start)*19)
for offset, record := range records[start:end] {
if offset > 0 {
query.WriteByte(',')
}
query.WriteString("(" + luckySQLPlaceholders(19) + ")")
candidateSnapshot, poolSnapshot, rtpSnapshot := luckyDrawRecordSnapshots(record)
args = append(args,
appCode, persistence.ExternalRequestID, start+offset+1, record.DrawID, record.Command.CommandID,
persistence.ExternalUserID, record.Config.PoolID, record.Config.RuleVersion, record.Command.CoinSpent,
record.Candidate.effectiveReward(), record.Candidate.TierID, record.Candidate.MultiplierPPM, record.ExperiencePool,
candidateSnapshot, poolSnapshot, rtpSnapshot, domain.StatusGranted,
luckyPaidTime(record.Command, nowMS).UnixMilli(), nowMS,
)
}
if _, err := tx.ExecContext(ctx, query.String(), args...); err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1,480 @@
package mysql
import (
"context"
"database/sql"
"fmt"
"sync"
"testing"
"time"
"hyapp/internal/testutil/mysqlschema"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
)
func TestDynamicLuckyGiftMySQLBatchAndExternalPaths(t *testing.T) {
caller := mysqlschema.CallerFile(t, 1)
schema := mysqlschema.New(t, mysqlschema.Config{
EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN",
InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"),
DatabasePrefix: "hy_lucky_dynamic",
})
repo := &Repository{db: schema.DB}
ctx := appcode.WithContext(context.Background(), "lalu")
now := time.Date(2026, 7, 12, 12, 30, 0, 0, time.UTC).UnixMilli()
internalRule := dynamicMySQLTestRule("internal-dynamic")
internalRule.SettlementWindowWager = 99
// 参考价故意设为 10旧算法会把 99 流水错误折成 10 抽;本用例预置 10 抽/98 流水,证明 V3 不会因抽数已满而提前滚窗。
internalRule.GiftPriceReference = 10
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, internalRule, now-1_000); err != nil {
t.Fatalf("publish internal dynamic rule: %v", err)
}
checked, err := repo.CheckLuckyGift(ctx, domain.CheckCommand{PoolID: internalRule.PoolID, GiftID: "super-lucky", UserID: 1001, RoomID: "room-1"})
if err != nil || checked.StrategyVersion != domain.StrategyDynamicV3 {
t.Fatalf("check did not expose immutable strategy version: result=%+v err=%v", checked, err)
}
seedDynamicRTPWindowsBelowWagerThreshold(t, schema.DB, internalRule.PoolID, now-900)
command := domain.DrawCommand{
CommandID: "dynamic-99", PoolID: internalRule.PoolID, UserID: 1001, TargetUserID: 2001,
DeviceID: "device-1001", RoomID: "room-1", AnchorID: "anchor-1", GiftID: "super-lucky",
GiftCount: 99, CoinSpent: 100, GiftIncomeCoins: 1, PaidAtMS: now,
}
missingWalletPaidAt := command
missingWalletPaidAt.CommandID = "dynamic-missing-wallet-paid-at"
missingWalletPaidAt.PaidAtMS = 0
if _, err := repo.ExecuteLuckyGiftDraw(ctx, missingWalletPaidAt, now+123_456); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("dynamic_v3 missing wallet paid_at_ms should fail closed, got %v", err)
}
var missingPaidAtDraws int64
if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id=?`, missingWalletPaidAt.CommandID).Scan(&missingPaidAtDraws); err != nil || missingPaidAtDraws != 0 {
t.Fatalf("missing wallet paid_at_ms draw facts=%d err=%v", missingPaidAtDraws, err)
}
missingDevice := command
missingDevice.CommandID = "dynamic-missing-device"
missingDevice.DeviceID = ""
if _, err := repo.ExecuteLuckyGiftDraw(ctx, missingDevice, now); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("dynamic_v3 missing signed device_id should fail closed, got %v", err)
}
var missingDeviceDraws int64
if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id=?`, missingDevice.CommandID).Scan(&missingDeviceDraws); err != nil || missingDeviceDraws != 0 {
t.Fatalf("missing device_id draw facts=%d err=%v", missingDeviceDraws, err)
}
// 两个相同 command_id 的首次请求同时进入:两者都必须成功,后到者等待 command lock 后
// 从首次事务回读同一批 99 条事实,不能以唯一键冲突失败,更不能额外推进奖池/RTP/风控。
var internalResults [2]domain.DrawResult
var internalErrors [2]error
internalStart := make(chan struct{})
var internalWorkers sync.WaitGroup
for index := range internalResults {
internalWorkers.Add(1)
go func(index int) {
defer internalWorkers.Done()
<-internalStart
internalResults[index], internalErrors[index] = repo.ExecuteLuckyGiftDraw(ctx, command, now)
}(index)
}
close(internalStart)
internalWorkers.Wait()
for index, workerErr := range internalErrors {
if workerErr != nil {
t.Fatalf("internal concurrent worker %d: %v", index, workerErr)
}
}
first := internalResults[0]
if len(first.DrawIDs) != 99 {
t.Fatalf("aggregate draw ids=%d want=99", len(first.DrawIDs))
}
if internalResults[1].DrawID != first.DrawID ||
internalResults[1].EffectiveRewardCoins != first.EffectiveRewardCoins ||
internalResults[1].MultiplierPPM != first.MultiplierPPM ||
internalResults[1].RewardStatus != first.RewardStatus ||
fmt.Sprint(internalResults[1].DrawIDs) != fmt.Sprint(first.DrawIDs) ||
fmt.Sprint(internalResults[1].Hits) != fmt.Sprint(first.Hits) {
t.Fatalf("internal concurrent aggregate mismatch: %+v / %+v", first, internalResults[1])
}
var internalLockCount int64
if err := schema.DB.QueryRow(`
SELECT COUNT(*) FROM lucky_gift_command_locks
WHERE app_code='lalu' AND command_id=?`, command.CommandID,
).Scan(&internalLockCount); err != nil || internalLockCount != 1 {
t.Fatalf("internal command lock count=%d err=%v", internalLockCount, err)
}
assertDynamicMySQLBatchFacts(t, schema.DB, "lalu", internalRule.PoolID, command.UserID, command.CommandID, 99, 100, 98, 1, 1)
assertDynamicRTPWindowRollover(t, schema.DB, internalRule.PoolID)
assertExactRolling72HourBoundary(t, repo, schema.DB, ctx, internalRule.PoolID, now)
// 顺序重放必须只读回首次 99 条事实,不能再次入池、推进风控或生成钱包返奖。
replayed, err := repo.ExecuteLuckyGiftDraw(ctx, command, now+1)
if err != nil {
t.Fatalf("replay dynamic 99 draw: %v", err)
}
if replayed.DrawID != first.DrawID || replayed.EffectiveRewardCoins != first.EffectiveRewardCoins {
t.Fatalf("replay changed aggregate: first=%+v replay=%+v", first, replayed)
}
assertDynamicMySQLBatchFacts(t, schema.DB, "lalu", internalRule.PoolID, command.UserID, command.CommandID, 99, 100, 98, 1, 1)
assertDynamicTokenSurvivesUTCDayBoundary(t, repo, schema.DB, ctx, now)
externalRule := dynamicMySQLTestRule("external-dynamic")
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, externalRule, now-500); err != nil {
t.Fatalf("publish external dynamic rule: %v", err)
}
missingExternalPaidAt := domain.ExternalDrawCommand{
AppCode: "lalu", ExternalUserID: "external-missing-paid-at", DeviceID: "trusted-device-missing-paid-at", RequestID: "external-dynamic-missing-paid-at",
GiftCount: 1, UnitAmount: 10, TotalAmount: 10, Currency: "COIN", PoolID: externalRule.PoolID,
}
if _, err := repo.ExecuteExternalGiftDraw(ctx, missingExternalPaidAt, now+234_567); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("external dynamic_v3 missing paid_at_ms should fail closed, got %v", err)
}
var missingExternalDraws int64
if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draws WHERE app_code='lalu' AND request_id=?`, missingExternalPaidAt.RequestID).Scan(&missingExternalDraws); err != nil || missingExternalDraws != 0 {
t.Fatalf("missing external paid_at_ms draw facts=%d err=%v", missingExternalDraws, err)
}
external := domain.ExternalDrawCommand{
AppCode: "lalu", ExternalUserID: "external-42", DeviceID: "external-device-attested-42", RequestID: "external-dynamic-99",
GiftCount: 99, UnitAmount: 10, TotalAmount: 990, Currency: "COIN", PaidAtMS: now, PoolID: externalRule.PoolID,
}
missingExternalDevice := external
missingExternalDevice.RequestID = "external-dynamic-missing-device"
missingExternalDevice.DeviceID = ""
if _, err := repo.ExecuteExternalGiftDraw(ctx, missingExternalDevice, now); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("external dynamic_v3 missing caller-bound device_id should fail closed, got %v", err)
}
var missingExternalDeviceDraws int64
if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draws WHERE app_code='lalu' AND request_id=?`, missingExternalDevice.RequestID).Scan(&missingExternalDeviceDraws); err != nil || missingExternalDeviceDraws != 0 {
t.Fatalf("missing external device draw facts=%d err=%v", missingExternalDeviceDraws, err)
}
var walletSettlementEventsBefore int64
if err := schema.DB.QueryRow(`
SELECT COUNT(*) FROM lucky_gift_outbox
WHERE app_code='lalu' AND event_type=?`, domain.EventTypeLuckyGiftRewardSettlement,
).Scan(&walletSettlementEventsBefore); err != nil {
t.Fatalf("query wallet settlement outbox before external: %v", err)
}
// 两个同 request_id 的首次请求同时进入时request lock 必须让后到者等待并读回同一结果。
var results [2]domain.ExternalDrawResult
var errorsByWorker [2]error
start := make(chan struct{})
var workers sync.WaitGroup
for index := range results {
workers.Add(1)
go func(index int) {
defer workers.Done()
<-start
results[index], errorsByWorker[index] = repo.ExecuteExternalGiftDraw(ctx, external, now)
}(index)
}
close(start)
workers.Wait()
for index, workerErr := range errorsByWorker {
if workerErr != nil {
t.Fatalf("external concurrent worker %d: %v", index, workerErr)
}
}
if results[0].DrawID == "" || results[0].DrawID != results[1].DrawID || results[0].RewardAmount != results[1].RewardAmount {
t.Fatalf("external concurrent idempotency mismatch: %+v / %+v", results[0], results[1])
}
var aggregateCount, itemCount, itemSpend, itemReward int64
if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draws WHERE app_code='lalu' AND request_id=?`, external.RequestID).Scan(&aggregateCount); err != nil {
t.Fatalf("query external aggregate: %v", err)
}
if err := schema.DB.QueryRow(`
SELECT COUNT(*), COALESCE(SUM(unit_amount),0), COALESCE(SUM(reward_amount),0)
FROM external_lucky_gift_draw_items WHERE app_code='lalu' AND request_id=?`, external.RequestID,
).Scan(&itemCount, &itemSpend, &itemReward); err != nil {
t.Fatalf("query external items: %v", err)
}
if aggregateCount != 1 || itemCount != 99 || itemSpend != 990 || itemReward != results[0].RewardAmount {
t.Fatalf("external facts aggregate=%d items=%d spend=%d reward=%d result=%d", aggregateCount, itemCount, itemSpend, itemReward, results[0].RewardAmount)
}
expectedExternalDeviceScope := luckyPoolScopedID(externalRule.PoolID, externalLuckyDeviceID(external.AppCode, external.DeviceID))
legacyExternalUserScope := luckyPoolScopedID(externalRule.PoolID, externalLuckyDeviceID(external.AppCode, external.ExternalUserID))
var expectedDeviceCounters, legacyUserCounters int64
if err := schema.DB.QueryRow(`
SELECT COUNT(*) FROM lucky_risk_counters
WHERE app_code='lalu' AND scope_type='device' AND scope_id=?`, expectedExternalDeviceScope,
).Scan(&expectedDeviceCounters); err != nil {
t.Fatalf("query external device counter: %v", err)
}
if err := schema.DB.QueryRow(`
SELECT COUNT(*) FROM lucky_risk_counters
WHERE app_code='lalu' AND scope_type='device' AND scope_id=?`, legacyExternalUserScope,
).Scan(&legacyUserCounters); err != nil {
t.Fatalf("query legacy external-user device counter: %v", err)
}
if expectedDeviceCounters != 1 || legacyUserCounters != 0 {
t.Fatalf("external dynamic device scope counters trusted=%d legacy_user=%d", expectedDeviceCounters, legacyUserCounters)
}
if _, err := repo.ExecuteExternalGiftDraw(ctx, external, now+1); err != nil {
t.Fatalf("replay external dynamic result: %v", err)
}
var walletSettlementEventsAfter int64
if err := schema.DB.QueryRow(`
SELECT COUNT(*) FROM lucky_gift_outbox
WHERE app_code='lalu' AND event_type=?`, domain.EventTypeLuckyGiftRewardSettlement,
).Scan(&walletSettlementEventsAfter); err != nil {
t.Fatalf("query wallet settlement outbox after external: %v", err)
}
if walletSettlementEventsAfter != walletSettlementEventsBefore {
t.Fatalf("external dynamic draw created HyApp wallet settlements: before=%d after=%d", walletSettlementEventsBefore, walletSettlementEventsAfter)
}
}
func seedDynamicRTPWindowsBelowWagerThreshold(t *testing.T, db *sql.DB, poolID string, nowMS int64) {
t.Helper()
scopeID := poolID + ":v1"
for _, scopeType := range []string{"pool", "pool_gift"} {
if _, err := db.Exec(`
INSERT INTO lucky_rtp_windows (
app_code,scope_type,scope_id,window_index,target_rtp_ppm,control_window_draws,
paid_draws,wager_coins,target_payout_coins,actual_base_payout_coins,carry_ppm,status,created_at_ms,updated_at_ms
) VALUES ('lalu',?,?,1,980000,10,10,98,96,0,40000,'open',?,?)`,
scopeType, scopeID, nowMS, nowMS,
); err != nil {
t.Fatalf("seed %s RTP window: %v", scopeType, err)
}
}
}
func assertDynamicRTPWindowRollover(t *testing.T, db *sql.DB, poolID string) {
t.Helper()
scopeID := poolID + ":v1"
for _, scopeType := range []string{"pool", "pool_gift"} {
rows, err := db.Query(`
SELECT window_index, paid_draws, wager_coins, status FROM lucky_rtp_windows
WHERE app_code='lalu' AND scope_type=? AND scope_id=? ORDER BY window_index`, scopeType, scopeID)
if err != nil {
t.Fatalf("query %s rollover: %v", scopeType, err)
}
var indexes, paid, wagers []int64
var statuses []string
for rows.Next() {
var index, drawCount, wager int64
var status string
if err := rows.Scan(&index, &drawCount, &wager, &status); err != nil {
_ = rows.Close()
t.Fatalf("scan %s rollover: %v", scopeType, err)
}
indexes = append(indexes, index)
paid = append(paid, drawCount)
wagers = append(wagers, wager)
statuses = append(statuses, status)
}
if err := rows.Close(); err != nil {
t.Fatalf("close %s rollover rows: %v", scopeType, err)
}
if fmt.Sprint(indexes) != "[1 2]" || fmt.Sprint(paid) != "[11 98]" || fmt.Sprint(wagers) != "[100 98]" || fmt.Sprint(statuses) != "[closed open]" {
t.Fatalf("%s wager rollover indexes/paid/wager/status=%v/%v/%v/%v", scopeType, indexes, paid, wagers, statuses)
}
}
var firstWindow, secondWindow, firstGiftWindow, secondGiftWindow int64
if err := db.QueryRow(`
SELECT
COALESCE(SUM(CASE WHEN command_id LIKE 'dynamic-99#%' AND rtp_window_index=1 THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN command_id LIKE 'dynamic-99#%' AND rtp_window_index=2 THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN command_id LIKE 'dynamic-99#%' AND gift_rtp_window_index=1 THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN command_id LIKE 'dynamic-99#%' AND gift_rtp_window_index=2 THEN 1 ELSE 0 END),0)
FROM lucky_draw_records WHERE app_code='lalu' AND pool_id=?`, poolID,
).Scan(&firstWindow, &secondWindow, &firstGiftWindow, &secondGiftWindow); err != nil {
t.Fatalf("query draw rollover indexes: %v", err)
}
if firstWindow != 1 || secondWindow != 98 || firstGiftWindow != 1 || secondGiftWindow != 98 {
t.Fatalf("draw rollover global/gift=%d/%d %d/%d want=1/98 1/98", firstWindow, secondWindow, firstGiftWindow, secondGiftWindow)
}
}
func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db *sql.DB, ctx context.Context, dayOneMS int64) {
t.Helper()
rule := dynamicMySQLTestRule("token-cross-day")
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, rule, dayOneMS-100); err != nil {
t.Fatalf("publish token rule: %v", err)
}
userID := int64(3030)
earn := domain.DrawCommand{
CommandID: "token-earn", PoolID: rule.PoolID, UserID: userID, TargetUserID: 1,
DeviceID: "device-token", RoomID: "room-token", AnchorID: "anchor-token", GiftID: "super-lucky",
GiftCount: 1, CoinSpent: 5_000, GiftIncomeCoins: 50, PaidAtMS: dayOneMS,
}
if _, err := repo.ExecuteLuckyGiftDraw(ctx, earn, dayOneMS); err != nil {
t.Fatalf("earn milestone token: %v", err)
}
assertPendingTokenCount(t, db, rule.PoolID, userID, 1)
if _, err := db.Exec(`UPDATE lucky_pools SET balance=0 WHERE app_code='lalu' AND scope_type='pool' AND scope_id=?`, rule.PoolID); err != nil {
t.Fatalf("drain token test pool: %v", err)
}
dayTwoMS := time.UnixMilli(dayOneMS).UTC().Add(24 * time.Hour).UnixMilli()
retain := earn
retain.CommandID = "token-retain-next-day"
retain.CoinSpent = 1
retain.GiftIncomeCoins = 0
retain.PaidAtMS = dayTwoMS
if _, err := repo.ExecuteLuckyGiftDraw(ctx, retain, dayTwoMS); err != nil {
t.Fatalf("retain token across UTC day: %v", err)
}
assertPendingTokenCount(t, db, rule.PoolID, userID, 1)
if _, err := db.Exec(`UPDATE lucky_pools SET balance=1000000 WHERE app_code='lalu' AND scope_type='pool' AND scope_id=?`, rule.PoolID); err != nil {
t.Fatalf("refill token test pool: %v", err)
}
consume := retain
consume.CommandID = "token-consume-next-day"
consume.PaidAtMS++
result, err := repo.ExecuteLuckyGiftDraw(ctx, consume, dayTwoMS+1)
if err != nil {
t.Fatalf("consume retained token: %v", err)
}
if !result.HighMultiplier || result.EffectiveRewardCoins <= 0 {
t.Fatalf("retained token did not produce payable jackpot: %+v", result)
}
assertPendingTokenCount(t, db, rule.PoolID, userID, 0)
}
func assertPendingTokenCount(t *testing.T, db *sql.DB, poolID string, userID, want int64) {
t.Helper()
var got int64
if err := db.QueryRow(`
SELECT pending_jackpot_tokens FROM lucky_user_states
WHERE app_code='lalu' AND user_id=? AND gift_id=?`, userID, poolID,
).Scan(&got); err != nil {
t.Fatalf("query pending token: %v", err)
}
if got != want {
t.Fatalf("pending token=%d want=%d", got, want)
}
}
func assertExactRolling72HourBoundary(t *testing.T, repo *Repository, db *sql.DB, ctx context.Context, poolID string, endMS int64) {
t.Helper()
const hourMS = int64(time.Hour / time.Millisecond)
startMS := endMS - 72*hourMS
fullStartMS := startMS + (hourMS - startMS%hourMS)
fullEndMS := endMS - endMS%hourMS
userID := int64(9090)
if _, err := db.Exec(`
INSERT INTO lucky_user_rtp_hour_buckets (
app_code,pool_id,user_id,bucket_hour_ms,wager_coins,payout_coins,created_at_ms,updated_at_ms
) VALUES
('lalu',?,?,?,10,1,1,1),
('lalu',?,?,?,999,999,1,1)`,
poolID, userID, fullStartMS,
poolID, userID, fullEndMS,
); err != nil {
t.Fatalf("seed exact 72h buckets: %v", err)
}
events := []struct {
id string
atMS int64
wager int64
payout int64
}{
{"before-start", startMS - 1, 100, 100},
{"at-start", startMS, 2, 1},
{"lower-end", fullStartMS - 1, 3, 1},
{"inside-full-hour", fullStartMS, 100, 100},
{"upper-start", fullEndMS, 4, 1},
{"before-end", endMS - 1, 5, 1},
{"at-end", endMS, 100, 100},
}
for _, event := range events {
if _, err := db.Exec(`
INSERT INTO lucky_user_rtp_boundary_events (
app_code,pool_id,user_id,draw_id,occurred_at_ms,wager_coins,payout_coins,created_at_ms
) VALUES ('lalu',?,?,?,?,?,?,1)`,
poolID, userID, event.id, event.atMS, event.wager, event.payout,
); err != nil {
t.Fatalf("seed exact 72h event %s: %v", event.id, err)
}
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin exact 72h query: %v", err)
}
defer func() { _ = tx.Rollback() }()
rtp, err := repo.getLuckyUserRolling72HourRTP(ctx, tx, "lalu", poolID, userID, endMS)
if err != nil {
t.Fatalf("query exact 72h: %v", err)
}
// 完整小时只取 bucket=10/1边界严格包含 start、排除 end并且不重复计算 fullStart 事件。
if rtp != (domain.StrategyRTP{WagerCoins: 24, PayoutCoins: 5}) {
t.Fatalf("exact 72h RTP=%+v want wager=24 payout=5", rtp)
}
}
func assertDynamicMySQLBatchFacts(t *testing.T, db queryRower, appCode, poolID string, userID int64, commandPrefix string, draws, spent, public, profit, anchor int64) {
t.Helper()
var count, spentSum, publicSum, profitSum, anchorSum, invalidUnits int64
err := db.QueryRow(`
SELECT COUNT(*), COALESCE(SUM(coin_spent),0),
COALESCE(SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.public_pool_coins')) AS SIGNED)),0),
COALESCE(SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.profit_pool_coins')) AS SIGNED)),0),
COALESCE(SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.anchor_return_coins')) AS SIGNED)),0),
COALESCE(SUM(CASE WHEN coin_spent <>
CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.public_pool_coins')) AS SIGNED) +
CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.profit_pool_coins')) AS SIGNED) +
CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.anchor_return_coins')) AS SIGNED)
THEN 1 ELSE 0 END),0)
FROM lucky_draw_records
WHERE app_code=? AND pool_id=? AND command_id LIKE ?`, appCode, poolID, commandPrefix+"#%",
).Scan(&count, &spentSum, &publicSum, &profitSum, &anchorSum, &invalidUnits)
if err != nil {
t.Fatalf("query dynamic batch facts: %v", err)
}
if count != draws || spentSum != spent || publicSum != public || profitSum != profit || anchorSum != anchor || invalidUnits != 0 {
t.Fatalf("batch facts count/spent/split/invalid=%d/%d/%d/%d/%d/%d want=%d/%d/%d/%d/%d/0",
count, spentSum, publicSum, profitSum, anchorSum, invalidUnits, draws, spent, public, profit, anchor)
}
var eventCount, eventWager int64
if err := db.QueryRow(`
SELECT COUNT(*), COALESCE(SUM(wager_coins),0) FROM lucky_user_rtp_boundary_events
WHERE app_code=? AND pool_id=? AND user_id=?`, appCode, poolID, userID,
).Scan(&eventCount, &eventWager); err != nil {
t.Fatalf("query exact 72h events: %v", err)
}
if eventCount != draws || eventWager != spent {
t.Fatalf("72h events count/wager=%d/%d want=%d/%d", eventCount, eventWager, draws, spent)
}
}
type queryRower interface {
QueryRow(query string, args ...any) *sql.Row
}
func dynamicMySQLTestRule(poolID string) domain.RuleConfig {
stages := make([]domain.RuleStage, 0, 3)
for index, stageName := range []string{domain.StageNovice, domain.StageNormal, domain.StageAdvanced} {
stage := domain.RuleStage{Stage: stageName}
if index >= 1 {
stage.MinRecharge30DCoins = 1
}
if index >= 2 {
stage.MinRecharge7DCoins = 1
}
for _, tier := range []struct {
id string
multiplier int64
weight int64
}{{"0x", 0, 50_000}, {"0_5x", 500_000, 40_000}, {"1x", 1_000_000, 860_000}, {"2x", 2_000_000, 50_000}} {
stage.Tiers = append(stage.Tiers, domain.RuleTier{
Stage: stageName, TierID: fmt.Sprintf("%s_%s", stageName, tier.id),
MultiplierPPM: tier.multiplier, BaseWeightPPM: tier.weight, Enabled: true,
})
}
stages = append(stages, stage)
}
return domain.RuleConfig{
PoolID: poolID, StrategyVersion: domain.StrategyDynamicV3, Enabled: true,
TargetRTPPPM: 980_000, PoolRatePPM: 980_000, ProfitRatePPM: 10_000, AnchorRatePPM: 10_000,
SettlementWindowWager: 1_000_000, ControlBandPPM: 30_000, GiftPriceReference: 1,
InitialPoolCoins: 1_000_000, LossStreakGuarantee: 5,
LowWatermarkCoins: 10_000_000, LowWaterNonzeroFactorPPM: 700_000,
HighWatermarkCoins: 20_000_000, HighWaterNonzeroFactorPPM: 1_300_000,
RechargeBoostWindowMS: 300_000, RechargeBoostFactorPPM: 1_100_000,
JackpotMultiplierPPMs: []int64{200_000_000, 500_000_000, 1_000_000_000},
JackpotGlobalRTPMaxPPM: 980_000, JackpotUserDayRTPMaxPPM: 960_000, JackpotUser72hRTPMaxPPM: 960_000,
JackpotSpendThresholdCoins: 5_000, MaxJackpotHitsPerUserDay: 5,
MaxSinglePayout: 1_000_000, UserHourlyPayoutCap: 2_000_000, UserDailyPayoutCap: 3_000_000,
DeviceDailyPayoutCap: 4_000_000, RoomHourlyPayoutCap: 5_000_000, AnchorDailyPayoutCap: 6_000_000,
Stages: stages,
}
}

View File

@ -0,0 +1,278 @@
package mysql
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
)
// luckyDynamicRiskCounter 是 dynamic_v3 在事务内锁定的单个风控窗口。
// scope_id 始终带 pool_id 前缀,防止同一用户在普通幸运与超级幸运池之间错误共享额度。
type luckyDynamicRiskCounter struct {
ScopeType string
ScopeID string
WindowType string
BucketKey string
Payout int64
}
type luckyUserStrategyDay struct {
StatDay string
WagerCoins int64
PayoutCoins int64
SpendCoins int64
JackpotHits int64
}
type luckyUserRTPHour struct {
BucketHourMS int64
WagerCoins int64
PayoutCoins int64
}
func luckyPaidTime(cmd domain.DrawCommand, nowMS int64) time.Time {
ms := cmd.PaidAtMS
if ms <= 0 {
ms = nowMS
}
return time.UnixMilli(ms).UTC()
}
func (r *Repository) getLuckyDynamicRiskCounters(ctx context.Context, tx *sql.Tx, appCode, poolID string, cmd domain.DrawCommand, nowMS int64) (map[string]luckyDynamicRiskCounter, error) {
paidAt := luckyPaidTime(cmd, nowMS)
hour := paidAt.Format("2006-01-02T15")
day := paidAt.Format("2006-01-02")
specs := []luckyDynamicRiskCounter{
{ScopeType: "user", ScopeID: luckyPoolScopedID(poolID, fmt.Sprintf("%d", cmd.UserID)), WindowType: "hour", BucketKey: hour},
{ScopeType: "user", ScopeID: luckyPoolScopedID(poolID, fmt.Sprintf("%d", cmd.UserID)), WindowType: "day", BucketKey: day},
{ScopeType: "device", ScopeID: luckyPoolScopedID(poolID, cmd.DeviceID), WindowType: "day", BucketKey: day},
{ScopeType: "room", ScopeID: luckyPoolScopedID(poolID, cmd.RoomID), WindowType: "hour", BucketKey: hour},
{ScopeType: "anchor", ScopeID: luckyPoolScopedID(poolID, cmd.AnchorID), WindowType: "day", BucketKey: day},
}
out := make(map[string]luckyDynamicRiskCounter, len(specs))
for _, spec := range specs {
counter, err := r.getOrCreateLuckyDynamicRiskCounter(ctx, tx, appCode, spec, nowMS)
if err != nil {
return nil, err
}
out[luckyDynamicRiskCounterKey(spec.ScopeType, spec.WindowType)] = counter
}
return out, nil
}
func (r *Repository) getOrCreateLuckyDynamicRiskCounter(ctx context.Context, tx *sql.Tx, appCode string, spec luckyDynamicRiskCounter, nowMS int64) (luckyDynamicRiskCounter, error) {
// 幂等建行先取得唯一键写锁,消除两个首次请求都读到 no rows 后其中一个 duplicate-key 失败的竞态。
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_risk_counters (
app_code, scope_type, scope_id, window_type, bucket_key, payout_coins, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, 0, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
appCode, spec.ScopeType, spec.ScopeID, spec.WindowType, spec.BucketKey, nowMS, nowMS,
); err != nil {
return luckyDynamicRiskCounter{}, err
}
err := tx.QueryRowContext(ctx, `
SELECT payout_coins
FROM lucky_risk_counters
WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_type = ? AND bucket_key = ?
FOR UPDATE`,
appCode, spec.ScopeType, spec.ScopeID, spec.WindowType, spec.BucketKey,
).Scan(&spec.Payout)
return spec, err
}
func luckyDynamicRiskCapacity(config domain.Config, counters map[string]luckyDynamicRiskCounter) domain.StrategyRiskCapacity {
// 每个值都是“本抽还能支付多少”而不是配置总额0 是真实硬锁,不能被解释为 unlimited。
return domain.StrategyRiskCapacity{
Enabled: true,
SingleDrawCoins: config.MaxSinglePayout,
UserHourCoins: maxInt64(0, config.UserHourlyPayoutCap-counters[luckyDynamicRiskCounterKey("user", "hour")].Payout),
UserDayCoins: maxInt64(0, config.UserDailyPayoutCap-counters[luckyDynamicRiskCounterKey("user", "day")].Payout),
DeviceDayCoins: maxInt64(0, config.DeviceDailyPayoutCap-counters[luckyDynamicRiskCounterKey("device", "day")].Payout),
RoomHourCoins: maxInt64(0, config.RoomHourlyPayoutCap-counters[luckyDynamicRiskCounterKey("room", "hour")].Payout),
AnchorDayCoins: maxInt64(0, config.AnchorDailyPayoutCap-counters[luckyDynamicRiskCounterKey("anchor", "day")].Payout),
}
}
func (r *Repository) addLuckyDynamicRiskPayout(ctx context.Context, tx *sql.Tx, appCode string, counters map[string]luckyDynamicRiskCounter, payout, nowMS int64) error {
if payout <= 0 {
return nil
}
for _, counter := range counters {
if _, err := tx.ExecContext(ctx, `
UPDATE lucky_risk_counters
SET payout_coins = payout_coins + ?, updated_at_ms = ?
WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_type = ? AND bucket_key = ?`,
payout, nowMS, appCode, counter.ScopeType, counter.ScopeID, counter.WindowType, counter.BucketKey,
); err != nil {
return err
}
}
return nil
}
func luckyDynamicRiskCounterKey(scopeType, windowType string) string {
return scopeType + ":" + windowType
}
func (r *Repository) getOrCreateLuckyUserStrategyDay(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, cmd domain.DrawCommand, nowMS int64) (luckyUserStrategyDay, error) {
day := luckyPaidTime(cmd, nowMS).Format("2006-01-02")
state := luckyUserStrategyDay{StatDay: day}
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_user_strategy_days (
app_code, pool_id, user_id, stat_day, wager_coins, payout_coins, spend_coins,
jackpot_hits, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 0, 0, 0, 0, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
appCode, poolID, userID, day, nowMS, nowMS,
); err != nil {
return luckyUserStrategyDay{}, err
}
err := tx.QueryRowContext(ctx, `
SELECT wager_coins, payout_coins, spend_coins, jackpot_hits
FROM lucky_user_strategy_days
WHERE app_code = ? AND pool_id = ? AND user_id = ? AND stat_day = ?
FOR UPDATE`, appCode, poolID, userID, day,
).Scan(&state.WagerCoins, &state.PayoutCoins, &state.SpendCoins, &state.JackpotHits)
return state, err
}
func (r *Repository) persistLuckyUserStrategyDay(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, state luckyUserStrategyDay, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
UPDATE lucky_user_strategy_days
SET wager_coins = ?, payout_coins = ?, spend_coins = ?, jackpot_hits = ?, updated_at_ms = ?
WHERE app_code = ? AND pool_id = ? AND user_id = ? AND stat_day = ?`,
state.WagerCoins, state.PayoutCoins, state.SpendCoins, state.JackpotHits, nowMS,
appCode, poolID, userID, state.StatDay,
)
return err
}
func (r *Repository) getOrCreateLuckyUserRTPHour(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, cmd domain.DrawCommand, nowMS int64) (luckyUserRTPHour, error) {
paidAt := luckyPaidTime(cmd, nowMS)
bucketHourMS := paidAt.Truncate(time.Hour).UnixMilli()
state := luckyUserRTPHour{BucketHourMS: bucketHourMS}
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_user_rtp_hour_buckets (
app_code, pool_id, user_id, bucket_hour_ms, wager_coins, payout_coins, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 0, 0, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
appCode, poolID, userID, bucketHourMS, nowMS, nowMS,
); err != nil {
return luckyUserRTPHour{}, err
}
err := tx.QueryRowContext(ctx, `
SELECT wager_coins, payout_coins
FROM lucky_user_rtp_hour_buckets
WHERE app_code = ? AND pool_id = ? AND user_id = ? AND bucket_hour_ms = ?
FOR UPDATE`, appCode, poolID, userID, bucketHourMS,
).Scan(&state.WagerCoins, &state.PayoutCoins)
return state, err
}
func (r *Repository) getLuckyUserRolling72HourRTP(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, endMS int64) (domain.StrategyRTP, error) {
// 严格口径是 [end-72h,end),不能把“当前小时+前71整点桶”近似成 72h。
// 完整小时从聚合桶读取;头尾两个不足一小时的片段从专用边界事件读取,避免回扫 lucky_draw_records。
const hourMS = int64(time.Hour / time.Millisecond)
startMS := endMS - 72*hourMS
if startMS < 0 || endMS <= 0 {
return domain.StrategyRTP{}, fmt.Errorf("dynamic lucky gift 72h range is invalid")
}
fullStartMS := startMS
if remainder := startMS % hourMS; remainder != 0 {
fullStartMS += hourMS - remainder
}
fullEndMS := endMS - endMS%hourMS
var state domain.StrategyRTP
if fullStartMS < fullEndMS {
if err := tx.QueryRowContext(ctx, `
SELECT COALESCE(SUM(wager_coins), 0), COALESCE(SUM(payout_coins), 0)
FROM lucky_user_rtp_hour_buckets
WHERE app_code = ? AND pool_id = ? AND user_id = ?
AND bucket_hour_ms >= ? AND bucket_hour_ms < ?`,
appCode, poolID, userID, fullStartMS, fullEndMS,
).Scan(&state.WagerCoins, &state.PayoutCoins); err != nil {
return domain.StrategyRTP{}, err
}
}
for _, boundary := range [][2]int64{{startMS, fullStartMS}, {fullEndMS, endMS}} {
if boundary[0] >= boundary[1] {
continue
}
var wager, payout int64
if err := tx.QueryRowContext(ctx, `
SELECT COALESCE(SUM(wager_coins), 0), COALESCE(SUM(payout_coins), 0)
FROM lucky_user_rtp_boundary_events
WHERE app_code = ? AND pool_id = ? AND user_id = ?
AND occurred_at_ms >= ? AND occurred_at_ms < ?`,
appCode, poolID, userID, boundary[0], boundary[1],
).Scan(&wager, &payout); err != nil {
return domain.StrategyRTP{}, err
}
if state.WagerCoins > int64(^uint64(0)>>1)-wager || state.PayoutCoins > int64(^uint64(0)>>1)-payout {
return domain.StrategyRTP{}, fmt.Errorf("dynamic lucky gift 72h RTP overflow")
}
state.WagerCoins += wager
state.PayoutCoins += payout
}
return state, nil
}
func (r *Repository) insertLuckyUserRTPBoundaryEvents(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, records []luckyDrawRecordInput, nowMS int64) error {
const chunkSize = 200
for start := 0; start < len(records); start += chunkSize {
end := start + chunkSize
if end > len(records) {
end = len(records)
}
var query strings.Builder
query.WriteString(`
INSERT INTO lucky_user_rtp_boundary_events (
app_code, pool_id, user_id, draw_id, occurred_at_ms, wager_coins, payout_coins, created_at_ms
) VALUES `)
args := make([]any, 0, (end-start)*8)
for index, record := range records[start:end] {
if index > 0 {
query.WriteByte(',')
}
query.WriteString("(?,?,?,?,?,?,?,?)")
args = append(args,
appCode, poolID, userID, record.DrawID, luckyPaidTime(record.Command, nowMS).UnixMilli(),
record.Command.CoinSpent, record.Candidate.effectiveReward(), nowMS,
)
}
if _, err := tx.ExecContext(ctx, query.String(), args...); err != nil {
return err
}
}
return nil
}
func (r *Repository) persistLuckyUserRTPHour(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, state luckyUserRTPHour, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
UPDATE lucky_user_rtp_hour_buckets
SET wager_coins = ?, payout_coins = ?, updated_at_ms = ?
WHERE app_code = ? AND pool_id = ? AND user_id = ? AND bucket_hour_ms = ?`,
state.WagerCoins, state.PayoutCoins, nowMS, appCode, poolID, userID, state.BucketHourMS,
)
return err
}
func (r *Repository) updateLuckyDynamicPoolNet(ctx context.Context, tx *sql.Tx, appCode, scopeID string, publicIn, payout, profitIn, anchorIncome, nowMS int64) error {
if publicIn < 0 || payout < 0 || profitIn < 0 || anchorIncome < 0 {
return fmt.Errorf("dynamic lucky gift pool delta cannot be negative")
}
_, err := tx.ExecContext(ctx, `
UPDATE lucky_pools
SET balance = balance + ? - ?, total_in = total_in + ?, total_out = total_out + ?,
profit_total = profit_total + ?, anchor_income_total = anchor_income_total + ?, updated_at_ms = ?
WHERE app_code = ? AND scope_type = ? AND scope_id = ?`,
publicIn, payout, publicIn, payout, profitIn, anchorIncome, nowMS,
appCode, luckyBasePoolScopeType, scopeID,
)
return err
}

View File

@ -0,0 +1,220 @@
package mysql
import (
crand "crypto/rand"
"fmt"
"math/big"
"sort"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
)
type luckyCryptoStrategyRandom struct{}
func (luckyCryptoStrategyRandom) Int63n(n int64) (int64, error) {
if n <= 0 {
return 0, fmt.Errorf("lucky strategy random bound must be positive")
}
value, err := crand.Int(crand.Reader, big.NewInt(n))
if err != nil {
return 0, err
}
return value.Int64(), nil
}
func luckyUsesDynamicStrategy(config domain.Config) bool {
return config.StrategyVersion == domain.StrategyDynamicV3
}
// luckyDynamicStrategyConfig 把不可变规则快照转换为纯策略输入。
// 同一倍率在三个充值阶段可能使用不同 tier_id运行内核按倍率合并并保存 stage->weight
// 从而既只有一个 0x 候选,又不会把某阶段不存在的倍率错误继承到另一阶段。
func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig {
stageNames := make([]string, 0, len(config.Stages))
rechargeStages := make([]domain.StrategyRechargeStage, 0, len(config.Stages))
for _, stage := range config.Stages {
stageNames = append(stageNames, stage.Stage)
rechargeStages = append(rechargeStages, domain.StrategyRechargeStage{
Name: stage.Stage,
MinRecharge7DCoins: stage.MinRecharge7DCoins,
MinRecharge30DCoins: stage.MinRecharge30DCoins,
})
}
jackpotSet := make(map[int64]struct{}, len(config.JackpotMultiplierPPMs))
for _, multiplier := range config.JackpotMultiplierPPMs {
jackpotSet[multiplier] = struct{}{}
}
tiersByMultiplier := make(map[int64]*domain.StrategyTier)
for _, stage := range config.Stages {
for _, configured := range stage.Tiers {
if !configured.Enabled {
continue
}
tier := tiersByMultiplier[configured.MultiplierPPM]
if tier == nil {
stageWeights := make(map[string]int64, len(stageNames))
for _, stageName := range stageNames {
stageWeights[stageName] = 0
}
tier = &domain.StrategyTier{
ID: fmt.Sprintf("multiplier_%d", configured.MultiplierPPM),
MultiplierPPM: configured.MultiplierPPM,
StageWeightPPM: stageWeights,
Enabled: true,
}
tiersByMultiplier[configured.MultiplierPPM] = tier
}
tier.StageWeightPPM[stage.Stage] += configured.BaseWeightPPM
}
}
// 大奖机制从显式倍率集合随机,不要求运营把 200x/500x/1000x 塞进普通概率表。
// 图片没有提供大奖之间的权重,因此统一使用 1这一选择会完整进入 decision trace不能被误认为基础概率。
for multiplier := range jackpotSet {
tier := tiersByMultiplier[multiplier]
if tier == nil {
stageWeights := make(map[string]int64, len(stageNames))
for _, stageName := range stageNames {
stageWeights[stageName] = 0
}
tier = &domain.StrategyTier{
ID: fmt.Sprintf("jackpot_%d", multiplier),
MultiplierPPM: multiplier,
StageWeightPPM: stageWeights,
Enabled: true,
}
tiersByMultiplier[multiplier] = tier
}
tier.Jackpot = true
tier.JackpotWeight = 1
}
multipliers := make([]int64, 0, len(tiersByMultiplier))
for multiplier := range tiersByMultiplier {
multipliers = append(multipliers, multiplier)
}
sort.Slice(multipliers, func(i, j int) bool { return multipliers[i] < multipliers[j] })
tiers := make([]domain.StrategyTier, 0, len(multipliers))
for _, multiplier := range multipliers {
tiers = append(tiers, *tiersByMultiplier[multiplier])
}
return domain.StrategyConfig{
Tiers: tiers,
PublicPoolRatePPM: config.PoolRatePPM,
ProfitPoolRatePPM: config.ProfitRatePPM,
AnchorReturnRatePPM: config.AnchorRatePPM,
ColdStartPoolCoins: config.InitialPoolCoins,
LowWaterThresholdCoins: config.LowWatermarkCoins,
HighWaterThresholdCoins: config.HighWatermarkCoins,
LowWaterFactorPPM: config.LowWaterNonzeroFactorPPM,
HighWaterFactorPPM: config.HighWaterNonzeroFactorPPM,
RechargeFactorPPM: config.RechargeBoostFactorPPM,
RechargeBoostStartMS: 0,
RechargeBoostEndMS: config.RechargeBoostWindowMS,
MissProtectionZeroDraws: config.LossStreakGuarantee,
DailyJackpotLimit: config.MaxJackpotHitsPerUserDay,
MilestoneSpendCoins: config.JackpotSpendThresholdCoins,
JackpotMechanism1Enabled: true,
JackpotMechanism2Enabled: config.JackpotSpendThresholdCoins > 0,
GlobalRTPMaxPPM: config.JackpotGlobalRTPMaxPPM,
UserDayRTPMaxPPM: config.JackpotUserDayRTPMaxPPM,
User72HourRTPMaxPPM: config.JackpotUser72hRTPMaxPPM,
RechargeStages: rechargeStages,
}
}
// luckyDynamicFundSplit 以不可变规则做唯一拆账,并校验 wallet 已经发给收礼人的 COIN 与规则主播份额完全一致。
// dynamic_v3 不能把旧 room 缺字段解释成 0也不能把 App 钱包配置的其他返币比例静默塞进盈利池;
// 启用新规则前必须先完成 wallet/room 契约滚动升级并让礼物返币配置与本规则一致。
func luckyDynamicFundSplit(totalSpent, actualAnchorIncome int64, config domain.StrategyConfig) (domain.StrategyFundSplit, error) {
configured, err := domain.SplitLuckyGiftStrategyFunds(totalSpent, config)
if err != nil {
return domain.StrategyFundSplit{}, err
}
if actualAnchorIncome != configured.AnchorReturnCoins {
return domain.StrategyFundSplit{}, fmt.Errorf(
"dynamic lucky gift anchor income mismatch: wallet=%d configured=%d",
actualAnchorIncome,
configured.AnchorReturnCoins,
)
}
return configured, nil
}
// luckyDynamicUnitFundSplits 联合分配整批的三个资金桶:每一抽都先满足
// public_i+profit_i+anchor_i=unit_spent_i同时整批三桶又精确等于总拆账。
// 不能分别对四个总量调用“余数落前几抽”,否则 100 金币/99 抽的首抽会出现 spent=2、三桶=3。
func luckyDynamicUnitFundSplits(totalSpent int64, drawCount int32, total domain.StrategyFundSplit) ([]domain.StrategyFundSplit, error) {
if totalSpent <= 0 || drawCount <= 0 || total.PublicPoolCoins < 0 || total.ProfitPoolCoins < 0 || total.AnchorReturnCoins < 0 ||
total.PublicPoolCoins+total.ProfitPoolCoins+total.AnchorReturnCoins != totalSpent {
return nil, fmt.Errorf("dynamic lucky gift batch split is invalid")
}
units := make([]int64, drawCount)
for index := int32(1); index <= drawCount; index++ {
units[index-1] = luckyDrawUnitSpend(totalSpent, drawCount, index)
if units[index-1] <= 0 {
return nil, fmt.Errorf("dynamic lucky gift unit spend must be positive")
}
}
publicParts, err := luckyDynamicApportionByCapacity(units, total.PublicPoolCoins)
if err != nil {
return nil, err
}
remaining := make([]int64, len(units))
for index := range units {
remaining[index] = units[index] - publicParts[index]
}
anchorParts, err := luckyDynamicApportionByCapacity(remaining, total.AnchorReturnCoins)
if err != nil {
return nil, err
}
parts := make([]domain.StrategyFundSplit, len(units))
var publicSum, profitSum, anchorSum int64
for index := range units {
parts[index] = domain.StrategyFundSplit{
PublicPoolCoins: publicParts[index],
AnchorReturnCoins: anchorParts[index],
ProfitPoolCoins: units[index] - publicParts[index] - anchorParts[index],
}
publicSum += parts[index].PublicPoolCoins
profitSum += parts[index].ProfitPoolCoins
anchorSum += parts[index].AnchorReturnCoins
}
if publicSum != total.PublicPoolCoins || profitSum != total.ProfitPoolCoins || anchorSum != total.AnchorReturnCoins {
return nil, fmt.Errorf("dynamic lucky gift unit split does not conserve bucket totals")
}
return parts, nil
}
// luckyDynamicApportionByCapacity 按每抽金额占比做累计配额,既避免前置抽被贪心塞满,
// 又保证每份不超过该抽剩余容量。big.Int 只用于防止 total*part 在大额外部请求上溢出 int64。
func luckyDynamicApportionByCapacity(capacities []int64, partTotal int64) ([]int64, error) {
var capacityTotal int64
for _, capacity := range capacities {
if capacity < 0 || capacityTotal > int64(^uint64(0)>>1)-capacity {
return nil, fmt.Errorf("dynamic lucky gift unit capacity is invalid")
}
capacityTotal += capacity
}
if partTotal < 0 || partTotal > capacityTotal || capacityTotal <= 0 {
return nil, fmt.Errorf("dynamic lucky gift apportioned total exceeds capacity")
}
parts := make([]int64, len(capacities))
denominator := big.NewInt(capacityTotal)
var prefix, allocated int64
for index, capacity := range capacities {
prefix += capacity
cumulative := new(big.Int).Mul(big.NewInt(prefix), big.NewInt(partTotal))
cumulative.Quo(cumulative, denominator)
if !cumulative.IsInt64() {
return nil, fmt.Errorf("dynamic lucky gift unit apportion overflow")
}
parts[index] = cumulative.Int64() - allocated
if parts[index] < 0 || parts[index] > capacity {
return nil, fmt.Errorf("dynamic lucky gift unit apportion exceeded one draw")
}
allocated += parts[index]
}
if allocated != partTotal {
return nil, fmt.Errorf("dynamic lucky gift unit apportion lost funds")
}
return parts, nil
}

View File

@ -0,0 +1,116 @@
package mysql
import (
"strings"
"testing"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
)
func TestDynamicLuckyGiftRequiresExplicitStableDeviceScope(t *testing.T) {
for _, value := range []string{"", " ", strings.Repeat("d", 129)} {
if _, err := normalizeLuckyDynamicDeviceID(value); err == nil {
t.Fatalf("dynamic_v3 accepted invalid device_id length=%d", len(value))
}
}
if got, err := normalizeLuckyDynamicDeviceID(" device-auth-session-1 "); err != nil || got != "device-auth-session-1" {
t.Fatalf("normalize explicit dynamic device scope got=%q err=%v", got, err)
}
}
func TestExternalDeviceScopeUsesCallerBoundDeviceInsteadOfExternalUser(t *testing.T) {
firstUser := domain.ExternalDrawCommand{AppCode: "aslan", ExternalUserID: "user-a", DeviceID: "shared-device"}
secondUser := domain.ExternalDrawCommand{AppCode: "aslan", ExternalUserID: "user-b", DeviceID: "shared-device"}
otherDevice := domain.ExternalDrawCommand{AppCode: "aslan", ExternalUserID: "user-a", DeviceID: "other-device"}
firstScope := externalLuckyDeviceID(firstUser.AppCode, firstUser.DeviceID)
if secondScope := externalLuckyDeviceID(secondUser.AppCode, secondUser.DeviceID); secondScope != firstScope {
// 同物理设备上的多账号必须共享设备日上限,不能继续按 external_user_id 分裂。
t.Fatalf("same external device produced different scopes: first=%q second=%q", firstScope, secondScope)
}
if otherScope := externalLuckyDeviceID(otherDevice.AppCode, otherDevice.DeviceID); otherScope == firstScope {
t.Fatalf("different external devices collapsed to one scope: %q", otherScope)
}
legacyA := externalLuckyFixedCompatibleDeviceID(domain.ExternalDrawCommand{AppCode: "aslan", ExternalUserID: "legacy-a"})
legacyB := externalLuckyFixedCompatibleDeviceID(domain.ExternalDrawCommand{AppCode: "aslan", ExternalUserID: "legacy-b"})
if legacyA == legacyB {
t.Fatalf("fixed_v2 legacy external users must retain their prior independent fallback scopes: %q", legacyA)
}
if historical := externalLuckyDeviceID("aslan", "legacy-a"); legacyA != historical {
t.Fatalf("fixed_v2 legacy device scope changed: got=%q historical=%q", legacyA, historical)
}
if fixedWithNewField := externalLuckyFixedCompatibleDeviceID(firstUser); fixedWithNewField != externalLuckyDeviceID("aslan", "user-a") {
t.Fatalf("fixed_v2 unexpectedly interpreted new device_id: %q", fixedWithNewField)
}
}
func TestLuckyDynamicUnitFundSplitsConserveEveryDrawAndBatchBucket(t *testing.T) {
tests := []struct {
name string
spent int64
drawCount int32
totalSplit domain.StrategyFundSplit
}{
{
name: "image 100 coins across 99 draws",
spent: 100,
drawCount: 99,
totalSplit: domain.StrategyFundSplit{
PublicPoolCoins: 98, ProfitPoolCoins: 1, AnchorReturnCoins: 1,
},
},
{
name: "990 coins keeps configured floor rounding totals",
spent: 990,
drawCount: 99,
totalSplit: domain.StrategyFundSplit{
PublicPoolCoins: 970, ProfitPoolCoins: 11, AnchorReturnCoins: 9,
},
},
{
name: "uneven unit prices and configured buckets",
spent: 101,
drawCount: 6,
totalSplit: domain.StrategyFundSplit{
PublicPoolCoins: 90, ProfitPoolCoins: 5, AnchorReturnCoins: 6,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
parts, err := luckyDynamicUnitFundSplits(test.spent, test.drawCount, test.totalSplit)
if err != nil {
t.Fatalf("split: %v", err)
}
if len(parts) != int(test.drawCount) {
t.Fatalf("parts=%d want=%d", len(parts), test.drawCount)
}
var publicTotal, profitTotal, anchorTotal int64
for index, part := range parts {
unitSpent := luckyDrawUnitSpend(test.spent, test.drawCount, int32(index+1))
if got := part.PublicPoolCoins + part.ProfitPoolCoins + part.AnchorReturnCoins; got != unitSpent {
t.Fatalf("draw %d buckets=%d want unit spend=%d: %+v", index+1, got, unitSpent, part)
}
publicTotal += part.PublicPoolCoins
profitTotal += part.ProfitPoolCoins
anchorTotal += part.AnchorReturnCoins
}
if publicTotal != test.totalSplit.PublicPoolCoins || profitTotal != test.totalSplit.ProfitPoolCoins || anchorTotal != test.totalSplit.AnchorReturnCoins {
t.Fatalf("batch totals=%d/%d/%d want=%+v", publicTotal, profitTotal, anchorTotal, test.totalSplit)
}
})
}
}
func TestLuckyDynamicFundSplitRejectsWalletAnchorMismatch(t *testing.T) {
config := domain.DefaultLuckyGiftStrategyConfig()
if split, err := luckyDynamicFundSplit(100, 1, config); err != nil || split != (domain.StrategyFundSplit{PublicPoolCoins: 98, ProfitPoolCoins: 1, AnchorReturnCoins: 1}) {
t.Fatalf("matching split=%+v err=%v", split, err)
}
if _, err := luckyDynamicFundSplit(100, 0, config); err == nil {
t.Fatal("missing wallet anchor snapshot was accepted")
}
if _, err := luckyDynamicFundSplit(100, 10, config); err == nil {
t.Fatal("wallet anchor ratio different from rule was accepted")
}
}

View File

@ -68,6 +68,9 @@ type luckyUserState struct {
CumulativeWagerCoins int64 CumulativeWagerCoins int64
EquivalentDraws int64 EquivalentDraws int64
LossStreak int64 LossStreak int64
// PendingJackpotTokens 是按 UTC 日消费赚取、但允许跨日等待资金的未消费大奖资格。
// 它属于 pool/user 生命周期,不能和“当天消费/当天命中次数”一起在午夜静默清零。
PendingJackpotTokens int64
} }
type luckyCandidate struct { type luckyCandidate struct {
@ -99,6 +102,12 @@ type luckyDrawRecordInput struct {
GlobalWindow luckyRTPWindow GlobalWindow luckyRTPWindow
GiftWindow luckyRTPWindow GiftWindow luckyRTPWindow
BasePool luckyPool BasePool luckyPool
// StrategyDecision 只在 dynamic_v3 存在完整权重、动态因子、P/W 重抽和大奖门槛会随 draw fact 固化,
// 后台以后修改规则版本也不会让历史结果失去可解释性。
StrategyDecision *domain.StrategyDecision
FundSplit domain.StrategyFundSplit
UserDayRTP domain.StrategyRTP
User72HourRTP domain.StrategyRTP
RewardStatus string RewardStatus string
NowMS int64 NowMS int64
} }
@ -159,6 +168,7 @@ func (r *Repository) CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand
GiftID: cmd.GiftID, GiftID: cmd.GiftID,
GiftPrice: config.GiftPrice, GiftPrice: config.GiftPrice,
RuleVersion: config.RuleVersion, RuleVersion: config.RuleVersion,
StrategyVersion: config.StrategyVersion,
TargetRTPPPM: config.TargetRTPPPM, TargetRTPPPM: config.TargetRTPPPM,
ExperiencePool: luckyExperiencePool(state.EquivalentDraws, config), ExperiencePool: luckyExperiencePool(state.EquivalentDraws, config),
}, nil }, nil
@ -214,6 +224,13 @@ func (r *Repository) ExecuteLuckyGiftDrawBatch(ctx context.Context, cmds []domai
} }
func (r *Repository) executeLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error) { func (r *Repository) executeLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error) {
appCode := appcode.FromContext(ctx)
// lucky_draw_records 的唯一键只能拒绝重复写,无法让两个“结果尚不存在”的首次请求都成功:
// 两个事务可能同时读到空结果,后到者最终只能撞唯一键回滚。原始 command_id 锁行把首次执行
// 串行化;等待者拿到锁后会重新走下方事实探测并完整回读首次结果,绝不重新随机或推进资金状态。
if err := r.lockLuckyGiftCommand(ctx, tx, appCode, cmd.CommandID, nowMS); err != nil {
return nil, err
}
// gift_count 是抽奖次数coin_spent 是本次送礼总扣费;批量路径会把总扣费拆成 N 份单抽金额。 // gift_count 是抽奖次数coin_spent 是本次送礼总扣费;批量路径会把总扣费拆成 N 份单抽金额。
// 单抽仍走原来的严谨路径,避免为了批量优化扩大普通送礼的行为面。 // 单抽仍走原来的严谨路径,避免为了批量优化扩大普通送礼的行为面。
drawCount := cmd.GiftCount drawCount := cmd.GiftCount
@ -227,7 +244,6 @@ func (r *Repository) executeLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx,
} }
return []domain.DrawResult{result}, nil return []domain.DrawResult{result}, nil
} }
appCode := appcode.FromContext(ctx)
if drawCount > 1 { if drawCount > 1 {
// 旧实现把整笔送礼作为一次抽奖并使用原始 command_id 落库;批量拆分上线后遇到这类重试必须直接返回旧事实,不能追加子抽奖造成重复返奖。 // 旧实现把整笔送礼作为一次抽奖并使用原始 command_id 落库;批量拆分上线后遇到这类重试必须直接返回旧事实,不能追加子抽奖造成重复返奖。
if existing, exists, err := r.getLuckyDrawByCommand(ctx, tx, appCode, cmd.CommandID, true); err != nil || exists { if existing, exists, err := r.getLuckyDrawByCommand(ctx, tx, appCode, cmd.CommandID, true); err != nil || exists {
@ -249,6 +265,24 @@ func (r *Repository) executeLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx,
return r.executeOptimizedLuckyGiftDrawBatch(ctx, tx, cmd, drawCount, nowMS) return r.executeOptimizedLuckyGiftDrawBatch(ctx, tx, cmd, drawCount, nowMS)
} }
// lockLuckyGiftCommand 为内部钱包已扣费命令创建稳定的事务互斥点。
// SELECT ... FOR UPDATE 不能锁住尚不存在的 draw record幂等插入锁行后InnoDB 会让同一
// app_code+command_id 的并发事务等待首事务提交或回滚,再由显式 FOR UPDATE 固定后续锁顺序。
func (r *Repository) lockLuckyGiftCommand(ctx context.Context, tx *sql.Tx, appCode, commandID string, nowMS int64) error {
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_gift_command_locks (app_code, command_id, created_at_ms)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE created_at_ms = created_at_ms`, appCode, commandID, nowMS,
); err != nil {
return err
}
var lockedCommandID string
return tx.QueryRowContext(ctx, `
SELECT command_id FROM lucky_gift_command_locks
WHERE app_code = ? AND command_id = ? FOR UPDATE`, appCode, commandID,
).Scan(&lockedCommandID)
}
// collectLuckyBatchDrawsByCommand 只服务批量命令重试:完整读回已提交的子抽奖,再由外层聚合成响应。 // collectLuckyBatchDrawsByCommand 只服务批量命令重试:完整读回已提交的子抽奖,再由外层聚合成响应。
// 这里不重新计算随机结果,避免同一个扣费命令因重试得到不同中奖结果。 // 这里不重新计算随机结果,避免同一个扣费命令因重试得到不同中奖结果。
func (r *Repository) collectLuckyBatchDrawsByCommand(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, drawCount int32) ([]domain.DrawResult, error) { func (r *Repository) collectLuckyBatchDrawsByCommand(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, drawCount int32) ([]domain.DrawResult, error) {
@ -283,6 +317,16 @@ func (r *Repository) executeOptimizedLuckyGiftDrawBatch(ctx context.Context, tx
if !exists || !ruleConfig.Enabled { if !exists || !ruleConfig.Enabled {
return nil, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active") return nil, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
} }
if ruleConfig.StrategyVersion == domain.StrategyDynamicV3 {
// dynamic_v3 必须在同一个事务里推进 P/W 重抽、充值层、大奖资格和六维风控;
// 不能先落入 fixed_v2 再在随机后修结果,否则审计和资金状态都会分叉。
return r.executeDynamicLuckyGiftDrawBatch(ctx, tx, cmd, drawCount, ruleConfig, luckyDynamicPersistence{}, nowMS)
}
if cmd.PaidAtMS <= 0 {
// fixed_v2 在旧 room/wallet 滚动发布期间允许使用 owner 收到请求的 UTC 时间兼容;
// dynamic_v3 在上面的专用路径严格拒绝缺失 wallet paid_at不能走这条降级。
cmd.PaidAtMS = nowMS
}
baseConfig, err := luckyRuntimeConfigFromRuleConfig(ruleConfig) baseConfig, err := luckyRuntimeConfigFromRuleConfig(ruleConfig)
if err != nil { if err != nil {
return nil, err return nil, err
@ -969,22 +1013,32 @@ func luckyDrawRecordArgs(input luckyDrawRecordInput) []any {
// luckyDrawRecordSnapshots 保存的是抽中当刻的可解释状态,不要求成为完整候选空间。 // luckyDrawRecordSnapshots 保存的是抽中当刻的可解释状态,不要求成为完整候选空间。
// 这里重点记录命中奖档、被过滤原因、结算窗口和当时池水位,足够定位“为什么本抽是 0/1/高倍”。 // 这里重点记录命中奖档、被过滤原因、结算窗口和当时池水位,足够定位“为什么本抽是 0/1/高倍”。
func luckyDrawRecordSnapshots(input luckyDrawRecordInput) (string, string, string) { func luckyDrawRecordSnapshots(input luckyDrawRecordInput) (string, string, string) {
candidateSnapshot, _ := json.Marshal(map[string]any{ candidateData := map[string]any{
"base_reward": input.Candidate.BaseReward, "base_reward": input.Candidate.BaseReward,
"limited": input.Limited, "limited": input.Limited,
"multiplier_ppm": input.Candidate.MultiplierPPM, "multiplier_ppm": input.Candidate.MultiplierPPM,
"weight": input.Candidate.Weight, "weight": input.Candidate.Weight,
}) }
poolSnapshot, _ := json.Marshal(map[string]any{ poolData := map[string]any{
"base_balance": input.BasePool.Balance, "base_balance": input.BasePool.Balance,
"base_reserve_floor": input.BasePool.ReserveFloor, "base_reserve_floor": input.BasePool.ReserveFloor,
"base_total_in": input.BasePool.TotalIn, "base_total_in": input.BasePool.TotalIn,
"base_total_out": input.BasePool.TotalOut, "base_total_out": input.BasePool.TotalOut,
}) }
rtpSnapshot, _ := json.Marshal(map[string]any{ rtpData := map[string]any{
"global_window": input.GlobalWindow.WindowIndex, "global_window": input.GlobalWindow.WindowIndex,
"gift_window": input.GiftWindow.WindowIndex, "gift_window": input.GiftWindow.WindowIndex,
}) }
if input.StrategyDecision != nil {
candidateData["dynamic_v3"] = input.StrategyDecision.Trace
poolData["fund_split"] = input.FundSplit
rtpData["user_day"] = input.UserDayRTP
rtpData["user_72h"] = input.User72HourRTP
rtpData["global"] = input.StrategyDecision.NextState.GlobalRTP
}
candidateSnapshot, _ := json.Marshal(candidateData)
poolSnapshot, _ := json.Marshal(poolData)
rtpSnapshot, _ := json.Marshal(rtpData)
return string(candidateSnapshot), string(poolSnapshot), string(rtpSnapshot) return string(candidateSnapshot), string(poolSnapshot), string(rtpSnapshot)
} }
@ -1039,6 +1093,20 @@ func (r *Repository) executeSingleLuckyGiftDraw(ctx context.Context, tx *sql.Tx,
if !exists || !ruleConfig.Enabled { if !exists || !ruleConfig.Enabled {
return domain.DrawResult{}, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active") return domain.DrawResult{}, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
} }
if ruleConfig.StrategyVersion == domain.StrategyDynamicV3 {
results, err := r.executeDynamicLuckyGiftDrawBatch(ctx, tx, cmd, 1, ruleConfig, luckyDynamicPersistence{}, nowMS)
if err != nil {
return domain.DrawResult{}, err
}
if len(results) != 1 {
return domain.DrawResult{}, xerr.New(xerr.Internal, "dynamic lucky gift single draw returned an invalid result count")
}
return results[0], nil
}
if cmd.PaidAtMS <= 0 {
// 仅历史 fixed_v2 接受缺失 paid_at 的滚动发布兼容;新 dynamic_v3 已在专用路径 fail-close。
cmd.PaidAtMS = nowMS
}
config, err := luckyRuntimeConfigFromRuleConfig(ruleConfig) config, err := luckyRuntimeConfigFromRuleConfig(ruleConfig)
if err != nil { if err != nil {
return domain.DrawResult{}, err return domain.DrawResult{}, err
@ -2151,7 +2219,7 @@ func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Conf
if referencePrice <= 0 { if referencePrice <= 0 {
return domain.Config{}, xerr.New(xerr.InvalidArgument, "lucky gift reference price must be positive") return domain.Config{}, xerr.New(xerr.InvalidArgument, "lucky gift reference price must be positive")
} }
// v2 用金币流水定义结算窗口,旧运行窗口仍以抽数推进;这里按参考价格折算成“等价抽数”,实际每抽会在 withCurrentWager 中继续按真实 coin_spent 累计 // fixed_v2 历史窗口仍按参考价格折算成“等价抽数”dynamic_v3 另行读取 SettlementWindowWager按真实 coin_spent 滚动
windowDraws := luckyCeilDiv(ruleConfig.SettlementWindowWager, referencePrice) windowDraws := luckyCeilDiv(ruleConfig.SettlementWindowWager, referencePrice)
if windowDraws <= 0 { if windowDraws <= 0 {
windowDraws = 1 windowDraws = 1
@ -2168,10 +2236,14 @@ func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Conf
PoolID: poolID, PoolID: poolID,
Enabled: ruleConfig.Enabled, Enabled: ruleConfig.Enabled,
RuleVersion: ruleConfig.RuleVersion, RuleVersion: ruleConfig.RuleVersion,
StrategyVersion: ruleConfig.StrategyVersion,
GiftPrice: referencePrice, GiftPrice: referencePrice,
TargetRTPPPM: ruleConfig.TargetRTPPPM, TargetRTPPPM: ruleConfig.TargetRTPPPM,
PoolRatePPM: ruleConfig.PoolRatePPM, PoolRatePPM: ruleConfig.PoolRatePPM,
ProfitRatePPM: ruleConfig.ProfitRatePPM,
AnchorRatePPM: ruleConfig.AnchorRatePPM,
ControlBandPPM: ruleConfig.ControlBandPPM, ControlBandPPM: ruleConfig.ControlBandPPM,
SettlementWindowWager: ruleConfig.SettlementWindowWager,
GlobalWindowDraws: windowDraws, GlobalWindowDraws: windowDraws,
GiftWindowDraws: windowDraws, GiftWindowDraws: windowDraws,
NoviceMaxEquivalentDraws: ruleConfig.NoviceMaxEquivalentDraws, NoviceMaxEquivalentDraws: ruleConfig.NoviceMaxEquivalentDraws,
@ -2180,11 +2252,40 @@ func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Conf
HighWaterPoolMultiple: 2, HighWaterPoolMultiple: 2,
InitialBasePool: referencePrice * (950 + 500 + 2_375), InitialBasePool: referencePrice * (950 + 500 + 2_375),
BasePoolReserve: referencePrice * (100 + 10 + 100), BasePoolReserve: referencePrice * (100 + 10 + 100),
InitialPoolCoins: ruleConfig.InitialPoolCoins,
LossStreakGuarantee: ruleConfig.LossStreakGuarantee,
LowWatermarkCoins: ruleConfig.LowWatermarkCoins,
LowWaterNonzeroFactorPPM: ruleConfig.LowWaterNonzeroFactorPPM,
HighWatermarkCoins: ruleConfig.HighWatermarkCoins,
HighWaterNonzeroFactorPPM: ruleConfig.HighWaterNonzeroFactorPPM,
RechargeBoostWindowMS: ruleConfig.RechargeBoostWindowMS,
RechargeBoostFactorPPM: ruleConfig.RechargeBoostFactorPPM,
JackpotMultiplierPPMs: append([]int64(nil), ruleConfig.JackpotMultiplierPPMs...),
JackpotGlobalRTPMaxPPM: ruleConfig.JackpotGlobalRTPMaxPPM,
JackpotUserDayRTPMaxPPM: ruleConfig.JackpotUserDayRTPMaxPPM,
JackpotUser72hRTPMaxPPM: ruleConfig.JackpotUser72hRTPMaxPPM,
JackpotSpendThresholdCoins: ruleConfig.JackpotSpendThresholdCoins,
MaxJackpotHitsPerUserDay: ruleConfig.MaxJackpotHitsPerUserDay,
MaxSinglePayout: ruleConfig.MaxSinglePayout,
UserHourlyPayoutCap: ruleConfig.UserHourlyPayoutCap,
UserDailyPayoutCap: ruleConfig.UserDailyPayoutCap,
DeviceDailyPayoutCap: ruleConfig.DeviceDailyPayoutCap,
RoomHourlyPayoutCap: ruleConfig.RoomHourlyPayoutCap,
AnchorDailyPayoutCap: ruleConfig.AnchorDailyPayoutCap,
Tiers: tiers, Tiers: tiers,
Stages: ruleConfig.Stages,
UpdatedByAdminID: ruleConfig.CreatedByAdminID, UpdatedByAdminID: ruleConfig.CreatedByAdminID,
CreatedAtMS: ruleConfig.CreatedAtMS, CreatedAtMS: ruleConfig.CreatedAtMS,
UpdatedAtMS: ruleConfig.CreatedAtMS, UpdatedAtMS: ruleConfig.CreatedAtMS,
} }
if config.StrategyVersion == "" {
config.StrategyVersion = domain.StrategyFixedV2
}
if config.StrategyVersion == domain.StrategyDynamicV3 {
// V3 的冷启动资金是绝对金币值且 P=W 可以支付到 0旧版按礼物参考价推导的 reserve 不能继续套用。
config.InitialBasePool = config.InitialPoolCoins
config.BasePoolReserve = 0
}
config.MultiplierPPMs = luckyMultiplierPPMsFromTiers(config.Tiers, config.GiftPrice) config.MultiplierPPMs = luckyMultiplierPPMsFromTiers(config.Tiers, config.GiftPrice)
return config, nil return config, nil
} }
@ -2241,11 +2342,11 @@ func luckyCeilDiv(value, divisor int64) int64 {
func (r *Repository) getLuckyUserState(ctx context.Context, appCode string, userID int64, giftID string) (luckyUserState, error) { func (r *Repository) getLuckyUserState(ctx context.Context, appCode string, userID int64, giftID string) (luckyUserState, error) {
var state luckyUserState var state luckyUserState
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, pending_jackpot_tokens
FROM lucky_user_states FROM lucky_user_states
WHERE app_code = ? AND user_id = ? AND gift_id = ?`, WHERE app_code = ? AND user_id = ? AND gift_id = ?`,
appCode, userID, giftID, appCode, userID, giftID,
).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak) ).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak, &state.PendingJackpotTokens)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return luckyUserState{}, nil return luckyUserState{}, nil
} }
@ -2253,29 +2354,27 @@ func (r *Repository) getLuckyUserState(ctx context.Context, appCode string, user
} }
func (r *Repository) getLuckyUserStateForUpdate(ctx context.Context, tx *sql.Tx, appCode string, userID int64, giftID string, nowMS int64) (luckyUserState, error) { func (r *Repository) getLuckyUserStateForUpdate(ctx context.Context, tx *sql.Tx, appCode string, userID int64, giftID string, nowMS int64) (luckyUserState, error) {
var state luckyUserState // 先用幂等 INSERT 建行再 SELECT FOR UPDATE两个首次抽奖并发时唯一键会让第二个事务等待
err := tx.QueryRowContext(ctx, ` // 而不是两边都先读到 no rows 后由其中一个以 duplicate key 失败。
SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak
FROM lucky_user_states
WHERE app_code = ? AND user_id = ? AND gift_id = ?
FOR UPDATE`,
appCode, userID, giftID,
).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak)
if err == nil {
return state, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return luckyUserState{}, err
}
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_user_states ( INSERT INTO lucky_user_states (
app_code, user_id, gift_id, paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, created_at_ms, updated_at_ms app_code, user_id, gift_id, paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak,
) VALUES (?, ?, ?, 0, 0, 0, 0, ?, ?)`, pending_jackpot_tokens, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, 0, 0, 0, 0, 0, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
appCode, userID, giftID, nowMS, nowMS, appCode, userID, giftID, nowMS, nowMS,
); err != nil { ); err != nil {
return luckyUserState{}, err return luckyUserState{}, err
} }
return luckyUserState{}, nil var state luckyUserState
err := tx.QueryRowContext(ctx, `
SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, pending_jackpot_tokens
FROM lucky_user_states
WHERE app_code = ? AND user_id = ? AND gift_id = ?
FOR UPDATE`,
appCode, userID, giftID,
).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak, &state.PendingJackpotTokens)
return state, err
} }
func (r *Repository) getOpenLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, windowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) { func (r *Repository) getOpenLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, windowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) {
@ -2302,6 +2401,35 @@ func (r *Repository) getOpenLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appC
return r.createLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, window.WindowIndex+1, window.CarryPPM, windowDraws, targetPPM, nowMS) return r.createLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, window.WindowIndex+1, window.CarryPPM, windowDraws, targetPPM, nowMS)
} }
// getOpenLuckyDynamicRTPWindow 只服务 dynamic_v3不可变 rule_version scope 已经固定真实流水阈值,
// 因此窗口是否结束只看 wager_coins。control_window_draws 继续保存 fixed_v2 的兼容观测值,不能参与 V3 滚动判断。
func (r *Repository) getOpenLuckyDynamicRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, windowWager, legacyWindowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) {
if windowWager <= 0 {
return luckyRTPWindow{}, xerr.New(xerr.InvalidArgument, "dynamic lucky gift settlement window wager must be positive")
}
window, exists, err := r.getLatestLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, true)
if err != nil {
return luckyRTPWindow{}, err
}
if !exists {
return r.createLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, 1, 0, legacyWindowDraws, targetPPM, nowMS)
}
if window.Status == "open" && window.WagerCoins < windowWager {
return window, nil
}
if window.Status == "open" {
if _, err := tx.ExecContext(ctx, `
UPDATE lucky_rtp_windows
SET status = 'closed', updated_at_ms = ?
WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`,
nowMS, appCode, scopeType, scopeID, window.WindowIndex,
); err != nil {
return luckyRTPWindow{}, err
}
}
return r.createLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, window.WindowIndex+1, window.CarryPPM, legacyWindowDraws, targetPPM, nowMS)
}
func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, forUpdate bool) (luckyRTPWindow, bool, error) { func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, forUpdate bool) (luckyRTPWindow, bool, error) {
lockSQL := "" lockSQL := ""
if forUpdate { if forUpdate {
@ -2328,51 +2456,51 @@ func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, ap
} }
func (r *Repository) createLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, index, carry, windowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) { func (r *Repository) createLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, index, carry, windowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) {
window := luckyRTPWindow{ // 同一 scope 的首次请求可能并发创建同一 window幂等 INSERT 先以唯一键串行化,
ScopeType: scopeType, // 随后必须重新读取实际行,不能在 duplicate 时返回一个全 0 的本地假快照覆盖已提交进度。
ScopeID: scopeID,
WindowIndex: index,
TargetRTPPPM: targetPPM,
ControlDraws: windowDraws,
CarryPPM: carry,
Status: "open",
}
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_rtp_windows ( INSERT INTO lucky_rtp_windows (
app_code, scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws, app_code, scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws,
paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status, created_at_ms, updated_at_ms paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, ?, 'open', ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, ?, 'open', ?, ?)
appCode, scopeType, scopeID, index, targetPPM, windowDraws, window.CarryPPM, nowMS, nowMS, ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
appCode, scopeType, scopeID, index, targetPPM, windowDraws, carry, nowMS, nowMS,
); err != nil { ); err != nil {
return luckyRTPWindow{}, err return luckyRTPWindow{}, err
} }
return window, nil var window luckyRTPWindow
err := tx.QueryRowContext(ctx, `
SELECT scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws,
paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status
FROM lucky_rtp_windows
WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?
FOR UPDATE`, appCode, scopeType, scopeID, index,
).Scan(&window.ScopeType, &window.ScopeID, &window.WindowIndex, &window.TargetRTPPPM, &window.ControlDraws,
&window.PaidDraws, &window.WagerCoins, &window.TargetPayoutCoins, &window.ActualPayoutCoins, &window.CarryPPM, &window.Status)
return window, err
} }
func (r *Repository) getOrCreateLuckyPool(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, initialBalance, reserveFloor, nowMS int64) (luckyPool, error) { func (r *Repository) getOrCreateLuckyPool(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, initialBalance, reserveFloor, nowMS int64) (luckyPool, error) {
row := tx.QueryRowContext(ctx, ` // 初始注资只能由唯一键胜出的事务写一次;第二个首次请求等待后读取真实余额,不能重复 seed。
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_pools (
app_code, scope_type, scope_id, balance, reserve_floor, total_in, total_out,
profit_total, anchor_income_total, initial_seed_total, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, 0, 0, 0, 0, ?, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
appCode, scopeType, scopeID, initialBalance, reserveFloor, initialBalance, nowMS, nowMS,
); err != nil {
return luckyPool{}, err
}
var pool luckyPool
err := tx.QueryRowContext(ctx, `
SELECT scope_type, scope_id, balance, reserve_floor, total_in, total_out SELECT scope_type, scope_id, balance, reserve_floor, total_in, total_out
FROM lucky_pools FROM lucky_pools
WHERE app_code = ? AND scope_type = ? AND scope_id = ? WHERE app_code = ? AND scope_type = ? AND scope_id = ?
FOR UPDATE`, FOR UPDATE`,
appCode, scopeType, scopeID, appCode, scopeType, scopeID,
) ).Scan(&pool.ScopeType, &pool.ScopeID, &pool.Balance, &pool.ReserveFloor, &pool.TotalIn, &pool.TotalOut)
var pool luckyPool return pool, err
if err := row.Scan(&pool.ScopeType, &pool.ScopeID, &pool.Balance, &pool.ReserveFloor, &pool.TotalIn, &pool.TotalOut); err == nil {
return pool, nil
} else if !errors.Is(err, sql.ErrNoRows) {
return luckyPool{}, err
}
pool = luckyPool{ScopeType: scopeType, ScopeID: scopeID, Balance: initialBalance, ReserveFloor: reserveFloor}
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_pools (
app_code, scope_type, scope_id, balance, reserve_floor, total_in, total_out, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?)`,
appCode, scopeType, scopeID, initialBalance, reserveFloor, nowMS, nowMS,
); err != nil {
return luckyPool{}, err
}
return pool, nil
} }
func (r *Repository) creditLuckyBasePool(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, spent int64, pool *luckyPool, nowMS int64) error { func (r *Repository) creditLuckyBasePool(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, spent int64, pool *luckyPool, nowMS int64) error {

View File

@ -3,7 +3,9 @@ package mysql
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt"
"strings" "strings"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
@ -16,7 +18,7 @@ type luckyRuleQueryer interface {
QueryRowContext(context.Context, string, ...any) *sql.Row QueryRowContext(context.Context, string, ...any) *sql.Row
} }
// GetLuckyGiftRuleConfig 读取指定奖池最新 v2 规则版本;旧抽奖运行表不会参与后台配置读取。 // GetLuckyGiftRuleConfig 读取指定奖池最新不可变规则版本;旧抽奖运行表不会参与后台配置读取。
func (r *Repository) GetLuckyGiftRuleConfig(ctx context.Context, poolID string) (domain.RuleConfig, bool, error) { func (r *Repository) GetLuckyGiftRuleConfig(ctx context.Context, poolID string) (domain.RuleConfig, bool, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return domain.RuleConfig{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") return domain.RuleConfig{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
@ -24,7 +26,7 @@ func (r *Repository) GetLuckyGiftRuleConfig(ctx context.Context, poolID string)
return r.getLuckyGiftRuleConfig(ctx, r.db, appcode.FromContext(ctx), luckyPoolID(poolID), false) return r.getLuckyGiftRuleConfig(ctx, r.db, appcode.FromContext(ctx), luckyPoolID(poolID), false)
} }
// PublishLuckyGiftRuleConfig 只新增不可变版本,不覆盖历史配置;后续 v2 抽奖引擎按版本读取快照。 // PublishLuckyGiftRuleConfig 只新增不可变版本,不覆盖历史配置;运行引擎按 strategy_version 和 rule_version 读取完整快照。
func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error) { func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return domain.RuleConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") return domain.RuleConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
@ -38,6 +40,18 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
appCode := appcode.FromContext(ctx) appCode := appcode.FromContext(ctx)
config.AppCode = appCode config.AppCode = appCode
config.PoolID = luckyPoolID(config.PoolID) config.PoolID = luckyPoolID(config.PoolID)
config.StrategyVersion = strings.ToLower(strings.TrimSpace(config.StrategyVersion))
if config.StrategyVersion == "" {
// 升级前的发布请求没有 strategy_version存储边界再次兜底为 fixed_v2避免绕过 service 的调用写成动态规则。
config.StrategyVersion = domain.StrategyFixedV2
}
if config.JackpotMultiplierPPMs == nil {
config.JackpotMultiplierPPMs = []int64{}
}
jackpotMultiplierPPMsJSON, err := json.Marshal(config.JackpotMultiplierPPMs)
if err != nil {
return domain.RuleConfig{}, fmt.Errorf("marshal lucky gift jackpot multipliers: %w", err)
}
config.RuleVersion = 1 config.RuleVersion = 1
config.CreatedAtMS = nowMS config.CreatedAtMS = nowMS
if config.EffectiveFromMS <= 0 { if config.EffectiveFromMS <= 0 {
@ -61,15 +75,33 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
} }
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_gift_rule_versions ( INSERT INTO lucky_gift_rule_versions (
app_code, pool_id, rule_version, enabled, target_rtp_ppm, pool_rate_ppm, app_code, pool_id, rule_version, enabled, strategy_version, target_rtp_ppm, pool_rate_ppm,
profit_rate_ppm, anchor_rate_ppm,
settlement_window_wager, control_band_ppm, gift_price_reference, settlement_window_wager, control_band_ppm, gift_price_reference,
novice_max_equivalent_draws, normal_max_equivalent_draws, novice_max_equivalent_draws, normal_max_equivalent_draws,
effective_from_ms, created_by_admin_id, created_at_ms effective_from_ms, created_by_admin_id, created_at_ms,
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, initial_pool_coins, loss_streak_guarantee,
config.AppCode, config.PoolID, config.RuleVersion, config.Enabled, config.TargetRTPPPM, config.PoolRatePPM, low_watermark_coins, low_water_nonzero_factor_ppm,
high_watermark_coins, high_water_nonzero_factor_ppm,
recharge_boost_window_ms, recharge_boost_factor_ppm, jackpot_multiplier_ppms,
jackpot_global_rtp_max_ppm, jackpot_user_day_rtp_max_ppm, jackpot_user_72h_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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
config.AppCode, config.PoolID, config.RuleVersion, config.Enabled, config.StrategyVersion, config.TargetRTPPPM, config.PoolRatePPM,
config.ProfitRatePPM, config.AnchorRatePPM,
config.SettlementWindowWager, config.ControlBandPPM, config.GiftPriceReference, config.SettlementWindowWager, config.ControlBandPPM, config.GiftPriceReference,
config.NoviceMaxEquivalentDraws, config.NormalMaxEquivalentDraws, config.NoviceMaxEquivalentDraws, config.NormalMaxEquivalentDraws,
config.EffectiveFromMS, config.CreatedByAdminID, config.CreatedAtMS, config.EffectiveFromMS, config.CreatedByAdminID, config.CreatedAtMS,
config.InitialPoolCoins, config.LossStreakGuarantee,
config.LowWatermarkCoins, config.LowWaterNonzeroFactorPPM,
config.HighWatermarkCoins, config.HighWaterNonzeroFactorPPM,
config.RechargeBoostWindowMS, config.RechargeBoostFactorPPM, jackpotMultiplierPPMsJSON,
config.JackpotGlobalRTPMaxPPM, config.JackpotUserDayRTPMaxPPM, config.JackpotUser72hRTPMaxPPM,
config.JackpotSpendThresholdCoins, config.MaxJackpotHitsPerUserDay,
config.MaxSinglePayout, config.UserHourlyPayoutCap, config.UserDailyPayoutCap,
config.DeviceDailyPayoutCap, config.RoomHourlyPayoutCap, config.AnchorDailyPayoutCap,
); err != nil { ); err != nil {
return domain.RuleConfig{}, err return domain.RuleConfig{}, err
} }
@ -77,10 +109,12 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
for _, tier := range stage.Tiers { for _, tier := range stage.Tiers {
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_gift_stage_tiers ( INSERT INTO lucky_gift_stage_tiers (
app_code, pool_id, rule_version, stage, tier_id, multiplier_ppm, base_weight_ppm, app_code, pool_id, rule_version, stage, min_recharge_7d_coins, min_recharge_30d_coins,
tier_id, multiplier_ppm, base_weight_ppm,
high_water_only, enabled high_water_only, enabled
) VALUES (?,?,?,?,?,?,?,?,?)`, ) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
config.AppCode, config.PoolID, config.RuleVersion, tier.Stage, tier.TierID, tier.MultiplierPPM, tier.BaseWeightPPM, config.AppCode, config.PoolID, config.RuleVersion, tier.Stage, stage.MinRecharge7DCoins, stage.MinRecharge30DCoins,
tier.TierID, tier.MultiplierPPM, tier.BaseWeightPPM,
tier.HighWaterOnly, tier.Enabled, tier.HighWaterOnly, tier.Enabled,
); err != nil { ); err != nil {
return domain.RuleConfig{}, err return domain.RuleConfig{}, err
@ -93,7 +127,7 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
return config, nil return config, nil
} }
// ListLuckyGiftRuleConfigs 返回每个奖池的最新 v2 规则版本,用于后台奖池列表。 // ListLuckyGiftRuleConfigs 返回每个奖池的最新规则版本,用于后台奖池列表。
func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode string) ([]domain.RuleConfig, error) { func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode string) ([]domain.RuleConfig, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
@ -105,10 +139,19 @@ func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode strin
args = append(args, appcode.Normalize(appCode)) args = append(args, appcode.Normalize(appCode))
} }
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT v.app_code, v.pool_id, v.rule_version, v.enabled, v.target_rtp_ppm, v.pool_rate_ppm, SELECT v.app_code, v.pool_id, v.rule_version, v.enabled, v.strategy_version, v.target_rtp_ppm, v.pool_rate_ppm,
v.profit_rate_ppm, v.anchor_rate_ppm,
v.settlement_window_wager, v.control_band_ppm, v.gift_price_reference, v.settlement_window_wager, v.control_band_ppm, v.gift_price_reference,
v.novice_max_equivalent_draws, v.normal_max_equivalent_draws, v.novice_max_equivalent_draws, v.normal_max_equivalent_draws,
v.effective_from_ms, v.created_by_admin_id, v.created_at_ms v.effective_from_ms, v.created_by_admin_id, v.created_at_ms,
v.initial_pool_coins, v.loss_streak_guarantee,
v.low_watermark_coins, v.low_water_nonzero_factor_ppm,
v.high_watermark_coins, v.high_water_nonzero_factor_ppm,
v.recharge_boost_window_ms, v.recharge_boost_factor_ppm, v.jackpot_multiplier_ppms,
v.jackpot_global_rtp_max_ppm, v.jackpot_user_day_rtp_max_ppm, v.jackpot_user_72h_rtp_max_ppm,
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
FROM lucky_gift_rule_versions v FROM lucky_gift_rule_versions v
INNER JOIN ( INNER JOIN (
SELECT app_code, pool_id, MAX(rule_version) AS rule_version SELECT app_code, pool_id, MAX(rule_version) AS rule_version
@ -148,10 +191,19 @@ func (r *Repository) getLuckyGiftRuleConfig(ctx context.Context, q luckyRuleQuer
lockSQL = " FOR UPDATE" lockSQL = " FOR UPDATE"
} }
row := q.QueryRowContext(ctx, ` row := q.QueryRowContext(ctx, `
SELECT app_code, pool_id, rule_version, enabled, target_rtp_ppm, pool_rate_ppm, SELECT app_code, pool_id, rule_version, enabled, strategy_version, target_rtp_ppm, pool_rate_ppm,
profit_rate_ppm, anchor_rate_ppm,
settlement_window_wager, control_band_ppm, gift_price_reference, settlement_window_wager, control_band_ppm, gift_price_reference,
novice_max_equivalent_draws, normal_max_equivalent_draws, novice_max_equivalent_draws, normal_max_equivalent_draws,
effective_from_ms, created_by_admin_id, created_at_ms effective_from_ms, created_by_admin_id, created_at_ms,
initial_pool_coins, loss_streak_guarantee,
low_watermark_coins, low_water_nonzero_factor_ppm,
high_watermark_coins, high_water_nonzero_factor_ppm,
recharge_boost_window_ms, recharge_boost_factor_ppm, jackpot_multiplier_ppms,
jackpot_global_rtp_max_ppm, jackpot_user_day_rtp_max_ppm, jackpot_user_72h_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
FROM lucky_gift_rule_versions FROM lucky_gift_rule_versions
WHERE app_code = ? AND pool_id = ? WHERE app_code = ? AND pool_id = ?
ORDER BY rule_version DESC ORDER BY rule_version DESC
@ -175,7 +227,8 @@ func (r *Repository) getLuckyGiftRuleConfig(ctx context.Context, q luckyRuleQuer
func (r *Repository) getLuckyGiftRuleStages(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, ruleVersion int64) ([]domain.RuleStage, error) { func (r *Repository) getLuckyGiftRuleStages(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, ruleVersion int64) ([]domain.RuleStage, error) {
rows, err := q.QueryContext(ctx, ` rows, err := q.QueryContext(ctx, `
SELECT stage, tier_id, multiplier_ppm, base_weight_ppm, high_water_only, enabled SELECT stage, min_recharge_7d_coins, min_recharge_30d_coins,
tier_id, multiplier_ppm, base_weight_ppm, high_water_only, enabled
FROM lucky_gift_stage_tiers FROM lucky_gift_stage_tiers
WHERE app_code = ? AND pool_id = ? AND rule_version = ? WHERE app_code = ? AND pool_id = ? AND rule_version = ?
ORDER BY FIELD(stage, 'novice', 'normal', 'advanced'), multiplier_ppm ASC, tier_id ASC`, ORDER BY FIELD(stage, 'novice', 'normal', 'advanced'), multiplier_ppm ASC, tier_id ASC`,
@ -189,14 +242,31 @@ func (r *Repository) getLuckyGiftRuleStages(ctx context.Context, q luckyRuleQuer
stages := make([]domain.RuleStage, 0, 3) stages := make([]domain.RuleStage, 0, 3)
for rows.Next() { for rows.Next() {
var tier domain.RuleTier var tier domain.RuleTier
if err := rows.Scan(&tier.Stage, &tier.TierID, &tier.MultiplierPPM, &tier.BaseWeightPPM, &tier.HighWaterOnly, &tier.Enabled); err != nil { var minRecharge7DCoins, minRecharge30DCoins int64
if err := rows.Scan(
&tier.Stage,
&minRecharge7DCoins,
&minRecharge30DCoins,
&tier.TierID,
&tier.MultiplierPPM,
&tier.BaseWeightPPM,
&tier.HighWaterOnly,
&tier.Enabled,
); err != nil {
return nil, err return nil, err
} }
index, exists := stageIndex[tier.Stage] index, exists := stageIndex[tier.Stage]
if !exists { if !exists {
index = len(stages) index = len(stages)
stageIndex[tier.Stage] = index stageIndex[tier.Stage] = index
stages = append(stages, domain.RuleStage{Stage: tier.Stage}) stages = append(stages, domain.RuleStage{
Stage: tier.Stage,
MinRecharge7DCoins: minRecharge7DCoins,
MinRecharge30DCoins: minRecharge30DCoins,
})
} else if stages[index].MinRecharge7DCoins != minRecharge7DCoins || stages[index].MinRecharge30DCoins != minRecharge30DCoins {
// 阈值按 stage 定义但为兼容旧表结构随 tier 冗余保存;同阶段不一致表示数据已被绕过发布入口篡改,不能静默选第一行。
return nil, fmt.Errorf("inconsistent recharge thresholds for lucky gift stage %s", tier.Stage)
} }
stages[index].Tiers = append(stages[index].Tiers, tier) stages[index].Tiers = append(stages[index].Tiers, tier)
} }
@ -205,13 +275,17 @@ func (r *Repository) getLuckyGiftRuleStages(ctx context.Context, q luckyRuleQuer
func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) { func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) {
var config domain.RuleConfig var config domain.RuleConfig
var jackpotMultiplierPPMsJSON []byte
if err := scanner.Scan( if err := scanner.Scan(
&config.AppCode, &config.AppCode,
&config.PoolID, &config.PoolID,
&config.RuleVersion, &config.RuleVersion,
&config.Enabled, &config.Enabled,
&config.StrategyVersion,
&config.TargetRTPPPM, &config.TargetRTPPPM,
&config.PoolRatePPM, &config.PoolRatePPM,
&config.ProfitRatePPM,
&config.AnchorRatePPM,
&config.SettlementWindowWager, &config.SettlementWindowWager,
&config.ControlBandPPM, &config.ControlBandPPM,
&config.GiftPriceReference, &config.GiftPriceReference,
@ -220,8 +294,40 @@ func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) {
&config.EffectiveFromMS, &config.EffectiveFromMS,
&config.CreatedByAdminID, &config.CreatedByAdminID,
&config.CreatedAtMS, &config.CreatedAtMS,
&config.InitialPoolCoins,
&config.LossStreakGuarantee,
&config.LowWatermarkCoins,
&config.LowWaterNonzeroFactorPPM,
&config.HighWatermarkCoins,
&config.HighWaterNonzeroFactorPPM,
&config.RechargeBoostWindowMS,
&config.RechargeBoostFactorPPM,
&jackpotMultiplierPPMsJSON,
&config.JackpotGlobalRTPMaxPPM,
&config.JackpotUserDayRTPMaxPPM,
&config.JackpotUser72hRTPMaxPPM,
&config.JackpotSpendThresholdCoins,
&config.MaxJackpotHitsPerUserDay,
&config.MaxSinglePayout,
&config.UserHourlyPayoutCap,
&config.UserDailyPayoutCap,
&config.DeviceDailyPayoutCap,
&config.RoomHourlyPayoutCap,
&config.AnchorDailyPayoutCap,
); err != nil { ); err != nil {
return domain.RuleConfig{}, err return domain.RuleConfig{}, err
} }
config.StrategyVersion = strings.ToLower(strings.TrimSpace(config.StrategyVersion))
if config.StrategyVersion == "" {
config.StrategyVersion = domain.StrategyFixedV2
}
if len(jackpotMultiplierPPMsJSON) > 0 {
if err := json.Unmarshal(jackpotMultiplierPPMsJSON, &config.JackpotMultiplierPPMs); err != nil {
return domain.RuleConfig{}, fmt.Errorf("decode lucky gift jackpot multipliers: %w", err)
}
}
if config.JackpotMultiplierPPMs == nil {
config.JackpotMultiplierPPMs = []int64{}
}
return config, nil return config, nil
} }

View File

@ -45,6 +45,9 @@ func TestPublishLuckyGiftRuleConfigPersistsVersionsAndTiers(t *testing.T) {
if published.RuleVersion != 1 { if published.RuleVersion != 1 {
t.Fatalf("first publish rule_version = %d, want 1", published.RuleVersion) t.Fatalf("first publish rule_version = %d, want 1", published.RuleVersion)
} }
if published.StrategyVersion != domain.StrategyFixedV2 {
t.Fatalf("legacy request strategy = %q, want fixed_v2", published.StrategyVersion)
}
// 版本必须只增不改:同奖池再次发布要拿到 v2且两版规则和奖档都能按最新版本读回。 // 版本必须只增不改:同奖池再次发布要拿到 v2且两版规则和奖档都能按最新版本读回。
again, err := repo.PublishLuckyGiftRuleConfig(ctx, config, 1_700_000_100_000) again, err := repo.PublishLuckyGiftRuleConfig(ctx, config, 1_700_000_100_000)
@ -69,4 +72,116 @@ func TestPublishLuckyGiftRuleConfigPersistsVersionsAndTiers(t *testing.T) {
if len(latest.Stages) != 1 || len(latest.Stages[0].Tiers) != 1 || latest.Stages[0].Tiers[0].TierID != "novice_1x" { if len(latest.Stages) != 1 || len(latest.Stages[0].Tiers) != 1 || latest.Stages[0].Tiers[0].TierID != "novice_1x" {
t.Fatalf("latest stages = %+v, want single novice tier novice_1x", latest.Stages) t.Fatalf("latest stages = %+v, want single novice tier novice_1x", latest.Stages)
} }
// dynamic_v3 的 JSON 倍率、阶段充值阈值和金额风控必须随不可变版本完整往返;否则服务重启后会静默退回默认策略。
dynamic := config
dynamic.StrategyVersion = domain.StrategyDynamicV3
dynamic.ProfitRatePPM = 10_000
dynamic.AnchorRatePPM = 10_000
dynamic.InitialPoolCoins = 1_000_000
dynamic.LossStreakGuarantee = 5
dynamic.LowWatermarkCoins = 10_000_000
dynamic.LowWaterNonzeroFactorPPM = 700_000
dynamic.HighWatermarkCoins = 20_000_000
dynamic.HighWaterNonzeroFactorPPM = 1_300_000
dynamic.RechargeBoostWindowMS = 300_000
dynamic.RechargeBoostFactorPPM = 1_100_000
dynamic.JackpotMultiplierPPMs = []int64{200_000_000, 500_000_000, 1_000_000_000}
dynamic.JackpotGlobalRTPMaxPPM = 980_000
dynamic.JackpotUserDayRTPMaxPPM = 960_000
dynamic.JackpotUser72hRTPMaxPPM = 960_000
dynamic.JackpotSpendThresholdCoins = 5_000
dynamic.MaxJackpotHitsPerUserDay = 5
dynamic.MaxSinglePayout = 1_000_000
dynamic.UserHourlyPayoutCap = 2_000_000
dynamic.UserDailyPayoutCap = 3_000_000
dynamic.DeviceDailyPayoutCap = 4_000_000
dynamic.RoomHourlyPayoutCap = 5_000_000
dynamic.AnchorDailyPayoutCap = 6_000_000
dynamic.Stages[0].MinRecharge7DCoins = 1
dynamic.Stages[0].MinRecharge30DCoins = 2
third, err := repo.PublishLuckyGiftRuleConfig(ctx, dynamic, 1_700_000_200_000)
if err != nil {
t.Fatalf("publish dynamic rule version: %v", err)
}
if third.RuleVersion != 3 {
t.Fatalf("dynamic rule_version = %d, want 3", third.RuleVersion)
}
loaded, exists, err := repo.GetLuckyGiftRuleConfig(ctx, "lucky")
if err != nil || !exists {
t.Fatalf("get dynamic rule: exists=%v err=%v", exists, err)
}
if loaded.StrategyVersion != domain.StrategyDynamicV3 || loaded.InitialPoolCoins != 1_000_000 || loaded.AnchorDailyPayoutCap != 6_000_000 {
t.Fatalf("dynamic scalar fields were not persisted: %+v", loaded)
}
if len(loaded.JackpotMultiplierPPMs) != 3 || loaded.JackpotMultiplierPPMs[2] != 1_000_000_000 {
t.Fatalf("jackpot multipliers = %v, want [200x 500x 1000x] ppm", loaded.JackpotMultiplierPPMs)
}
if len(loaded.Stages) != 1 || loaded.Stages[0].MinRecharge7DCoins != 1 || loaded.Stages[0].MinRecharge30DCoins != 2 {
t.Fatalf("dynamic stage thresholds were not persisted: %+v", loaded.Stages)
}
}
func TestDynamicStrategyMigrationIsIdempotent(t *testing.T) {
caller := mysqlschema.CallerFile(t, 1)
schema := mysqlschema.New(t, mysqlschema.Config{
EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN",
InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"),
DatabasePrefix: "hy_lucky_gift",
})
migrationPath := mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "migrations", "004_dynamic_strategy.sql")
// initdb 已包含最终结构004 连续运行两次仍必须成功,覆盖全新环境与线上重复发布 migration 两条路径。
runLuckyGiftSQLScript(t, schema.DB, migrationPath)
runLuckyGiftSQLScript(t, schema.DB, migrationPath)
for _, table := range []string{
"lucky_risk_counters", "lucky_user_rtp_hour_buckets", "lucky_user_rtp_boundary_events",
"lucky_user_strategy_days", "lucky_gift_command_locks", "external_lucky_gift_draw_items", "external_lucky_gift_request_locks",
} {
var count int
if err := schema.DB.QueryRow(`
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`, table).Scan(&count); err != nil || count != 1 {
t.Fatalf("migration table %s: count=%d err=%v", table, count, err)
}
}
for _, column := range []struct {
table string
name string
minLength int64
}{
{table: "lucky_user_states", name: "pending_jackpot_tokens"},
{table: "lucky_rtp_windows", name: "scope_id", minLength: 128},
{table: "lucky_risk_counters", name: "scope_id", minLength: 255},
{table: "lucky_gift_command_locks", name: "command_id", minLength: 128},
} {
var count, length int64
if err := schema.DB.QueryRow(`
SELECT COUNT(*), COALESCE(MAX(CHARACTER_MAXIMUM_LENGTH), 0)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
column.table, column.name,
).Scan(&count, &length); err != nil || count != 1 || (column.minLength > 0 && length < column.minLength) {
t.Fatalf("migration column %s.%s count=%d length=%d err=%v", column.table, column.name, count, length, err)
}
}
// 旧发布器只写 v2 原列;数据库默认值和扫描器必须共同把该行解释为 fixed_v2而不是要求动态字段。
if _, err := schema.DB.Exec(`
INSERT INTO lucky_gift_rule_versions (
app_code, pool_id, rule_version, enabled, target_rtp_ppm, pool_rate_ppm,
settlement_window_wager, control_band_ppm, gift_price_reference,
novice_max_equivalent_draws, normal_max_equivalent_draws,
effective_from_ms, created_by_admin_id, created_at_ms
) VALUES ('legacy', 'lucky', 1, 1, 950000, 960000, 1000000, 10000, 100, 2000, 20000, 1, 7, 1)`); err != nil {
t.Fatalf("insert pre-dynamic rule row: %v", err)
}
repo := &Repository{db: schema.DB}
legacy, exists, err := repo.GetLuckyGiftRuleConfig(appcode.WithContext(context.Background(), "legacy"), "lucky")
if err != nil || !exists {
t.Fatalf("read pre-dynamic rule row: exists=%v err=%v", exists, err)
}
if legacy.StrategyVersion != domain.StrategyFixedV2 || len(legacy.JackpotMultiplierPPMs) != 0 {
t.Fatalf("pre-dynamic row decoded as strategy=%q jackpot=%v, want fixed_v2 and empty jackpot", legacy.StrategyVersion, legacy.JackpotMultiplierPPMs)
}
} }

View File

@ -40,6 +40,7 @@ func (s *LuckyGiftServer) CheckLuckyGift(ctx context.Context, req *luckygiftv1.C
GiftId: result.GiftID, GiftId: result.GiftID,
GiftPrice: result.GiftPrice, GiftPrice: result.GiftPrice,
RuleVersion: result.RuleVersion, RuleVersion: result.RuleVersion,
StrategyVersion: result.StrategyVersion,
TargetRtpPpm: result.TargetRTPPPM, TargetRtpPpm: result.TargetRTPPPM,
ExperiencePool: result.ExperiencePool, ExperiencePool: result.ExperiencePool,
}, nil }, nil
@ -88,6 +89,7 @@ func (s *LuckyGiftServer) ExecuteExternalGiftDraw(ctx context.Context, req *luck
result, err := s.svc.ExecuteExternalDraw(ctx, domain.ExternalDrawCommand{ result, err := s.svc.ExecuteExternalDraw(ctx, domain.ExternalDrawCommand{
AppCode: req.GetAppCode(), AppCode: req.GetAppCode(),
ExternalUserID: req.GetExternalUserId(), ExternalUserID: req.GetExternalUserId(),
DeviceID: req.GetDeviceId(),
RequestID: req.GetRequestId(), RequestID: req.GetRequestId(),
GiftCount: req.GetGiftCount(), GiftCount: req.GetGiftCount(),
UnitAmount: req.GetUnitAmount(), UnitAmount: req.GetUnitAmount(),
@ -118,6 +120,12 @@ func luckyDrawCommandFromProto(meta *luckygiftv1.LuckyGiftMeta) domain.DrawComma
PaidAtMS: meta.GetPaidAtMs(), PaidAtMS: meta.GetPaidAtMs(),
VisibleRegionID: meta.GetVisibleRegionId(), VisibleRegionID: meta.GetVisibleRegionId(),
CountryID: meta.GetCountryId(), CountryID: meta.GetCountryId(),
// 充值分层、短时加权与主播风控只消费 wallet-service 已落账的事实,
// lucky-gift-service 不跨 owner 数据库反查并重新解释同一笔交易。
Recharge7DCoins: meta.GetRecharge_7DCoins(),
Recharge30DCoins: meta.GetRecharge_30DCoins(),
LastRechargedAtMS: meta.GetLastRechargedAtMs(),
GiftIncomeCoins: meta.GetGiftIncomeCoins(),
} }
} }
@ -248,6 +256,8 @@ func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.Ru
} }
stages = append(stages, domain.RuleStage{ stages = append(stages, domain.RuleStage{
Stage: stage.GetStage(), Stage: stage.GetStage(),
MinRecharge7DCoins: stage.GetMinRecharge_7DCoins(),
MinRecharge30DCoins: stage.GetMinRecharge_30DCoins(),
Tiers: tiers, Tiers: tiers,
}) })
} }
@ -266,6 +276,29 @@ func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.Ru
EffectiveFromMS: config.GetEffectiveFromMs(), EffectiveFromMS: config.GetEffectiveFromMs(),
CreatedByAdminID: config.GetCreatedByAdminId(), CreatedByAdminID: config.GetCreatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(), CreatedAtMS: config.GetCreatedAtMs(),
StrategyVersion: config.GetStrategyVersion(),
ProfitRatePPM: config.GetProfitRatePpm(),
AnchorRatePPM: config.GetAnchorRatePpm(),
InitialPoolCoins: config.GetInitialPoolCoins(),
LossStreakGuarantee: config.GetLossStreakGuarantee(),
LowWatermarkCoins: config.GetLowWatermarkCoins(),
LowWaterNonzeroFactorPPM: config.GetLowWaterNonzeroFactorPpm(),
HighWatermarkCoins: config.GetHighWatermarkCoins(),
HighWaterNonzeroFactorPPM: config.GetHighWaterNonzeroFactorPpm(),
RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(),
RechargeBoostFactorPPM: config.GetRechargeBoostFactorPpm(),
JackpotMultiplierPPMs: append([]int64(nil), config.GetJackpotMultiplierPpms()...),
JackpotGlobalRTPMaxPPM: config.GetJackpotGlobalRtpMaxPpm(),
JackpotUserDayRTPMaxPPM: config.GetJackpotUserDayRtpMaxPpm(),
JackpotUser72hRTPMaxPPM: config.GetJackpotUser_72HRtpMaxPpm(),
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
MaxSinglePayout: config.GetMaxSinglePayout(),
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(),
RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(),
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(),
Stages: stages, Stages: stages,
} }
} }
@ -286,6 +319,8 @@ func luckyRuleConfigToProto(config domain.RuleConfig) *luckygiftv1.LuckyGiftRule
} }
stages = append(stages, &luckygiftv1.LuckyGiftRuleStage{ stages = append(stages, &luckygiftv1.LuckyGiftRuleStage{
Stage: stage.Stage, Stage: stage.Stage,
MinRecharge_7DCoins: stage.MinRecharge7DCoins,
MinRecharge_30DCoins: stage.MinRecharge30DCoins,
Tiers: tiers, Tiers: tiers,
}) })
} }
@ -304,6 +339,29 @@ func luckyRuleConfigToProto(config domain.RuleConfig) *luckygiftv1.LuckyGiftRule
EffectiveFromMs: config.EffectiveFromMS, EffectiveFromMs: config.EffectiveFromMS,
CreatedByAdminId: config.CreatedByAdminID, CreatedByAdminId: config.CreatedByAdminID,
CreatedAtMs: config.CreatedAtMS, CreatedAtMs: config.CreatedAtMS,
StrategyVersion: config.StrategyVersion,
ProfitRatePpm: config.ProfitRatePPM,
AnchorRatePpm: config.AnchorRatePPM,
InitialPoolCoins: config.InitialPoolCoins,
LossStreakGuarantee: config.LossStreakGuarantee,
LowWatermarkCoins: config.LowWatermarkCoins,
LowWaterNonzeroFactorPpm: config.LowWaterNonzeroFactorPPM,
HighWatermarkCoins: config.HighWatermarkCoins,
HighWaterNonzeroFactorPpm: config.HighWaterNonzeroFactorPPM,
RechargeBoostWindowMs: config.RechargeBoostWindowMS,
RechargeBoostFactorPpm: config.RechargeBoostFactorPPM,
JackpotMultiplierPpms: append([]int64(nil), config.JackpotMultiplierPPMs...),
JackpotGlobalRtpMaxPpm: config.JackpotGlobalRTPMaxPPM,
JackpotUserDayRtpMaxPpm: config.JackpotUserDayRTPMaxPPM,
JackpotUser_72HRtpMaxPpm: config.JackpotUser72hRTPMaxPPM,
JackpotSpendThresholdCoins: config.JackpotSpendThresholdCoins,
MaxJackpotHitsPerUserDay: config.MaxJackpotHitsPerUserDay,
MaxSinglePayout: config.MaxSinglePayout,
UserHourlyPayoutCap: config.UserHourlyPayoutCap,
UserDailyPayoutCap: config.UserDailyPayoutCap,
DeviceDailyPayoutCap: config.DeviceDailyPayoutCap,
RoomHourlyPayoutCap: config.RoomHourlyPayoutCap,
AnchorDailyPayoutCap: config.AnchorDailyPayoutCap,
Stages: stages, Stages: stages,
} }
} }

View File

@ -34,6 +34,9 @@ type Base struct {
GatewayNodeID string `json:"gateway_node_id"` GatewayNodeID string `json:"gateway_node_id"`
// SessionID 记录 gateway 看到的客户端会话,不等同于腾讯云 IM 连接。 // SessionID 记录 gateway 看到的客户端会话,不等同于腾讯云 IM 连接。
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
// DeviceID 是 user-service 绑定到 auth_session 并经 JWT 验签的设备 ID。
// 旧 command log 没有该字段时反序列化为空fixed_v2 仍可重放dynamic_v3 由抽奖 owner fail-close。
DeviceID string `json:"device_id,omitempty"`
// SentAtMS 是客户端请求进入系统的时间,恢复回放时用于还原用户态时间字段。 // SentAtMS 是客户端请求进入系统的时间,恢复回放时用于还原用户态时间字段。
SentAtMS int64 `json:"sent_at_ms"` SentAtMS int64 `json:"sent_at_ms"`
} }
@ -436,6 +439,9 @@ type SendGift struct {
GiftID string `json:"gift_id"` GiftID string `json:"gift_id"`
// PoolID 是幸运礼物奖池标识;为空表示普通礼物或默认幸运奖池。 // PoolID 是幸运礼物奖池标识;为空表示普通礼物或默认幸运奖池。
PoolID string `json:"pool_id,omitempty"` PoolID string `json:"pool_id,omitempty"`
// LuckyGiftStrategyVersion 是 lucky-gift Check 返回的 owner 规则快照room saga 固化它只为
// 在滚动发布期间区分 fixed_v2 兼容与 dynamic_v3 必填 wallet 事实,不参与客户端幂等语义。
LuckyGiftStrategyVersion string `json:"lucky_gift_strategy_version,omitempty"`
// GiftCount 是礼物数量,必须为正数。 // GiftCount 是礼物数量,必须为正数。
GiftCount int32 `json:"gift_count"` GiftCount int32 `json:"gift_count"`
// EntitlementID 是背包送礼要消耗的用户礼物权益 ID普通金币送礼为空。 // EntitlementID 是背包送礼要消耗的用户礼物权益 ID普通金币送礼为空。
@ -649,6 +655,9 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
delete(values, "request_id") delete(values, "request_id")
delete(values, "gateway_node_id") delete(values, "gateway_node_id")
delete(values, "session_id") delete(values, "session_id")
// device_id 是首次请求的可信风控快照,会在 gift operation/command log 中按原值恢复;
// 它不属于客户端业务载荷token 旋转后的同 command_id 重试不应因此冲突。
delete(values, "device_id")
delete(values, "sent_at_ms") delete(values, "sent_at_ms")
// 展示快照来自 gateway 对 user-service 的读取结果,不改变扣费和房间状态语义;重试同一 command_id 时不能因为昵称头像变化冲突。 // 展示快照来自 gateway 对 user-service 的读取结果,不改变扣费和房间状态语义;重试同一 command_id 时不能因为昵称头像变化冲突。
delete(values, "sender_display_profile") delete(values, "sender_display_profile")
@ -682,6 +691,7 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
delete(values, "gift_type_code") delete(values, "gift_type_code")
delete(values, "host_period_diamond_added") delete(values, "host_period_diamond_added")
delete(values, "host_period_cycle_key") delete(values, "host_period_cycle_key")
delete(values, "lucky_gift_strategy_version")
delete(values, "rocket_touched") delete(values, "rocket_touched")
delete(values, "rocket_added_fuel") delete(values, "rocket_added_fuel")
delete(values, "rocket_effective_fuel") delete(values, "rocket_effective_fuel")

View File

@ -7,7 +7,7 @@ import (
func TestSendGiftIdempotencyIgnoresGatewayResolvedSnapshots(t *testing.T) { func TestSendGiftIdempotencyIgnoresGatewayResolvedSnapshots(t *testing.T) {
base := SendGift{ base := SendGift{
Base: Base{CommandID: "combo-1_b1", Room: "room-1", ActorID: 42}, Base: Base{CommandID: "combo-1_b1", Room: "room-1", ActorID: 42, DeviceID: "device-first"},
TargetType: "user", TargetUserIDs: []int64{43}, TargetUserID: 43, TargetType: "user", TargetUserIDs: []int64{43}, TargetUserID: 43,
GiftID: "rose", GiftCount: 9, ComboSessionID: "combo-1", BatchSeq: 1, GiftID: "rose", GiftCount: 9, ComboSessionID: "combo-1", BatchSeq: 1,
SenderRegionID: 1001, SenderCountryID: 840, SenderRegionID: 1001, SenderCountryID: 840,
@ -19,6 +19,7 @@ func TestSendGiftIdempotencyIgnoresGatewayResolvedSnapshots(t *testing.T) {
retry.SenderCountryID = 356 retry.SenderCountryID = 356
retry.TargetHostScopes = []GiftTargetHostScope{{TargetUserID: 43, TargetIsHost: false}} retry.TargetHostScopes = []GiftTargetHostScope{{TargetUserID: 43, TargetIsHost: false}}
retry.SenderDisplayProfile = GiftDisplayProfile{UserID: 42, Username: "after"} retry.SenderDisplayProfile = GiftDisplayProfile{UserID: 42, Username: "after"}
retry.DeviceID = "device-after-token-rotation"
left, err := IdempotencyPayloadForCommand(base) left, err := IdempotencyPayloadForCommand(base)
if err != nil { if err != nil {
@ -33,6 +34,40 @@ func TestSendGiftIdempotencyIgnoresGatewayResolvedSnapshots(t *testing.T) {
} }
} }
func TestSendGiftCommandLogPreservesTrustedDeviceIDAndReadsLegacyPayload(t *testing.T) {
original := SendGift{
Base: Base{
AppCode: "lalu", CommandID: "cmd-device-recovery", Room: "room-1", ActorID: 42,
SessionID: "sess-not-a-device", DeviceID: "device-auth-session-1", SentAtMS: 1_000,
},
TargetType: "user", TargetUserIDs: []int64{43}, TargetUserID: 43,
GiftID: "rose", GiftCount: 1,
}
payload, err := Serialize(original)
if err != nil {
t.Fatalf("serialize send gift device snapshot: %v", err)
}
recovered, err := Deserialize(original.Type(), payload)
if err != nil {
t.Fatalf("deserialize send gift device snapshot: %v", err)
}
got, ok := recovered.(*SendGift)
if !ok || got.DeviceID != "device-auth-session-1" || got.SessionID != "sess-not-a-device" {
t.Fatalf("recovered trusted device snapshot mismatch: %#v", recovered)
}
// 滚动升级前的 command log 不包含 device_id反序列化必须保持可恢复
// 并留下空值让 fixed_v2 兼容、dynamic_v3 在 owner 处明确拒绝。
legacy, err := Deserialize(original.Type(), []byte(`{"app_code":"lalu","command_id":"cmd-legacy","actor_user_id":42,"room_id":"room-1","session_id":"sess-legacy","target_type":"user","target_user_id":43,"gift_id":"rose","gift_count":1}`))
if err != nil {
t.Fatalf("deserialize legacy send gift: %v", err)
}
legacyGift := legacy.(*SendGift)
if legacyGift.DeviceID != "" || legacyGift.SessionID != "sess-legacy" {
t.Fatalf("legacy command must not derive device from session: %+v", legacyGift.Base)
}
}
func TestSendGiftIdempotencyKeepsComboBatchIdentity(t *testing.T) { func TestSendGiftIdempotencyKeepsComboBatchIdentity(t *testing.T) {
base := SendGift{Base: Base{CommandID: "combo-1_b1", Room: "room-1", ActorID: 42}, TargetType: "user", TargetUserIDs: []int64{43}, TargetUserID: 43, GiftID: "rose", GiftCount: 1, ComboSessionID: "combo-1", BatchSeq: 1} base := SendGift{Base: Base{CommandID: "combo-1_b1", Room: "room-1", ActorID: 42}, TargetType: "user", TargetUserIDs: []int64{43}, TargetUserID: 43, GiftID: "rose", GiftCount: 1, ComboSessionID: "combo-1", BatchSeq: 1}
reused := base reused := base

View File

@ -243,6 +243,15 @@ func (f *giftFlow) prepareRoomFacts() error {
if checkResp == nil || !checkResp.GetEnabled() { if checkResp == nil || !checkResp.GetEnabled() {
return xerr.New(xerr.RuleNotActive, "lucky gift rule is not active") return xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
} }
// 规则版本由 lucky-gift owner 判定并随 room saga 命令固化;恢复阶段不重新 Check
// 防止后台切换策略后把一笔已扣费命令从 fixed_v2 兼容语义改成 dynamic_v3。
f.cmd.LuckyGiftStrategyVersion = normalizeLuckyGiftStrategyVersion(checkResp.GetStrategyVersion())
f.settledCommand.LuckyGiftStrategyVersion = f.cmd.LuckyGiftStrategyVersion
if f.cmd.LuckyGiftStrategyVersion == luckyGiftStrategyDynamicV3 && strings.TrimSpace(f.cmd.DeviceID) == "" {
// 新 dynamic_v3 请求在 wallet 扣费前拒绝旧 JWT避免创建一笔因缺设备作用域而无法完成的扣费 saga。
// 已扣费恢复仍由 lucky-gift owner 的同一校验 fail-close不得用 sid/command_id 补值。
return xerr.New(xerr.InvalidArgument, "device_id is required for dynamic lucky gift")
}
// Check 只说明“允许按该 pool 抽奖”,真实价格、区域可用性和扣费仍以 wallet-service 为准。 // Check 只说明“允许按该 pool 抽奖”,真实价格、区域可用性和扣费仍以 wallet-service 为准。
f.luckyEnabled = true f.luckyEnabled = true
} }
@ -289,7 +298,7 @@ func (f *giftFlow) debitAndSettle(now time.Time) error {
f.luckyEnabled = f.s.shouldDrawLuckyGift(f.cmd.PoolID, f.billing.GetGiftTypeCode()) f.luckyEnabled = f.s.shouldDrawLuckyGift(f.cmd.PoolID, f.billing.GetGiftTypeCode())
} }
if !f.cmd.SyntheticLuckyGift && f.luckyEnabled { if !f.cmd.SyntheticLuckyGift && f.luckyEnabled {
f.luckyGifts, err = f.s.executeLuckyGiftDraws(f.ctx, f.req.GetMeta(), f.cmd, f.roomMeta, f.targetBillings, now) f.luckyGifts, err = f.s.executeLuckyGiftDraws(f.ctx, f.req.GetMeta(), f.cmd, f.roomMeta, f.targetBillings)
if err != nil { if err != nil {
// 抽奖失败时整笔送礼失败并依赖 wallet 幂等重试;不能落普通礼物后丢失幸运礼物抽奖事实。 // 抽奖失败时整笔送礼失败并依赖 wallet 幂等重试;不能落普通礼物后丢失幸运礼物抽奖事实。
return err return err

View File

@ -15,28 +15,37 @@ import (
"hyapp/services/room-service/internal/room/command" "hyapp/services/room-service/internal/room/command"
) )
func (s *Service) executeLuckyGiftDraws(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling, now time.Time) ([]*roomv1.LuckyGiftDrawResult, error) { const (
luckyGiftStrategyFixedV2 = "fixed_v2"
luckyGiftStrategyDynamicV3 = "dynamic_v3"
)
func (s *Service) executeLuckyGiftDraws(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling) ([]*roomv1.LuckyGiftDrawResult, error) {
if len(targetBillings) == 0 { if len(targetBillings) == 0 {
return nil, nil return nil, nil
} }
if len(targetBillings) == 1 { if len(targetBillings) == 1 {
result, err := s.executeLuckyGiftDraw(ctx, meta, cmd, roomMeta, targetBillings[0], now) result, err := s.executeLuckyGiftDraw(ctx, meta, cmd, roomMeta, targetBillings[0])
if err != nil { if err != nil {
return nil, err return nil, err
} }
return []*roomv1.LuckyGiftDrawResult{result}, nil return []*roomv1.LuckyGiftDrawResult{result}, nil
} }
return s.executeLuckyGiftDrawBatch(ctx, meta, cmd, roomMeta, targetBillings, now) return s.executeLuckyGiftDrawBatch(ctx, meta, cmd, roomMeta, targetBillings)
} }
func (s *Service) executeLuckyGiftDraw(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBilling giftTargetBilling, now time.Time) (*roomv1.LuckyGiftDrawResult, error) { func (s *Service) executeLuckyGiftDraw(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBilling giftTargetBilling) (*roomv1.LuckyGiftDrawResult, error) {
if targetBilling.Billing == nil { if targetBilling.Billing == nil {
return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty") return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty")
} }
// 抽奖必须按每个收礼目标独立落事实command_id、target_user_id 和 coin_spent 都来自该目标的 wallet 子交易。 // 抽奖必须按每个收礼目标独立落事实command_id、target_user_id 和 coin_spent 都来自该目标的 wallet 子交易。
luckyMeta, err := luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling)
if err != nil {
return nil, err
}
drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &luckygiftv1.ExecuteLuckyGiftDrawRequest{ drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &luckygiftv1.ExecuteLuckyGiftDrawRequest{
LuckyGift: luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling, now), LuckyGift: luckyMeta,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@ -48,14 +57,18 @@ func (s *Service) executeLuckyGiftDraw(ctx context.Context, meta *roomv1.Request
return luckyGiftResultFromProto(drawResp.GetResult(), targetBilling.TargetUserID), nil return luckyGiftResultFromProto(drawResp.GetResult(), targetBilling.TargetUserID), nil
} }
func (s *Service) executeLuckyGiftDrawBatch(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling, now time.Time) ([]*roomv1.LuckyGiftDrawResult, error) { func (s *Service) executeLuckyGiftDrawBatch(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling) ([]*roomv1.LuckyGiftDrawResult, error) {
req := &luckygiftv1.BatchExecuteLuckyGiftDrawRequest{LuckyGifts: make([]*luckygiftv1.LuckyGiftMeta, 0, len(targetBillings))} req := &luckygiftv1.BatchExecuteLuckyGiftDrawRequest{LuckyGifts: make([]*luckygiftv1.LuckyGiftMeta, 0, len(targetBillings))}
for _, targetBilling := range targetBillings { for _, targetBilling := range targetBillings {
if targetBilling.Billing == nil { if targetBilling.Billing == nil {
return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty") return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty")
} }
// 多人送礼只跨服务调用一次activity-service 在一个事务内按这个顺序推进锁和抽奖事实。 luckyMeta, err := luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling)
req.LuckyGifts = append(req.LuckyGifts, luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling, now)) if err != nil {
return nil, err
}
// 多人送礼只跨服务调用一次lucky-gift-service 在一个事务内按这个顺序推进锁和抽奖事实。
req.LuckyGifts = append(req.LuckyGifts, luckyMeta)
} }
resp, err := s.luckyGift.BatchExecuteLuckyGiftDraw(ctx, req) resp, err := s.luckyGift.BatchExecuteLuckyGiftDraw(ctx, req)
if err != nil { if err != nil {
@ -74,25 +87,47 @@ func (s *Service) executeLuckyGiftDrawBatch(ctx context.Context, meta *roomv1.Re
return results, nil return results, nil
} }
func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBilling giftTargetBilling, now time.Time) *luckygiftv1.LuckyGiftMeta { func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBilling giftTargetBilling) (*luckygiftv1.LuckyGiftMeta, error) {
paidAtMS := targetBilling.Billing.GetPaidAtMs()
if paidAtMS <= 0 && normalizeLuckyGiftStrategyVersion(cmd.LuckyGiftStrategyVersion) == luckyGiftStrategyDynamicV3 {
// dynamic_v3 所有时间窗口必须归属 wallet owner 的实际交易;缺字段通常表示 wallet 尚未滚动升级,
// 已扣费 saga 保持可恢复并等待 owner 回执补齐,绝不能拿 room mutate/recovery 时钟继续开奖。
return nil, xerr.New(xerr.Unavailable, "wallet paid_at_ms is required for dynamic lucky gift")
}
return &luckygiftv1.LuckyGiftMeta{ return &luckygiftv1.LuckyGiftMeta{
Meta: luckyGiftMetaFromRoom(ctx, meta), Meta: luckyGiftMetaFromRoom(ctx, meta),
CommandId: targetBilling.CommandID, CommandId: targetBilling.CommandID,
UserId: cmd.ActorUserID(), UserId: cmd.ActorUserID(),
TargetUserId: targetBilling.TargetUserID, TargetUserId: targetBilling.TargetUserID,
// 目前没有独立设备 ID 字段,优先用 session_id没有时退回目标子 command_id保证每次抽奖 scope 不为空。 // DeviceID 已在首次请求进入 gift operation 时随 command.Base 持久化,恢复必须继续使用该值。
DeviceId: luckyGiftDeviceID(cmd, targetBilling.CommandID), // 空值原样传给 ownerfixed_v2 兼容旧 JWTdynamic_v3 拒绝;绝不用 session_id/command_id 伪造设备。
DeviceId: cmd.DeviceID,
RoomId: cmd.RoomID(), RoomId: cmd.RoomID(),
AnchorId: luckyGiftAnchorID(roomMeta), AnchorId: luckyGiftAnchorID(roomMeta),
GiftId: cmd.GiftID, GiftId: cmd.GiftID,
GiftCount: cmd.GiftCount, GiftCount: cmd.GiftCount,
CoinSpent: targetBilling.Billing.GetCoinSpent(), CoinSpent: targetBilling.Billing.GetCoinSpent(),
// 房间链路统一使用 UTC epoch ms不能把本地时区时间传给幸运礼物 RTP 窗口。 // PaidAtMs 只来自当前目标的 wallet transactionbatch 每个 target 也保留自己的 owner 回执,
PaidAtMs: now.UTC().UnixMilli(), // 不使用聚合回执、Room Cell mutate 时钟或 gift_operation.created_at_ms。
PaidAtMs: paidAtMS,
PoolId: cmd.PoolID, PoolId: cmd.PoolID,
VisibleRegionId: roomMeta.VisibleRegionID, VisibleRegionId: roomMeta.VisibleRegionID,
CountryId: cmd.SenderCountryID, CountryId: cmd.SenderCountryID,
// 充值分层和短时加权只消费 wallet 扣费回执里固化的 owner 快照room 不查询充值流水,也不按当前时间重新计算。
Recharge_7DCoins: targetBilling.Billing.GetRechargeSevenDayCoins(),
Recharge_30DCoins: targetBilling.Billing.GetRechargeThirtyDayCoins(),
LastRechargedAtMs: targetBilling.Billing.GetLastRechargedAtMs(),
GiftIncomeCoins: targetBilling.Billing.GetGiftIncomeCoinAmount(),
}, nil
}
func normalizeLuckyGiftStrategyVersion(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
// 旧 lucky-gift 版本没有返回 strategy_version它发布时只有 fixed_v2故仅该空值兼容成 fixed_v2。
return luckyGiftStrategyFixedV2
} }
return value
} }
func (s *Service) shouldDrawLuckyGift(poolID string, giftTypeCode string) bool { func (s *Service) shouldDrawLuckyGift(poolID string, giftTypeCode string) bool {
@ -135,16 +170,6 @@ func luckyGiftMetaFromRoom(ctx context.Context, meta *roomv1.RequestMeta) *lucky
} }
} }
func luckyGiftDeviceID(cmd command.SendGift, fallbackCommandID string) string {
if value := strings.TrimSpace(cmd.SessionID); value != "" {
return value
}
if value := strings.TrimSpace(fallbackCommandID); value != "" {
return value
}
return cmd.ID()
}
func luckyGiftAnchorID(roomMeta RoomMeta) string { func luckyGiftAnchorID(roomMeta RoomMeta) string {
if roomMeta.OwnerUserID <= 0 { if roomMeta.OwnerUserID <= 0 {
return roomMeta.RoomID return roomMeta.RoomID

View File

@ -22,6 +22,8 @@ type concurrentV2Wallet struct {
calls atomic.Int32 calls atomic.Int32
started chan struct{} started chan struct{}
release chan struct{} release chan struct{}
walletPaidAtMS int64
omitPaidAt bool
} }
func (w *concurrentV2Wallet) DebitGift(ctx context.Context, _ *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) { func (w *concurrentV2Wallet) DebitGift(ctx context.Context, _ *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
@ -35,6 +37,10 @@ func (w *concurrentV2Wallet) DebitGift(ctx context.Context, _ *walletv1.DebitGif
case <-w.release: case <-w.release:
} }
} }
paidAtMS := w.walletPaidAtMS
if paidAtMS <= 0 && !w.omitPaidAt {
paidAtMS = 1_700_000_000_000
}
return &walletv1.DebitGiftResponse{ return &walletv1.DebitGiftResponse{
BillingReceiptId: "receipt-v2-stable", BillingReceiptId: "receipt-v2-stable",
TransactionId: "wallet-tx-v2-stable", TransactionId: "wallet-tx-v2-stable",
@ -45,9 +51,38 @@ func (w *concurrentV2Wallet) DebitGift(ctx context.Context, _ *walletv1.DebitGif
BalanceVersion: 17, BalanceVersion: 17,
GiftIncomeBalanceAfter: 25, GiftIncomeBalanceAfter: 25,
GiftTypeCode: "normal", GiftTypeCode: "normal",
PaidAtMs: paidAtMS,
}, nil }, nil
} }
func TestDynamicLuckyGiftRejectsMissingWalletPaidAtAfterDebit(t *testing.T) {
ctx := appcode.WithContext(context.Background(), appcode.Default)
repository := mysqltest.NewRepository(t)
wallet := &concurrentV2Wallet{omitPaidAt: true}
lucky := &recoverableLuckyClient{}
svc := roomservice.New(roomservice.Config{
NodeID: "node-gift-v2-missing-paid-at", LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 1,
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), lucky)
roomID := "room-gift-v2-missing-paid-at"
createRocketRoom(t, ctx, svc, roomID, 351, 9001)
joinRocketRoom(t, ctx, svc, roomID, 352)
request := &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{CommandId: "cmd-v2-missing-paid-at", ActorUserId: 351, RoomId: roomID, AppCode: appcode.Default, DeviceId: "device-v2-missing-paid-at"},
TargetType: "user", TargetUserId: 352, GiftId: "lucky-rose", GiftCount: 1, PoolId: "lucky-pool",
}
if _, err := svc.SendGift(ctx, request); !xerr.IsCode(err, xerr.Unavailable) {
t.Fatalf("dynamic_v3 missing wallet paid_at_ms should fail closed, got %v", err)
}
if lucky.calls.Load() != 0 {
t.Fatalf("dynamic_v3 missing wallet paid_at_ms reached lucky random path %d times", lucky.calls.Load())
}
operation, exists, err := repository.GetGiftOperation(ctx, roomID, request.GetMeta().GetCommandId())
if err != nil || !exists || operation.Status != roomservice.GiftOperationStatusDebited {
t.Fatalf("already debited saga must remain recoverable: exists=%v operation=%+v err=%v", exists, operation, err)
}
}
func TestSendGiftConcurrentCommandReplaysOriginalV2AndKeepsV1Fields(t *testing.T) { func TestSendGiftConcurrentCommandReplaysOriginalV2AndKeepsV1Fields(t *testing.T) {
ctx := appcode.WithContext(context.Background(), appcode.Default) ctx := appcode.WithContext(context.Background(), appcode.Default)
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
@ -120,17 +155,22 @@ func TestSendGiftConcurrentCommandReplaysOriginalV2AndKeepsV1Fields(t *testing.T
type recoverableLuckyClient struct { type recoverableLuckyClient struct {
calls atomic.Int32 calls atomic.Int32
paidAtMS [2]atomic.Int64
} }
func (*recoverableLuckyClient) CheckLuckyGift(context.Context, *luckygiftv1.CheckLuckyGiftRequest) (*luckygiftv1.CheckLuckyGiftResponse, error) { func (*recoverableLuckyClient) CheckLuckyGift(context.Context, *luckygiftv1.CheckLuckyGiftRequest) (*luckygiftv1.CheckLuckyGiftResponse, error) {
return &luckygiftv1.CheckLuckyGiftResponse{Enabled: true, RuleVersion: 1, PoolId: "lucky-pool"}, nil return &luckygiftv1.CheckLuckyGiftResponse{Enabled: true, RuleVersion: 1, PoolId: "lucky-pool", StrategyVersion: "dynamic_v3"}, nil
} }
func (c *recoverableLuckyClient) ExecuteLuckyGiftDraw(_ context.Context, req *luckygiftv1.ExecuteLuckyGiftDrawRequest) (*luckygiftv1.ExecuteLuckyGiftDrawResponse, error) { func (c *recoverableLuckyClient) ExecuteLuckyGiftDraw(_ context.Context, req *luckygiftv1.ExecuteLuckyGiftDrawRequest) (*luckygiftv1.ExecuteLuckyGiftDrawResponse, error) {
if c.calls.Add(1) == 1 { meta := req.GetLuckyGift()
call := c.calls.Add(1)
if call <= 2 {
c.paidAtMS[call-1].Store(meta.GetPaidAtMs())
}
if call == 1 {
return nil, xerr.New(xerr.Unavailable, "temporary lucky outage") return nil, xerr.New(xerr.Unavailable, "temporary lucky outage")
} }
meta := req.GetLuckyGift()
return &luckygiftv1.ExecuteLuckyGiftDrawResponse{Result: &luckygiftv1.LuckyGiftDrawResult{ return &luckygiftv1.ExecuteLuckyGiftDrawResponse{Result: &luckygiftv1.LuckyGiftDrawResult{
DrawId: "draw-recovered", CommandId: meta.GetCommandId(), GiftId: meta.GetGiftId(), PoolId: meta.GetPoolId(), DrawId: "draw-recovered", CommandId: meta.GetCommandId(), GiftId: meta.GetGiftId(), PoolId: meta.GetPoolId(),
MultiplierPpm: 2_000_000, EffectiveRewardCoins: 200, RewardStatus: "granted", CoinBalanceAfter: 1100, MultiplierPpm: 2_000_000, EffectiveRewardCoins: 200, RewardStatus: "granted", CoinBalanceAfter: 1100,
@ -153,13 +193,16 @@ func (c *recoverableLuckyClient) BatchExecuteLuckyGiftDraw(ctx context.Context,
func TestGiftOperationWorkerFinishesDebitedRequestWithoutClientRetry(t *testing.T) { func TestGiftOperationWorkerFinishesDebitedRequestWithoutClientRetry(t *testing.T) {
ctx := appcode.WithContext(context.Background(), appcode.Default) ctx := appcode.WithContext(context.Background(), appcode.Default)
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
wallet := &concurrentV2Wallet{} walletPaidAtMS := time.Date(2026, 7, 12, 23, 59, 59, 700_000_000, time.UTC).UnixMilli()
wallet := &concurrentV2Wallet{walletPaidAtMS: walletPaidAtMS}
lucky := &recoverableLuckyClient{} lucky := &recoverableLuckyClient{}
clock := &fixedRoomRocketClock{now: time.Date(2026, 7, 12, 23, 59, 59, 800_000_000, time.UTC)}
svc := roomservice.New(roomservice.Config{ svc := roomservice.New(roomservice.Config{
NodeID: "node-gift-v2-recovery", NodeID: "node-gift-v2-recovery",
LeaseTTL: 10 * time.Second, LeaseTTL: 48 * time.Hour,
RankLimit: 20, RankLimit: 20,
SnapshotEveryN: 1, SnapshotEveryN: 1,
Clock: clock,
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), lucky) }, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), lucky)
roomID := "room-gift-v2-recovery" roomID := "room-gift-v2-recovery"
@ -167,7 +210,7 @@ func TestGiftOperationWorkerFinishesDebitedRequestWithoutClientRetry(t *testing.
createRocketRoom(t, ctx, svc, roomID, senderID, 9001) createRocketRoom(t, ctx, svc, roomID, senderID, 9001)
joinRocketRoom(t, ctx, svc, roomID, targetID) joinRocketRoom(t, ctx, svc, roomID, targetID)
req := &roomv1.SendGiftRequest{ req := &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{RequestId: "req-v2-recovery", CommandId: "cmd-v2-recovery", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default}, Meta: &roomv1.RequestMeta{RequestId: "req-v2-recovery", CommandId: "cmd-v2-recovery", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default, DeviceId: "device-v2-recovery"},
TargetType: "user", TargetUserId: targetID, GiftId: "lucky-rose", GiftCount: 1, PoolId: "lucky-pool", TargetType: "user", TargetUserId: targetID, GiftId: "lucky-rose", GiftCount: 1, PoolId: "lucky-pool",
} }
if _, err := svc.SendGift(ctx, req); !xerr.IsCode(err, xerr.Unavailable) { if _, err := svc.SendGift(ctx, req); !xerr.IsCode(err, xerr.Unavailable) {
@ -177,6 +220,12 @@ func TestGiftOperationWorkerFinishesDebitedRequestWithoutClientRetry(t *testing.
if err != nil || !exists || operation.Status != roomservice.GiftOperationStatusDebited { if err != nil || !exists || operation.Status != roomservice.GiftOperationStatusDebited {
t.Fatalf("debited saga was not persisted: exists=%v operation=%+v err=%v", exists, operation, err) t.Fatalf("debited saga was not persisted: exists=%v operation=%+v err=%v", exists, operation, err)
} }
// 恢复跨过五分钟加权窗和 UTC 切日后lucky-service 仍必须收到 wallet 首次交易时间;
// saga created_at 与 wallet paid_at 即使只差 100ms 也不是同一个 owner 事实,不能相互替代。
clock.now = clock.now.Add(6 * time.Minute)
if clock.Now().Sub(time.UnixMilli(operation.CreatedAtMS)) <= 5*time.Minute || clock.Now().UTC().Day() == time.UnixMilli(operation.CreatedAtMS).UTC().Day() {
t.Fatalf("test clock did not cross both recovery boundaries: created=%s recovery=%s", time.UnixMilli(operation.CreatedAtMS).UTC(), clock.Now())
}
if _, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{ if _, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{
Meta: &roomv1.RequestMeta{CommandId: "close-during-gift-recovery", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default}, Meta: &roomv1.RequestMeta{CommandId: "close-during-gift-recovery", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default},
}); !xerr.IsCode(err, xerr.Conflict) { }); !xerr.IsCode(err, xerr.Conflict) {
@ -203,6 +252,12 @@ func TestGiftOperationWorkerFinishesDebitedRequestWithoutClientRetry(t *testing.
} }
time.Sleep(20 * time.Millisecond) time.Sleep(20 * time.Millisecond)
} }
if operation.CreatedAtMS == walletPaidAtMS {
t.Fatalf("test must distinguish room saga time from wallet transaction time: %d", walletPaidAtMS)
}
if firstPaidAt, recoveredPaidAt := lucky.paidAtMS[0].Load(), lucky.paidAtMS[1].Load(); firstPaidAt != walletPaidAtMS || recoveredPaidAt != walletPaidAtMS {
t.Fatalf("lucky paid_at_ms drifted from wallet fact across recovery: wallet=%d operation=%d first=%d recovered=%d", walletPaidAtMS, operation.CreatedAtMS, firstPaidAt, recoveredPaidAt)
}
replayed, err := svc.SendGift(ctx, req) replayed, err := svc.SendGift(ctx, req)
if err != nil { if err != nil {

View File

@ -198,6 +198,7 @@ func baseFromMeta(meta *roomv1.RequestMeta) command.Base {
Room: meta.GetRoomId(), Room: meta.GetRoomId(),
GatewayNodeID: meta.GetGatewayNodeId(), GatewayNodeID: meta.GetGatewayNodeId(),
SessionID: meta.GetSessionId(), SessionID: meta.GetSessionId(),
DeviceID: meta.GetDeviceId(),
SentAtMS: meta.GetSentAtMs(), SentAtMS: meta.GetSentAtMs(),
} }
} }
@ -240,6 +241,7 @@ func requestMetaWithRoomID(meta *roomv1.RequestMeta, roomID string) *roomv1.Requ
RoomId: roomID, RoomId: roomID,
GatewayNodeId: meta.GetGatewayNodeId(), GatewayNodeId: meta.GetGatewayNodeId(),
SessionId: meta.GetSessionId(), SessionId: meta.GetSessionId(),
DeviceId: meta.GetDeviceId(),
SentAtMs: meta.GetSentAtMs(), SentAtMs: meta.GetSentAtMs(),
AppCode: meta.GetAppCode(), AppCode: meta.GetAppCode(),
} }

View File

@ -10,6 +10,7 @@ import (
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1" walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/integration" "hyapp/services/room-service/internal/integration"
roomservice "hyapp/services/room-service/internal/room/service" roomservice "hyapp/services/room-service/internal/room/service"
"hyapp/services/room-service/internal/router" "hyapp/services/room-service/internal/router"
@ -21,6 +22,8 @@ type luckyGiftTestClient struct {
draws []*luckygiftv1.ExecuteLuckyGiftDrawRequest draws []*luckygiftv1.ExecuteLuckyGiftDrawRequest
batchDraws []*luckygiftv1.BatchExecuteLuckyGiftDrawRequest batchDraws []*luckygiftv1.BatchExecuteLuckyGiftDrawRequest
drawResults []*luckygiftv1.LuckyGiftDrawResult drawResults []*luckygiftv1.LuckyGiftDrawResult
// checkStrategy 让 room 测试显式区分 fixed_v2 滚动兼容与 dynamic_v3 强设备事实。
checkStrategy string
} }
func (c *luckyGiftTestClient) CheckLuckyGift(_ context.Context, req *luckygiftv1.CheckLuckyGiftRequest) (*luckygiftv1.CheckLuckyGiftResponse, error) { func (c *luckyGiftTestClient) CheckLuckyGift(_ context.Context, req *luckygiftv1.CheckLuckyGiftRequest) (*luckygiftv1.CheckLuckyGiftResponse, error) {
@ -32,6 +35,7 @@ func (c *luckyGiftTestClient) CheckLuckyGift(_ context.Context, req *luckygiftv1
PoolId: req.GetPoolId(), PoolId: req.GetPoolId(),
RuleVersion: 12, RuleVersion: 12,
ExperiencePool: "novice", ExperiencePool: "novice",
StrategyVersion: c.checkStrategy,
}, nil }, nil
} }
@ -116,13 +120,22 @@ func (c *luckyGiftTestClient) BatchExecuteLuckyGiftDraw(_ context.Context, req *
func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) { func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{ wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{
BillingReceiptId: "receipt-lucky", BillingReceiptId: "receipt-lucky",
CoinSpent: 100, CoinSpent: 100,
GiftPointAdded: 100, GiftPointAdded: 100,
HeatValue: 100, HeatValue: 100,
GiftTypeCode: "super_lucky", GiftTypeCode: "super_lucky",
}}} },
{
BillingReceiptId: "receipt-lucky-legacy-token",
CoinSpent: 100,
GiftPointAdded: 100,
HeatValue: 100,
GiftTypeCode: "super_lucky",
},
}}
luckyGift := &luckyGiftTestClient{} luckyGift := &luckyGiftTestClient{}
svc := roomservice.New(roomservice.Config{ svc := roomservice.New(roomservice.Config{
NodeID: "node-lucky-test", NodeID: "node-lucky-test",
@ -143,7 +156,8 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
CommandId: "cmd-lucky", CommandId: "cmd-lucky",
ActorUserId: ownerID, ActorUserId: ownerID,
RoomId: roomID, RoomId: roomID,
SessionId: "device-session-1", SessionId: "sess-must-not-be-device-1",
DeviceId: "device-auth-session-1",
AppCode: appcode.Default, AppCode: appcode.Default,
SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(), SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(),
}, },
@ -171,11 +185,45 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
drawMeta := luckyGift.draws[0].GetLuckyGift() drawMeta := luckyGift.draws[0].GetLuckyGift()
if drawMeta.GetCommandId() != "cmd-lucky" || if drawMeta.GetCommandId() != "cmd-lucky" ||
drawMeta.GetCoinSpent() != 100 || drawMeta.GetCoinSpent() != 100 ||
drawMeta.GetDeviceId() != "device-session-1" || drawMeta.GetDeviceId() != "device-auth-session-1" ||
drawMeta.GetAnchorId() != "101" || drawMeta.GetAnchorId() != "101" ||
drawMeta.GetVisibleRegionId() != 9001 { drawMeta.GetVisibleRegionId() != 9001 {
t.Fatalf("lucky draw meta mismatch: %+v", drawMeta) t.Fatalf("lucky draw meta mismatch: %+v", drawMeta)
} }
// 旧 access JWT 没有 device_id 时room 必须原样传空以保留 fixed_v2 兼容性;
// 不能因为需要一个非空风控键就用 session_id 或 command_id 代替设备。
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{
RequestId: "req-lucky-legacy-token", CommandId: "cmd-lucky-legacy-token",
ActorUserId: ownerID, RoomId: roomID, SessionId: "sess-legacy-not-device",
AppCode: appcode.Default, SentAtMs: time.Date(2026, 5, 20, 12, 1, 0, 0, time.UTC).UnixMilli(),
},
TargetType: "user", TargetUserId: viewerID, GiftId: "rose", GiftCount: 1, PoolId: "super_lucky",
}); err != nil {
t.Fatalf("legacy-token fixed-compatible lucky send failed: %v", err)
}
legacyDraw := luckyGift.draws[len(luckyGift.draws)-1].GetLuckyGift()
if legacyDraw.GetDeviceId() != "" {
t.Fatalf("legacy-token lucky device_id=%q, want empty instead of session/command fallback", legacyDraw.GetDeviceId())
}
luckyGift.checkStrategy = "dynamic_v3"
debitsBeforeDynamicReject := len(wallet.debitRequests)
_, err = svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{
RequestId: "req-lucky-dynamic-missing-device", CommandId: "cmd-lucky-dynamic-missing-device",
ActorUserId: ownerID, RoomId: roomID, SessionId: "sess-still-not-a-device",
AppCode: appcode.Default, SentAtMs: time.Date(2026, 5, 20, 12, 2, 0, 0, time.UTC).UnixMilli(),
},
TargetType: "user", TargetUserId: viewerID, GiftId: "rose", GiftCount: 1, PoolId: "super_lucky",
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("dynamic_v3 missing signed device_id should fail before debit, got %v", err)
}
if len(wallet.debitRequests) != debitsBeforeDynamicReject {
t.Fatalf("dynamic_v3 missing device reached wallet: before=%d after=%d", debitsBeforeDynamicReject, len(wallet.debitRequests))
}
} }
func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) { func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) {
@ -210,7 +258,8 @@ func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) {
CommandId: "cmd-lucky-multi", CommandId: "cmd-lucky-multi",
ActorUserId: ownerID, ActorUserId: ownerID,
RoomId: roomID, RoomId: roomID,
SessionId: "device-session-multi", SessionId: "sess-must-not-be-device-multi",
DeviceId: "device-auth-session-multi",
AppCode: appcode.Default, AppCode: appcode.Default,
SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(), SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(),
}, },
@ -240,6 +289,9 @@ func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) {
if secondDraw.GetCommandId() != "cmd-lucky-multi:target:303" || secondDraw.GetTargetUserId() != secondTargetID || secondDraw.GetCoinSpent() != 200 { if secondDraw.GetCommandId() != "cmd-lucky-multi:target:303" || secondDraw.GetTargetUserId() != secondTargetID || secondDraw.GetCoinSpent() != 200 {
t.Fatalf("second target lucky draw mismatch: %+v", secondDraw) t.Fatalf("second target lucky draw mismatch: %+v", secondDraw)
} }
if firstDraw.GetDeviceId() != "device-auth-session-multi" || secondDraw.GetDeviceId() != "device-auth-session-multi" {
t.Fatalf("multi-target draws must share the signed JWT device scope: first=%q second=%q", firstDraw.GetDeviceId(), secondDraw.GetDeviceId())
}
if resp.GetLuckyGift().GetCommandId() != "cmd-lucky-multi" || if resp.GetLuckyGift().GetCommandId() != "cmd-lucky-multi" ||
resp.GetLuckyGift().GetTargetUserId() != 0 || resp.GetLuckyGift().GetTargetUserId() != 0 ||
resp.GetLuckyGift().GetSelectedTierId() != "batch" || resp.GetLuckyGift().GetSelectedTierId() != "batch" ||

View File

@ -67,7 +67,7 @@ func (s *Service) LoginPassword(ctx context.Context, displayUserID string, passw
} }
s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "") s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "")
token, err := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs) token, err := s.issueToken(user, session.SessionID, session.DeviceID, refreshToken, session.ExpiresAtMs)
if err != nil { if err != nil {
return authdomain.Token{}, err return authdomain.Token{}, err
} }

View File

@ -121,7 +121,7 @@ func (s *Service) QuickCreateAccount(ctx context.Context, password string, regis
}, nil }, nil
} }
s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "") s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "")
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs) token, tokenErr := s.issueToken(user, session.SessionID, session.DeviceID, refreshToken, session.ExpiresAtMs)
if tokenErr != nil { if tokenErr != nil {
return authdomain.Token{}, tokenErr return authdomain.Token{}, tokenErr
} }

View File

@ -40,6 +40,23 @@ func accessTokenExpUnix(t *testing.T, accessToken string) int64 {
return exp.Unix() return exp.Unix()
} }
func accessTokenStringClaim(t *testing.T, accessToken string, name string) string {
t.Helper()
parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}), jwt.WithoutClaimsValidation())
parsed, err := parser.Parse(accessToken, func(token *jwt.Token) (any, error) {
return []byte("test-secret"), nil
})
if err != nil || !parsed.Valid {
t.Fatalf("parse access token claim %s failed: token=%q err=%v", name, accessToken, err)
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
t.Fatalf("access token claim set has unexpected type %T", parsed.Claims)
}
value, _ := claims[name].(string)
return value
}
type memoryDecisionCache struct { type memoryDecisionCache struct {
mu sync.Mutex mu sync.Mutex
ip map[string]authservice.IPDecision ip map[string]authservice.IPDecision
@ -330,6 +347,10 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
if got, want := accessTokenExpUnix(t, thirdToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want { if got, want := accessTokenExpUnix(t, thirdToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want {
t.Fatalf("third-party access token exp mismatch: got=%d want=%d", got, want) t.Fatalf("third-party access token exp mismatch: got=%d want=%d", got, want)
} }
if got := accessTokenStringClaim(t, thirdToken.AccessToken, "device_id"); got != "dev-ios" {
// 登录签发必须使用 auth_session 的设备绑定,不能让下游拿 sid 代替设备风控 scope。
t.Fatalf("third-party access token device_id=%q, want dev-ios", got)
}
if thirdToken.ProfileCompleted || thirdToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) { if thirdToken.ProfileCompleted || thirdToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
t.Fatalf("new third-party user must require onboarding: %+v", thirdToken) t.Fatalf("new third-party user must require onboarding: %+v", thirdToken)
} }
@ -365,6 +386,9 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
if got, want := accessTokenExpUnix(t, loginToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want { if got, want := accessTokenExpUnix(t, loginToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want {
t.Fatalf("password login access token exp mismatch: got=%d want=%d", got, want) t.Fatalf("password login access token exp mismatch: got=%d want=%d", got, want)
} }
if got := accessTokenStringClaim(t, loginToken.AccessToken, "device_id"); got != "ios" {
t.Fatalf("password access token device_id=%q, want ios", got)
}
var auditedAppVersion string var auditedAppVersion string
var auditedBuildNumber string var auditedBuildNumber string
if err := repository.RawDB().QueryRowContext(ctx, ` if err := repository.RawDB().QueryRowContext(ctx, `
@ -402,6 +426,9 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
if got, want := accessTokenExpUnix(t, refreshToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want { if got, want := accessTokenExpUnix(t, refreshToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want {
t.Fatalf("refresh access token exp mismatch: got=%d want=%d", got, want) t.Fatalf("refresh access token exp mismatch: got=%d want=%d", got, want)
} }
if got := accessTokenStringClaim(t, refreshToken.AccessToken, "device_id"); got != "ios" {
t.Fatalf("refreshed access token device_id=%q, want ios", got)
}
if got := cache.revoked["lalu:"+loginToken.SessionID]; got != "REFRESH_ROTATED" { if got := cache.revoked["lalu:"+loginToken.SessionID]; got != "REFRESH_ROTATED" {
t.Fatalf("refresh must denylist rotated access session, got %q", got) t.Fatalf("refresh must denylist rotated access session, got %q", got)
} }
@ -1196,6 +1223,10 @@ func TestIssueAccessTokenForSessionCarriesProfileRequiredOnboardingStatusWithout
if reissued.SessionID != token.SessionID || reissued.RefreshToken != "" { if reissued.SessionID != token.SessionID || reissued.RefreshToken != "" {
t.Fatalf("access resign must reuse session and not mint refresh token: %+v", reissued) t.Fatalf("access resign must reuse session and not mint refresh token: %+v", reissued)
} }
if got := accessTokenStringClaim(t, reissued.AccessToken, "device_id"); got != "dev-ios" {
// access resign 不接受客户端 device_id只能从已校验的 active session 回读原绑定值。
t.Fatalf("reissued access token device_id=%q, want dev-ios", got)
}
} }
func TestIssueAccessTokenForSessionCapsTTLAtSessionExpiry(t *testing.T) { func TestIssueAccessTokenForSessionCapsTTLAtSessionExpiry(t *testing.T) {

View File

@ -246,7 +246,7 @@ func (s *Service) loginExistingThirdParty(ctx context.Context, identity authdoma
s.audit(ctx, meta, user.UserID, loginThird, identity.Provider, resultSuccess, "") s.audit(ctx, meta, user.UserID, loginThird, identity.Provider, resultSuccess, "")
// isNewUser=false 表示本次只创建 session没有创建用户。 // isNewUser=false 表示本次只创建 session没有创建用户。
token, err := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs) token, err := s.issueToken(user, session.SessionID, session.DeviceID, refreshToken, session.ExpiresAtMs)
if err != nil { if err != nil {
return authdomain.Token{}, false, err return authdomain.Token{}, false, err
} }
@ -296,7 +296,7 @@ func (s *Service) registerExistingThirdParty(ctx context.Context, identity authd
} }
s.audit(ctx, meta, updated.UserID, loginThird, identity.Provider, resultSuccess, "") s.audit(ctx, meta, updated.UserID, loginThird, identity.Provider, resultSuccess, "")
token, err := s.issueToken(updated, session.SessionID, refreshToken, session.ExpiresAtMs) token, err := s.issueToken(updated, session.SessionID, session.DeviceID, refreshToken, session.ExpiresAtMs)
if err != nil { if err != nil {
return authdomain.Token{}, false, err return authdomain.Token{}, false, err
} }
@ -354,7 +354,7 @@ func (s *Service) createThirdPartyUser(ctx context.Context, provider string, pro
if err == nil { if err == nil {
// repository 事务成功后,用户、默认短号、三方绑定和首个 session 同时存在。 // repository 事务成功后,用户、默认短号、三方绑定和首个 session 同时存在。
s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "") s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "")
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs) token, tokenErr := s.issueToken(user, session.SessionID, session.DeviceID, refreshToken, session.ExpiresAtMs)
if tokenErr != nil { if tokenErr != nil {
return authdomain.Token{}, false, tokenErr return authdomain.Token{}, false, tokenErr
} }
@ -436,7 +436,7 @@ func (s *Service) createRegisteredThirdPartyUser(ctx context.Context, provider s
err = s.authRepository.CreateThirdPartyUser(ctx, user, displayIdentity, thirdParty, session, inviteBind, maxAccountsPerDevice) err = s.authRepository.CreateThirdPartyUser(ctx, user, displayIdentity, thirdParty, session, inviteBind, maxAccountsPerDevice)
if err == nil { if err == nil {
s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "") s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "")
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs) token, tokenErr := s.issueToken(user, session.SessionID, session.DeviceID, refreshToken, session.ExpiresAtMs)
if tokenErr != nil { if tokenErr != nil {
return authdomain.Token{}, false, tokenErr return authdomain.Token{}, false, tokenErr
} }

View File

@ -80,7 +80,7 @@ func (s *Service) RefreshToken(ctx context.Context, refreshToken string, deviceI
} }
s.audit(ctx, meta, session.UserID, loginRefresh, "", resultSuccess, "") s.audit(ctx, meta, session.UserID, loginRefresh, "", resultSuccess, "")
return s.issueToken(user, newSession.SessionID, newRefreshToken, newSession.ExpiresAtMs) return s.issueToken(user, newSession.SessionID, newSession.DeviceID, newRefreshToken, newSession.ExpiresAtMs)
} }
// IssueAccessTokenForSession 基于当前 active session 重签 access token不轮换 refresh token。 // IssueAccessTokenForSession 基于当前 active session 重签 access token不轮换 refresh token。
@ -122,7 +122,7 @@ func (s *Service) IssueAccessTokenForSession(ctx context.Context, userID int64,
} }
s.audit(ctx, meta, userID, loginAccessResign, "", resultSuccess, "") s.audit(ctx, meta, userID, loginAccessResign, "", resultSuccess, "")
return s.issueToken(user, session.SessionID, "", session.ExpiresAtMs) return s.issueToken(user, session.SessionID, session.DeviceID, "", session.ExpiresAtMs)
} }
// Logout 失效指定 session 或 refresh token。 // Logout 失效指定 session 或 refresh token。
@ -232,7 +232,7 @@ func (s *Service) newSession(appCode string, userID int64, deviceID string) (aut
}, refreshToken, nil }, refreshToken, nil
} }
func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToken string, sessionExpiresAtMs int64) (authdomain.Token, error) { func (s *Service) issueToken(user userdomain.User, sessionID string, deviceID string, refreshToken string, sessionExpiresAtMs int64) (authdomain.Token, error) {
now := s.now() now := s.now()
expiresInSec := s.cfg.AccessTokenTTLSec expiresInSec := s.cfg.AccessTokenTTLSec
expiresAt := now.Add(time.Duration(expiresInSec) * time.Second) expiresAt := now.Add(time.Duration(expiresInSec) * time.Second)
@ -253,6 +253,8 @@ func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToke
onboardingStatus := tokenOnboardingStatus(user) onboardingStatus := tokenOnboardingStatus(user)
// JWT 只携带内部 user_id客户端展示和人工输入统一使用 display_user_id。 // JWT 只携带内部 user_id客户端展示和人工输入统一使用 display_user_id。
// user_id 必须写成字符串:雪花 ID 超过 JS/JSON number 的安全整数范围,写成 number 会在 gateway 解析时丢精度。 // user_id 必须写成字符串:雪花 ID 超过 JS/JSON number 的安全整数范围,写成 number 会在 gateway 解析时丢精度。
// device_id 只能来自已落库 auth_session 的绑定值;心跳/资料完成重签也必须从同一 session 回读,
// 不允许 gateway 用 sid 或业务 command_id 伪造设备风控作用域。
claims := jwt.MapClaims{ claims := jwt.MapClaims{
"iss": s.cfg.Issuer, "iss": s.cfg.Issuer,
"app_code": appcode.Normalize(user.AppCode), "app_code": appcode.Normalize(user.AppCode),
@ -265,6 +267,7 @@ func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToke
"profile_completed": user.ProfileCompleted, "profile_completed": user.ProfileCompleted,
"onboarding_status": onboardingStatus, "onboarding_status": onboardingStatus,
"sid": sessionID, "sid": sessionID,
"device_id": strings.TrimSpace(deviceID),
"typ": "access", "typ": "access",
"iat": now.Unix(), "iat": now.Unix(),
"exp": expiresAt.Unix(), "exp": expiresAt.Unix(),

View File

@ -106,6 +106,14 @@ type Receipt struct {
GiftIncomeCoinAmount int64 GiftIncomeCoinAmount int64
// GiftIncomeBalanceAfter 是收礼人 COIN 入账后的余额;没有返币时为 0。 // GiftIncomeBalanceAfter 是收礼人 COIN 入账后的余额;没有返币时为 0。
GiftIncomeBalanceAfter int64 GiftIncomeBalanceAfter int64
// Recharge* 是扣费事务固化的 sender 充值画像。它们跟随 gift transaction metadata 幂等重放,
// 防止 lucky-gift 因重试时间不同进入另一概率层或丢失“充值后五分钟”加成。
RechargeSevenDayCoins int64
RechargeThirtyDayCoins int64
LastRechargedAtMS int64
// PaidAtMS 是 wallet owner 创建成功交易行的 UTC epoch ms。它跟 transaction_id 一起构成不可变扣费事实,
// room 恢复和 lucky 重放必须复用它,不能改用 saga 创建时间或恢复执行时间。
PaidAtMS int64
} }
// BatchGiftTargetReceipt 保留批量送礼里每个目标的独立交易回执。 // BatchGiftTargetReceipt 保留批量送礼里每个目标的独立交易回执。

View File

@ -360,6 +360,15 @@ func TestBatchDebitGiftSettlesAllTargetsAtomically(t *testing.T) {
if again.Aggregate.CoinSpent != receipt.Aggregate.CoinSpent || again.Targets[0].Receipt.TransactionID != receipt.Targets[0].Receipt.TransactionID || repository.CountRows("wallet_transactions", "command_id IN (?, ?)", "cmd-gift-batch:target:10002", "cmd-gift-batch:target:10003") != 2 { if again.Aggregate.CoinSpent != receipt.Aggregate.CoinSpent || again.Targets[0].Receipt.TransactionID != receipt.Targets[0].Receipt.TransactionID || repository.CountRows("wallet_transactions", "command_id IN (?, ?)", "cmd-gift-batch:target:10002", "cmd-gift-batch:target:10003") != 2 {
t.Fatalf("batch idempotency mismatch: first=%+v again=%+v", receipt, again) t.Fatalf("batch idempotency mismatch: first=%+v again=%+v", receipt, again)
} }
if receipt.Aggregate.PaidAtMS <= 0 || again.Aggregate.PaidAtMS != receipt.Aggregate.PaidAtMS {
t.Fatalf("batch aggregate paid_at_ms drifted: first=%d replay=%d", receipt.Aggregate.PaidAtMS, again.Aggregate.PaidAtMS)
}
for index, target := range receipt.Targets {
walletCreatedAtMS := repository.TransactionCreatedAtMS(target.CommandID)
if target.Receipt.PaidAtMS != walletCreatedAtMS || again.Targets[index].Receipt.PaidAtMS != walletCreatedAtMS || target.Receipt.PaidAtMS != receipt.Aggregate.PaidAtMS {
t.Fatalf("batch target paid_at_ms is not stable wallet fact: target=%s created=%d first=%d replay=%d aggregate=%d", target.CommandID, walletCreatedAtMS, target.Receipt.PaidAtMS, again.Targets[index].Receipt.PaidAtMS, receipt.Aggregate.PaidAtMS)
}
}
for _, userID := range []int64{10002, 10003} { for _, userID := range []int64{10002, 10003} {
balances, err := svc.GetBalances(context.Background(), userID, []string{ledger.AssetCoin}) balances, err := svc.GetBalances(context.Background(), userID, []string{ledger.AssetCoin})
if err != nil { if err != nil {
@ -1537,6 +1546,8 @@ func TestDebitGiftIsIdempotent(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("first debit failed: %v", err) t.Fatalf("first debit failed: %v", err)
} }
// 模拟旧版本 transaction metadata 未写 paid_at_ms重放仍必须读取 wallet_transactions.created_at_ms。
repository.RemoveGiftPaidAtMetadata(command.CommandID)
second, err := svc.DebitGift(context.Background(), command) second, err := svc.DebitGift(context.Background(), command)
if err != nil { if err != nil {
t.Fatalf("second debit failed: %v", err) t.Fatalf("second debit failed: %v", err)
@ -1545,6 +1556,11 @@ func TestDebitGiftIsIdempotent(t *testing.T) {
if first.BillingReceiptID != second.BillingReceiptID || second.BalanceAfter != 80 || second.CoinSpent != 20 { if first.BillingReceiptID != second.BillingReceiptID || second.BalanceAfter != 80 || second.CoinSpent != 20 {
t.Fatalf("idempotency mismatch: first=%+v second=%+v", first, second) t.Fatalf("idempotency mismatch: first=%+v second=%+v", first, second)
} }
transactionCreatedAtMS := repository.TransactionCreatedAtMS(command.CommandID)
if first.PaidAtMS <= 0 || first.PaidAtMS != transactionCreatedAtMS || second.PaidAtMS != transactionCreatedAtMS {
// paid_at_ms 必须来自首次 wallet transaction而不是每次 RPC 重放的服务时钟。
t.Fatalf("gift paid_at_ms is not stable wallet fact: created=%d first=%d second=%d", transactionCreatedAtMS, first.PaidAtMS, second.PaidAtMS)
}
if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 { if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 {
t.Fatalf("idempotent command should write one transaction, got %d", got) t.Fatalf("idempotent command should write one transaction, got %d", got)
} }

View File

@ -31,8 +31,8 @@ func TestGiftSenderBalanceVersionAfterCountsEverySenderLedgerEntry(t *testing.T)
} }
func TestGiftReceiptReplayAndBatchAggregatePreserveBalanceVersion(t *testing.T) { func TestGiftReceiptReplayAndBatchAggregatePreserveBalanceVersion(t *testing.T) {
first := receiptFromGiftMetadata("tx-1", giftMetadata{BillingReceipt: "receipt-1", BalanceAfter: 900, BalanceVersion: 11}) first := receiptFromGiftMetadata("tx-1", giftMetadata{BillingReceipt: "receipt-1", BalanceAfter: 900, BalanceVersion: 11, PaidAtMS: 1_700_000_000_000})
last := receiptFromGiftMetadata("tx-2", giftMetadata{BillingReceipt: "receipt-2", BalanceAfter: 800, BalanceVersion: 12}) last := receiptFromGiftMetadata("tx-2", giftMetadata{BillingReceipt: "receipt-2", BalanceAfter: 800, BalanceVersion: 12, PaidAtMS: 1_700_000_000_000})
if first.BalanceVersion != 11 || last.BalanceVersion != 12 { if first.BalanceVersion != 11 || last.BalanceVersion != 12 {
t.Fatalf("metadata replay lost balance version: first=%+v last=%+v", first, last) t.Fatalf("metadata replay lost balance version: first=%+v last=%+v", first, last)
} }
@ -43,7 +43,17 @@ func TestGiftReceiptReplayAndBatchAggregatePreserveBalanceVersion(t *testing.T)
if err != nil { if err != nil {
t.Fatalf("aggregate gift receipts: %v", err) t.Fatalf("aggregate gift receipts: %v", err)
} }
if aggregate.BalanceAfter != 800 || aggregate.BalanceVersion != 12 { if aggregate.BalanceAfter != 800 || aggregate.BalanceVersion != 12 || aggregate.PaidAtMS != 1_700_000_000_000 {
t.Fatalf("aggregate must expose final sender snapshot: %+v", aggregate) t.Fatalf("aggregate must expose final sender snapshot: %+v", aggregate)
} }
} }
func TestAggregateGiftReceiptsRejectsDifferentWalletPaidTimes(t *testing.T) {
_, err := aggregateGiftReceipts([]ledger.BatchGiftTargetReceipt{
{Receipt: ledger.Receipt{PaidAtMS: 1_700_000_000_000}},
{Receipt: ledger.Receipt{PaidAtMS: 1_700_000_000_001}},
})
if err == nil {
t.Fatal("batch receipts with different wallet paid_at_ms must fail closed")
}
}

View File

@ -110,6 +110,12 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
} }
return ledger.BatchGiftReceipt{Aggregate: aggregate, Targets: targetReceipts}, nil return ledger.BatchGiftReceipt{Aggregate: aggregate, Targets: targetReceipts}, nil
} }
// 批量钱包命令只承载真实用户多目标扣费,不存在 RobotGift 语义;同一 sender 共享一次查询,
// 每个目标交易固化同一快照,避免 room-service 拆成多个 lucky draw 时因目标顺序或重试时刻跨层。
rechargeSnapshot, err := r.getLuckyGiftRechargeSnapshot(ctx, tx, command.SenderUserID, giftConfig.GiftTypeCode, nowMs)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
type targetHostPointPolicy struct { type targetHostPointPolicy struct {
policy walletPolicyInstance policy walletPolicyInstance
@ -304,6 +310,10 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
GiftIncomeRatioPercent: giftIncomeRatio.Percent, GiftIncomeRatioPercent: giftIncomeRatio.Percent,
GiftIncomeRatioRegionID: giftIncomeRatioRegionID, GiftIncomeRatioRegionID: giftIncomeRatioRegionID,
GiftIncomeBalanceAfter: giftIncomeBalanceAfter, GiftIncomeBalanceAfter: giftIncomeBalanceAfter,
RechargeSevenDayCoins: rechargeSnapshot.SevenDayCoins,
RechargeThirtyDayCoins: rechargeSnapshot.ThirtyDayCoins,
LastRechargedAtMS: rechargeSnapshot.LastAtMS,
PaidAtMS: nowMs,
BillingReceipt: billingReceiptID(command.AppCode, target.CommandID), BillingReceipt: billingReceiptID(command.AppCode, target.CommandID),
SenderUserID: command.SenderUserID, SenderUserID: command.SenderUserID,
SenderRegionID: command.SenderRegionID, SenderRegionID: command.SenderRegionID,

View File

@ -67,6 +67,14 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
if err != nil { if err != nil {
return ledger.Receipt{}, err return ledger.Receipt{}, err
} }
rechargeSnapshot := luckyGiftRechargeSnapshot{}
if !command.RobotGift {
// 画像必须在扣费交易内读取并随交易快照落库;若放到 room/lucky 重试阶段实时查询,同一 command_id 可能因时间推进改变分层。
rechargeSnapshot, err = r.getLuckyGiftRechargeSnapshot(ctx, tx, command.SenderUserID, giftConfig.GiftTypeCode, nowMs)
if err != nil {
return ledger.Receipt{}, err
}
}
roomContributionRatio, roomContributionRatioRegionID, err := r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode) roomContributionRatio, roomContributionRatioRegionID, err := r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode)
if err != nil { if err != nil {
@ -247,6 +255,10 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
GiftIncomeRatioPercent: giftIncomeRatio.Percent, GiftIncomeRatioPercent: giftIncomeRatio.Percent,
GiftIncomeRatioRegionID: giftIncomeRatioRegionID, GiftIncomeRatioRegionID: giftIncomeRatioRegionID,
GiftIncomeBalanceAfter: giftIncomeBalanceAfter, GiftIncomeBalanceAfter: giftIncomeBalanceAfter,
RechargeSevenDayCoins: rechargeSnapshot.SevenDayCoins,
RechargeThirtyDayCoins: rechargeSnapshot.ThirtyDayCoins,
LastRechargedAtMS: rechargeSnapshot.LastAtMS,
PaidAtMS: nowMs,
BillingReceipt: billingReceiptID(command.AppCode, command.CommandID), BillingReceipt: billingReceiptID(command.AppCode, command.CommandID),
SenderUserID: command.SenderUserID, SenderUserID: command.SenderUserID,
SenderRegionID: command.SenderRegionID, SenderRegionID: command.SenderRegionID,

View File

@ -100,6 +100,13 @@ func aggregateGiftReceipts(targets []ledger.BatchGiftTargetReceipt) (ledger.Rece
if aggregate.HostPointTemplateVersion == "" { if aggregate.HostPointTemplateVersion == "" {
aggregate.HostPointTemplateVersion = receipt.HostPointTemplateVersion aggregate.HostPointTemplateVersion = receipt.HostPointTemplateVersion
} }
if aggregate.PaidAtMS == 0 {
aggregate.PaidAtMS = receipt.PaidAtMS
} else if receipt.PaidAtMS > 0 && aggregate.PaidAtMS != receipt.PaidAtMS {
// BatchDebitGift 在同一个 MySQL 事务、同一个 nowMs 创建全部目标交易;时间不一致说明
// 回执集合被破坏,不能选一个时间继续让 lucky 把子抽分散到不同 UTC 风控窗口。
return ledger.Receipt{}, fmt.Errorf("wallet batch gift paid_at_ms mismatch")
}
aggregate.BalanceAfter = receipt.BalanceAfter aggregate.BalanceAfter = receipt.BalanceAfter
aggregate.BalanceVersion = receipt.BalanceVersion aggregate.BalanceVersion = receipt.BalanceVersion

View File

@ -0,0 +1,56 @@
package mysql
import (
"context"
"database/sql"
"time"
"hyapp/pkg/appcode"
)
// luckyGiftRechargeSnapshot 是 wallet 在送礼交易内固化给 lucky-gift 的充值画像。
// 它只按单个用户的 (app_code,user_id,created_at_ms) 索引读取最近 30 个 UTC 自然日,
// 不扫描全站充值流水;快照写进 gift transaction metadata 后,幂等重放永远返回首次结果。
type luckyGiftRechargeSnapshot struct {
SevenDayCoins int64
ThirtyDayCoins int64
LastAtMS int64
}
func (r *Repository) getLuckyGiftRechargeSnapshot(ctx context.Context, tx *sql.Tx, userID int64, giftTypeCode string, nowMS int64) (luckyGiftRechargeSnapshot, error) {
if userID <= 0 || !isLuckyGiftType(giftTypeCode) {
return luckyGiftRechargeSnapshot{}, nil
}
now := time.UnixMilli(nowMS).UTC()
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
sevenDayStartMS := dayStart.AddDate(0, 0, -6).UnixMilli()
thirtyDayStartMS := dayStart.AddDate(0, 0, -29).UnixMilli()
// 业务时间范围统一使用 [start_ms,end_ms)。加 1ms 让发生在本事务时钟同一毫秒的已完成充值也属于“含当天”快照。
endMS := nowMS + 1
if endMS < nowMS { // int64 极值只可能来自坏时钟;退回不溢出的上界,避免范围反转。
endMS = nowMS
}
var snapshot luckyGiftRechargeSnapshot
err := tx.QueryRowContext(ctx, `
SELECT
COALESCE(SUM(CASE WHEN created_at_ms >= ? THEN coin_amount ELSE 0 END), 0),
COALESCE(SUM(coin_amount), 0),
COALESCE(MAX(created_at_ms), 0)
FROM wallet_recharge_records
WHERE app_code = ? AND user_id = ?
AND created_at_ms >= ? AND created_at_ms < ?`,
sevenDayStartMS,
appcode.FromContext(ctx), userID,
thirtyDayStartMS, endMS,
).Scan(&snapshot.SevenDayCoins, &snapshot.ThirtyDayCoins, &snapshot.LastAtMS)
return snapshot, err
}
func isLuckyGiftType(giftTypeCode string) bool {
switch giftTypeCode {
case "lucky", "super_lucky":
return true
default:
return false
}
}

View File

@ -0,0 +1,63 @@
package mysql
import (
"context"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"hyapp/pkg/appcode"
)
func TestGetLuckyGiftRechargeSnapshotUsesUTCNaturalDayWindows(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
defer db.Close()
mock.ExpectBegin()
tx, err := db.Begin()
if err != nil {
t.Fatalf("begin transaction: %v", err)
}
defer tx.Rollback()
now := time.Date(2026, 7, 12, 23, 59, 59, 999_000_000, time.UTC)
sevenStart := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC).UnixMilli()
thirtyStart := time.Date(2026, 6, 13, 0, 0, 0, 0, time.UTC).UnixMilli()
query := regexp.QuoteMeta(`
SELECT
COALESCE(SUM(CASE WHEN created_at_ms >= ? THEN coin_amount ELSE 0 END), 0),
COALESCE(SUM(coin_amount), 0),
COALESCE(MAX(created_at_ms), 0)
FROM wallet_recharge_records
WHERE app_code = ? AND user_id = ?
AND created_at_ms >= ? AND created_at_ms < ?`)
mock.ExpectQuery(query).
WithArgs(sevenStart, "fami", int64(42), thirtyStart, now.UnixMilli()+1).
WillReturnRows(sqlmock.NewRows([]string{"seven", "thirty", "last"}).AddRow(700, 3_000, now.Add(-time.Minute).UnixMilli()))
repo := &Repository{db: db}
snapshot, err := repo.getLuckyGiftRechargeSnapshot(appcode.WithContext(context.Background(), "fami"), tx, 42, "super_lucky", now.UnixMilli())
if err != nil {
t.Fatalf("get recharge snapshot: %v", err)
}
if snapshot.SevenDayCoins != 700 || snapshot.ThirtyDayCoins != 3_000 || snapshot.LastAtMS != now.Add(-time.Minute).UnixMilli() {
t.Fatalf("unexpected snapshot: %+v", snapshot)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestGetLuckyGiftRechargeSnapshotSkipsNonLuckyGift(t *testing.T) {
repo := &Repository{}
snapshot, err := repo.getLuckyGiftRechargeSnapshot(context.Background(), nil, 42, "normal", 1)
if err != nil {
t.Fatalf("non-lucky snapshot: %v", err)
}
if snapshot != (luckyGiftRechargeSnapshot{}) {
t.Fatalf("non-lucky gift must not expose recharge profile: %+v", snapshot)
}
}

View File

@ -36,6 +36,13 @@ type giftMetadata struct {
GiftIncomeRatioPercent string `json:"gift_income_ratio_percent,omitempty"` GiftIncomeRatioPercent string `json:"gift_income_ratio_percent,omitempty"`
GiftIncomeRatioRegionID int64 `json:"gift_income_ratio_region_id,omitempty"` GiftIncomeRatioRegionID int64 `json:"gift_income_ratio_region_id,omitempty"`
GiftIncomeBalanceAfter int64 `json:"gift_income_balance_after,omitempty"` GiftIncomeBalanceAfter int64 `json:"gift_income_balance_after,omitempty"`
// 充值画像只写入幸运礼物真实扣费的交易快照;旧交易没有这些字段时 JSON 解码自然回落为 0保持回放兼容。
RechargeSevenDayCoins int64 `json:"recharge_seven_day_coins,omitempty"`
RechargeThirtyDayCoins int64 `json:"recharge_thirty_day_coins,omitempty"`
LastRechargedAtMS int64 `json:"last_recharged_at_ms,omitempty"`
// PaidAtMS 与 wallet_transactions.created_at_ms 同源;写进 metadata 让新交易直接重放,
// 历史 metadata 缺字段时 receipt 查询会从交易主表创建时间补齐。
PaidAtMS int64 `json:"paid_at_ms,omitempty"`
BillingReceipt string `json:"billing_receipt_id"` BillingReceipt string `json:"billing_receipt_id"`
SenderUserID int64 `json:"sender_user_id"` SenderUserID int64 `json:"sender_user_id"`
SenderRegionID int64 `json:"sender_region_id"` SenderRegionID int64 `json:"sender_region_id"`

View File

@ -13,19 +13,23 @@ import (
func (r *Repository) receiptForGiftTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.Receipt, error) { func (r *Repository) receiptForGiftTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.Receipt, error) {
var metadataJSON string var metadataJSON string
var createdAtMS int64
if err := tx.QueryRowContext(ctx, if err := tx.QueryRowContext(ctx,
`SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') `SELECT COALESCE(CAST(metadata_json AS CHAR), '{}'), created_at_ms
FROM wallet_transactions FROM wallet_transactions
WHERE app_code = ? AND transaction_id = ?`, WHERE app_code = ? AND transaction_id = ?`,
appcode.FromContext(ctx), appcode.FromContext(ctx),
transactionID, transactionID,
).Scan(&metadataJSON); err != nil { ).Scan(&metadataJSON, &createdAtMS); err != nil {
return ledger.Receipt{}, err return ledger.Receipt{}, err
} }
var metadata giftMetadata var metadata giftMetadata
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return ledger.Receipt{}, err return ledger.Receipt{}, err
} }
// wallet_transactions.created_at_ms 始终是 owner 权威时间。metadata 的副本只用于首次事务内
// 直接构造回执;幂等回读无条件以主表覆盖,旧交易缺字段和异常副本都不能改变扣费归属。
metadata.PaidAtMS = createdAtMS
return receiptFromGiftMetadata(transactionID, metadata), nil return receiptFromGiftMetadata(transactionID, metadata), nil
} }
@ -67,6 +71,10 @@ func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger
ChargeSource: normalizeGiftChargeSource(metadata.ChargeSource, metadata.EntitlementID), ChargeSource: normalizeGiftChargeSource(metadata.ChargeSource, metadata.EntitlementID),
GiftIncomeCoinAmount: metadata.GiftIncomeCoinAmount, GiftIncomeCoinAmount: metadata.GiftIncomeCoinAmount,
GiftIncomeBalanceAfter: metadata.GiftIncomeBalanceAfter, GiftIncomeBalanceAfter: metadata.GiftIncomeBalanceAfter,
RechargeSevenDayCoins: metadata.RechargeSevenDayCoins,
RechargeThirtyDayCoins: metadata.RechargeThirtyDayCoins,
LastRechargedAtMS: metadata.LastRechargedAtMS,
PaidAtMS: metadata.PaidAtMS,
} }
} }

View File

@ -46,6 +46,34 @@ func (r *Repository) CountRows(table string, where string, args ...any) int {
return count return count
} }
// TransactionCreatedAtMS 读取 wallet owner 固化的交易创建时间,供跨服务契约测试核对回执 paid_at_ms。
// 测试只按唯一 command_id 查询,避免把服务时钟近似值误当成账务事实。
func (r *Repository) TransactionCreatedAtMS(commandID string) int64 {
r.t.Helper()
var createdAtMS int64
if err := r.schema.DB.QueryRowContext(context.Background(), `
SELECT created_at_ms
FROM wallet_transactions
WHERE command_id = ?
`, commandID).Scan(&createdAtMS); err != nil {
r.t.Fatalf("query wallet transaction created_at_ms failed: %v", err)
}
return createdAtMS
}
// RemoveGiftPaidAtMetadata 模拟 paid_at_ms 契约上线前创建的历史 gift transaction。
// 交易主表 created_at_ms 保留不变,重放必须从 owner 主表恢复同一个时间。
func (r *Repository) RemoveGiftPaidAtMetadata(commandID string) {
r.t.Helper()
if _, err := r.schema.DB.ExecContext(context.Background(), `
UPDATE wallet_transactions
SET metadata_json = JSON_REMOVE(metadata_json, '$.paid_at_ms')
WHERE command_id = ?
`, commandID); err != nil {
r.t.Fatalf("remove gift paid_at_ms metadata failed: %v", err)
}
}
// SetRechargeProductCode overwrites product_code to model legacy rows created before code matched product_name. // SetRechargeProductCode overwrites product_code to model legacy rows created before code matched product_name.
func (r *Repository) SetRechargeProductCode(productID int64, productCode string) { func (r *Repository) SetRechargeProductCode(productID int64, productCode string) {
r.t.Helper() r.t.Helper()

View File

@ -159,5 +159,9 @@ func debitGiftResponseFromReceipt(receipt ledger.Receipt) *walletv1.DebitGiftRes
ChargeSource: receipt.ChargeSource, ChargeSource: receipt.ChargeSource,
GiftIncomeCoinAmount: receipt.GiftIncomeCoinAmount, GiftIncomeCoinAmount: receipt.GiftIncomeCoinAmount,
GiftIncomeBalanceAfter: receipt.GiftIncomeBalanceAfter, GiftIncomeBalanceAfter: receipt.GiftIncomeBalanceAfter,
RechargeSevenDayCoins: receipt.RechargeSevenDayCoins,
RechargeThirtyDayCoins: receipt.RechargeThirtyDayCoins,
LastRechargedAtMs: receipt.LastRechargedAtMS,
PaidAtMs: receipt.PaidAtMS,
} }
} }

View File

@ -7,8 +7,8 @@ import (
) )
func TestDebitGiftResponseFromReceiptIncludesBalanceVersion(t *testing.T) { func TestDebitGiftResponseFromReceiptIncludesBalanceVersion(t *testing.T) {
response := debitGiftResponseFromReceipt(ledger.Receipt{BalanceAfter: 1234, BalanceVersion: 17}) response := debitGiftResponseFromReceipt(ledger.Receipt{BalanceAfter: 1234, BalanceVersion: 17, PaidAtMS: 1_700_000_000_000})
if response.GetBalanceAfter() != 1234 || response.GetBalanceVersion() != 17 { if response.GetBalanceAfter() != 1234 || response.GetBalanceVersion() != 17 || response.GetPaidAtMs() != 1_700_000_000_000 {
t.Fatalf("wallet protobuf response lost sender balance snapshot: %+v", response) t.Fatalf("wallet protobuf response lost sender balance snapshot: %+v", response)
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,66 @@
package main
import (
"context"
"errors"
"flag"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
var config demoConfig
flag.StringVar(&config.ListenAddr, "listen", envOr("LUCKY_ROOM_DEMO_LISTEN", "127.0.0.1:8789"), "local demo listen address")
flag.StringVar(&config.GatewayBaseURL, "gateway", envOr("LUCKY_ROOM_DEMO_GATEWAY", "http://127.0.0.1:13000"), "gateway-service base URL")
flag.StringVar(&config.AdminBaseURL, "admin", envOr("LUCKY_ROOM_DEMO_ADMIN", "http://127.0.0.1:13100"), "admin base URL")
flag.StringVar(&config.AdminUsername, "admin-user", envOr("LUCKY_ROOM_DEMO_ADMIN_USER", "admin"), "local admin username")
flag.StringVar(&config.AdminPassword, "admin-password", os.Getenv("LUCKY_ROOM_DEMO_ADMIN_PASSWORD"), "local admin password; prefer the environment variable")
flag.StringVar(&config.WalletAddr, "wallet", envOr("LUCKY_ROOM_DEMO_WALLET", "127.0.0.1:13004"), "wallet-service gRPC address")
flag.StringVar(&config.LuckyGiftAddr, "lucky", envOr("LUCKY_ROOM_DEMO_LUCKY", "127.0.0.1:13013"), "lucky-gift-service gRPC address")
flag.StringVar(&config.AppCode, "app", envOr("LUCKY_ROOM_DEMO_APP", "lalu"), "App scope")
flag.Int64Var(&config.InitialCoins, "initial-coins", 1_000_000, "exact sender COIN balance after fixture setup")
flag.Parse()
if config.AdminPassword == "" {
log.Fatal("LUCKY_ROOM_DEMO_ADMIN_PASSWORD is required; the local page never stores administrator credentials")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
demo, err := newDemoServer(config)
if err != nil {
log.Fatal(err)
}
defer demo.Close()
server := &http.Server{
Addr: config.ListenAddr,
Handler: demo.Routes(),
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
log.Printf("lucky gift voice-room demo: http://%s", config.ListenAddr)
log.Printf("real API upstream: %s", config.GatewayBaseURL)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}
func envOr(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}

View File

@ -0,0 +1,811 @@
package main
import (
"bytes"
"context"
"crypto/rand"
_ "embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httputil"
"net/url"
"strconv"
"strings"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
const (
demoGiftID = "local_lucky_meteor_100"
demoResourceCode = "local_lucky_meteor_resource"
demoPoolID = "local_dynamic_v3"
demoGiftPrice = int64(100)
)
//go:embed index.html
var indexHTML []byte
type demoConfig struct {
ListenAddr string
GatewayBaseURL string
AdminBaseURL string
AdminUsername string
AdminPassword string
WalletAddr string
LuckyGiftAddr string
AppCode string
InitialCoins int64
}
type demoServer struct {
config demoConfig
http *http.Client
proxy *httputil.ReverseProxy
walletConn *grpc.ClientConn
luckyConn *grpc.ClientConn
wallet walletv1.WalletServiceClient
luckyAdmin luckygiftv1.AdminLuckyGiftServiceClient
// setupMu serializes fixture creation because quick accounts, an enabled rule and a room are one local-test unit.
// A failed setup never publishes a half-filled in-memory fixture to the page.
setupMu sync.Mutex
fixture *demoFixture
}
type demoFixture struct {
AppCode string `json:"app_code"`
RoomID string `json:"room_id"`
RoomName string `json:"room_name"`
RegionID int64 `json:"region_id"`
GiftID string `json:"gift_id"`
GiftName string `json:"gift_name"`
GiftPrice int64 `json:"gift_price"`
PoolID string `json:"pool_id"`
RuleVersion int64 `json:"rule_version"`
TargetRTPPPM int64 `json:"target_rtp_ppm"`
InitialCoins int64 `json:"initial_coins"`
SenderToken string `json:"sender_token"`
Sender fixtureUser `json:"sender"`
Host fixtureUser `json:"host"`
CreatedAtMS int64 `json:"created_at_ms"`
hostToken string
}
type fixtureUser struct {
UserID string `json:"user_id"`
DisplayUserID string `json:"display_user_id"`
Name string `json:"name"`
DeviceID string `json:"device_id"`
}
type quickCreateResponse struct {
UID string `json:"uid"`
Account string `json:"account"`
AccessToken string `json:"access_token"`
Token struct {
UserID string `json:"user_id"`
DisplayUserID string `json:"display_user_id"`
AccessToken string `json:"access_token"`
} `json:"token"`
}
type gatewayEnvelope struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Data json.RawMessage `json:"data"`
}
type adminEnvelope struct {
Code any `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
type giftRatioItem struct {
GiftTypeCode string `json:"giftTypeCode"`
RatioPercent string `json:"ratioPercent"`
ReturnCoinRatioPercent string `json:"returnCoinRatioPercent"`
}
type giftRatioList struct {
RegionID int64 `json:"regionId"`
Items []giftRatioItem `json:"items"`
}
func newDemoServer(config demoConfig) (*demoServer, error) {
if strings.TrimSpace(config.AppCode) == "" || config.InitialCoins <= 0 {
return nil, errors.New("app and initial-coins must be configured")
}
gatewayURL, err := url.Parse(strings.TrimRight(config.GatewayBaseURL, "/"))
if err != nil || gatewayURL.Scheme == "" || gatewayURL.Host == "" {
return nil, fmt.Errorf("invalid gateway URL %q", config.GatewayBaseURL)
}
walletConn, err := grpc.NewClient(config.WalletAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, fmt.Errorf("connect wallet-service: %w", err)
}
luckyConn, err := grpc.NewClient(config.LuckyGiftAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
_ = walletConn.Close()
return nil, fmt.Errorf("connect lucky-gift-service: %w", err)
}
proxy := httputil.NewSingleHostReverseProxy(gatewayURL)
originalDirector := proxy.Director
proxy.Director = func(request *http.Request) {
originalDirector(request)
request.Host = gatewayURL.Host
}
proxy.ModifyResponse = func(response *http.Response) error {
response.Header.Set("Cache-Control", "no-store")
return nil
}
server := &demoServer{
config: config,
http: &http.Client{Timeout: 15 * time.Second},
proxy: proxy,
walletConn: walletConn,
luckyConn: luckyConn,
wallet: walletv1.NewWalletServiceClient(walletConn),
luckyAdmin: luckygiftv1.NewAdminLuckyGiftServiceClient(luckyConn),
}
proxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, proxyErr error) {
server.writeError(writer, http.StatusBadGateway, "gateway unavailable: "+proxyErr.Error())
}
return server, nil
}
func (s *demoServer) Close() {
_ = s.walletConn.Close()
_ = s.luckyConn.Close()
}
func (s *demoServer) Routes() http.Handler {
mux := http.NewServeMux()
// 根页面必须使用精确模式;若写成 GET /Go 1.22+ 会把它视为路径前缀,
// 与允许多种 HTTP 方法的 /api/ 反向代理产生歧义并在启动时直接 panic。
mux.HandleFunc("GET /{$}", s.handleIndex)
mux.HandleFunc("GET /index.html", s.handleIndex)
mux.HandleFunc("POST /__local/setup", s.handleSetup)
mux.HandleFunc("POST /__local/heartbeat", s.handleHeartbeat)
mux.HandleFunc("GET /__local/state", s.handleState)
mux.Handle("/api/", s.proxy)
return mux
}
func (s *demoServer) handleIndex(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.Header().Set("Cache-Control", "no-store")
_, _ = writer.Write(indexHTML)
}
func (s *demoServer) handleSetup(writer http.ResponseWriter, request *http.Request) {
s.setupMu.Lock()
defer s.setupMu.Unlock()
ctx, cancel := context.WithTimeout(request.Context(), 35*time.Second)
defer cancel()
fixture := s.fixture
var err error
if fixture == nil {
fixture, err = s.createFixture(ctx)
if err == nil {
s.fixture = fixture
}
} else {
// "准备测试数据" is intentionally a reset boundary: the sender starts at exactly one million coins,
// while the same authenticated users and room remain real owner-service rows for repeatable UI work.
err = s.setSenderBalance(ctx, fixture, s.config.InitialCoins)
if err == nil {
err = s.heartbeatFixture(ctx, fixture)
}
}
if err != nil {
s.writeError(writer, http.StatusBadGateway, err.Error())
return
}
s.writeOK(writer, fixture)
}
func (s *demoServer) createFixture(ctx context.Context) (*demoFixture, error) {
stamp := time.Now().UTC().UnixMilli()
host, hostToken, err := s.quickCreate(ctx, "星河主播", fmt.Sprintf("lucky-demo-host-%d", stamp))
if err != nil {
return nil, fmt.Errorf("create host: %w", err)
}
sender, senderToken, err := s.quickCreate(ctx, "幸运玩家", fmt.Sprintf("lucky-demo-sender-%d", stamp))
if err != nil {
return nil, fmt.Errorf("create sender: %w", err)
}
operatorID, err := parsePositiveInt64(host.UserID, "host user_id")
if err != nil {
return nil, err
}
if err := s.ensureGiftCatalog(ctx, operatorID); err != nil {
return nil, fmt.Errorf("ensure lucky gift: %w", err)
}
roomID, regionID, err := s.createRoom(ctx, hostToken)
if err != nil {
return nil, fmt.Errorf("create voice room: %w", err)
}
if err := s.ensureGiftReturnPolicy(ctx, regionID); err != nil {
return nil, fmt.Errorf("ensure regional lucky gift return: %w", err)
}
rule, err := s.ensureDynamicRule(ctx, operatorID)
if err != nil {
return nil, fmt.Errorf("ensure dynamic_v3 rule: %w", err)
}
// CreateRoom already establishes owner presence, but an explicit JoinRoom keeps the fixture aligned with the
// public client lifecycle and refreshes the same lease before the sender starts interacting with the room.
if err := s.joinRoom(ctx, hostToken, roomID, "host"); err != nil {
return nil, fmt.Errorf("join host: %w", err)
}
if err := s.joinRoom(ctx, senderToken, roomID, "sender"); err != nil {
return nil, fmt.Errorf("join sender: %w", err)
}
fixture := &demoFixture{
AppCode: s.config.AppCode,
RoomID: roomID,
RoomName: "星河幸运房",
RegionID: regionID,
GiftID: demoGiftID,
GiftName: "幸运流星",
GiftPrice: demoGiftPrice,
PoolID: demoPoolID,
RuleVersion: rule.GetRuleVersion(),
TargetRTPPPM: rule.GetTargetRtpPpm(),
InitialCoins: s.config.InitialCoins,
SenderToken: senderToken,
Sender: sender,
Host: host,
CreatedAtMS: time.Now().UTC().UnixMilli(),
hostToken: hostToken,
}
if err := s.setSenderBalance(ctx, fixture, s.config.InitialCoins); err != nil {
return nil, err
}
if err := s.assertGiftPanel(ctx, fixture); err != nil {
return nil, err
}
return fixture, nil
}
func (s *demoServer) quickCreate(ctx context.Context, name, deviceID string) (fixtureUser, string, error) {
body := map[string]any{
"password": "LocalLucky#2026",
"username": name,
"country": "SG",
"gender": "unknown",
"device_id": deviceID,
// 账号注册协议当前只接受 android/ios页面仍通过本地 Web 调用 gateway
// 这里的 android 仅是落库的真实测试账号平台快照,不伪造第三方凭证。
"platform": "android",
"language": "zh-CN",
"timezone": "Asia/Shanghai",
"source": "quick_account",
"install_channel": "local_lucky_room_demo",
}
var data quickCreateResponse
if err := s.gatewayJSON(ctx, http.MethodPost, "/api/v1/auth/account/quick-create", "", body, &data); err != nil {
return fixtureUser{}, "", err
}
token := data.AccessToken
if token == "" {
token = data.Token.AccessToken
}
userID := strings.TrimSpace(data.Token.UserID)
displayID := strings.TrimSpace(data.UID)
if displayID == "" {
displayID = strings.TrimSpace(data.Token.DisplayUserID)
}
if userID == "" || displayID == "" || token == "" {
return fixtureUser{}, "", fmt.Errorf("quick-create response is incomplete")
}
if _, err := strconv.ParseInt(userID, 10, 64); err != nil {
return fixtureUser{}, "", fmt.Errorf("quick-create user_id is invalid: %w", err)
}
return fixtureUser{UserID: userID, DisplayUserID: displayID, Name: name, DeviceID: deviceID}, token, nil
}
func (s *demoServer) createRoom(ctx context.Context, token string) (string, int64, error) {
body := map[string]any{
"command_id": commandID("room-create"),
"seat_count": 10,
"mode": "voice",
"room_name": "星河幸运房",
"room_avatar": "https://cdn.example.com/room/lucky-gift-demo.png",
"room_description": "真实用户、真实房间、真实钱包的 dynamic_v3 幸运礼物联调房",
}
var data struct {
Room struct {
RoomID string `json:"room_id"`
VisibleRegionID int64 `json:"visible_region_id"`
} `json:"room"`
}
if err := s.gatewayJSON(ctx, http.MethodPost, "/api/v1/rooms/create", token, body, &data); err != nil {
return "", 0, err
}
if strings.TrimSpace(data.Room.RoomID) == "" {
return "", 0, errors.New("create room response is missing room_id")
}
if data.Room.VisibleRegionID <= 0 {
return "", 0, errors.New("create room response is missing visible_region_id")
}
return data.Room.RoomID, data.Room.VisibleRegionID, nil
}
func (s *demoServer) joinRoom(ctx context.Context, token, roomID, role string) error {
return s.gatewayJSON(ctx, http.MethodPost, "/api/v1/rooms/join", token, map[string]any{
"room_id": roomID,
"command_id": commandID("room-join-" + role),
"role": "audience",
"response_mode": "lite",
}, nil)
}
func (s *demoServer) heartbeatFixture(ctx context.Context, fixture *demoFixture) error {
for role, token := range map[string]string{"host": fixture.hostToken, "sender": fixture.SenderToken} {
if err := s.gatewayJSON(ctx, http.MethodPost, "/api/v1/rooms/heartbeat", token, map[string]any{
"room_id": fixture.RoomID,
"command_id": commandID("room-heartbeat-" + role),
}, nil); err != nil {
return fmt.Errorf("heartbeat %s: %w", role, err)
}
}
return nil
}
func (s *demoServer) handleHeartbeat(writer http.ResponseWriter, request *http.Request) {
s.setupMu.Lock()
defer s.setupMu.Unlock()
if s.fixture == nil {
s.writeError(writer, http.StatusConflict, "fixture is not ready")
return
}
ctx, cancel := context.WithTimeout(request.Context(), 10*time.Second)
defer cancel()
if err := s.heartbeatFixture(ctx, s.fixture); err != nil {
s.writeError(writer, http.StatusBadGateway, err.Error())
return
}
s.writeOK(writer, map[string]any{"room_id": s.fixture.RoomID, "heartbeat_at_ms": time.Now().UTC().UnixMilli()})
}
func (s *demoServer) ensureGiftCatalog(ctx context.Context, operatorID int64) error {
resources, err := s.wallet.ListResources(ctx, &walletv1.ListResourcesRequest{
RequestId: commandID("resource-list"), AppCode: s.config.AppCode, ResourceType: "gift", Keyword: demoResourceCode, Page: 1, PageSize: 100,
})
if err != nil {
return err
}
var resourceID int64
for _, resource := range resources.GetResources() {
if resource.GetResourceCode() == demoResourceCode {
resourceID = resource.GetResourceId()
break
}
}
if resourceID == 0 {
created, createErr := s.wallet.CreateResource(ctx, &walletv1.CreateResourceRequest{
RequestId: commandID("resource-create"), AppCode: s.config.AppCode,
ResourceCode: demoResourceCode, ResourceType: "gift", Name: "幸运流星", Status: "active",
// 礼物资源若被运营发放会累加背包数量grant_strategy 使用钱包域的明确枚举,
// 不能把展示层的“永久”概念写进资源 owner 的协议。
Grantable: true, GrantStrategy: "increase_quantity", UsageScopes: []string{"gift_panel"},
MetadataJson: `{"local_demo":true,"visual":"meteor"}`, SortOrder: 10, OperatorUserId: operatorID,
PriceType: "coin", CoinPrice: demoGiftPrice,
})
if createErr != nil {
return createErr
}
resourceID = created.GetResource().GetResourceId()
}
gifts, err := s.wallet.ListGiftConfigs(ctx, &walletv1.ListGiftConfigsRequest{
RequestId: commandID("gift-list"), AppCode: s.config.AppCode, Keyword: demoGiftID, Page: 1, PageSize: 100,
})
if err != nil {
return err
}
for _, gift := range gifts.GetGifts() {
if gift.GetGiftId() != demoGiftID {
continue
}
_, err = s.wallet.UpdateGiftConfig(ctx, &walletv1.UpdateGiftConfigRequest{
RequestId: commandID("gift-update"), AppCode: s.config.AppCode, GiftId: demoGiftID,
ResourceId: resourceID, Status: "active", Name: "幸运流星", SortOrder: 10,
PresentationJson: `{"lucky_pool_id":"` + demoPoolID + `","visual":"meteor"}`,
PriceVersion: "local-dynamic-v3", CoinPrice: demoGiftPrice, HeatValue: demoGiftPrice,
OperatorUserId: operatorID, RegionIds: []int64{0}, GiftTypeCode: "super_lucky", ChargeAssetType: "COIN",
})
return err
}
_, err = s.wallet.CreateGiftConfig(ctx, &walletv1.CreateGiftConfigRequest{
RequestId: commandID("gift-create"), AppCode: s.config.AppCode, GiftId: demoGiftID,
ResourceId: resourceID, Status: "active", Name: "幸运流星", SortOrder: 10,
PresentationJson: `{"lucky_pool_id":"` + demoPoolID + `","visual":"meteor"}`,
PriceVersion: "local-dynamic-v3", CoinPrice: demoGiftPrice, HeatValue: demoGiftPrice,
OperatorUserId: operatorID, RegionIds: []int64{0}, GiftTypeCode: "super_lucky", ChargeAssetType: "COIN",
})
return err
}
func (s *demoServer) ensureGiftReturnPolicy(ctx context.Context, regionID int64) error {
if regionID <= 0 {
return errors.New("room region must be positive")
}
adminToken, err := s.adminLogin(ctx)
if err != nil {
return err
}
var current giftRatioList
path := "/api/v1/admin/operations/gift-diamond-ratios?region_id=" + strconv.FormatInt(regionID, 10)
if err := s.adminJSON(ctx, http.MethodGet, path, adminToken, nil, &current); err != nil {
return err
}
ratios := make(map[string]any, len(current.Items))
returns := make(map[string]any, len(current.Items))
foundSuperLucky := false
needsUpdate := false
for _, item := range current.Items {
giftType := strings.TrimSpace(item.GiftTypeCode)
if giftType == "" || strings.TrimSpace(item.RatioPercent) == "" || strings.TrimSpace(item.ReturnCoinRatioPercent) == "" {
return errors.New("gift ratio owner returned an incomplete item")
}
ratios[giftType] = item.RatioPercent
returns[giftType] = item.ReturnCoinRatioPercent
if giftType == "super_lucky" {
foundSuperLucky = true
needsUpdate = item.ReturnCoinRatioPercent != "1.00"
returns[giftType] = "1.00"
}
}
if !foundSuperLucky || len(ratios) != 3 {
return errors.New("gift ratio owner did not return all three gift types")
}
if !needsUpdate {
return nil
}
// 98/1/1 的 dynamic_v3 只能消费 wallet 固化的真实 1% 收礼返币。这里通过后台 owner API
// 仅修正当前真实房间区域的 super_lucky 返币,并把 GET 到的其他比例原样回写;不能直改钱包表、
// 不能篡改已扣费回执,也不能让 lucky service 在金额不一致时继续开奖。
var updated giftRatioList
if err := s.adminJSON(ctx, http.MethodPut, "/api/v1/admin/operations/gift-diamond-ratios", adminToken, map[string]any{
"regionId": regionID, "ratios": ratios, "returnCoinRatios": returns,
}, &updated); err != nil {
return err
}
for _, item := range updated.Items {
if item.GiftTypeCode == "super_lucky" && item.ReturnCoinRatioPercent == "1.00" {
return nil
}
}
return errors.New("super_lucky return ratio was not persisted as 1.00%")
}
func (s *demoServer) ensureDynamicRule(ctx context.Context, operatorID int64) (*luckygiftv1.LuckyGiftRuleConfig, error) {
meta := s.luckyMeta("rule-get")
current, err := s.luckyAdmin.GetLuckyGiftConfig(ctx, &luckygiftv1.GetLuckyGiftConfigRequest{Meta: meta, PoolId: demoPoolID})
if err == nil {
config := current.GetConfig()
if config.GetEnabled() && config.GetStrategyVersion() == "dynamic_v3" && config.GetPoolRatePpm() == 980_000 && config.GetProfitRatePpm() == 10_000 && config.GetAnchorRatePpm() == 10_000 {
return config, nil
}
}
stages := make([]*luckygiftv1.LuckyGiftRuleStage, 0, 3)
for index, name := range []string{"novice", "normal", "advanced"} {
min7, min30 := int64(0), int64(0)
if index == 1 {
min30 = 1
}
if index == 2 {
min7, min30 = 1, 1
}
stages = append(stages, &luckygiftv1.LuckyGiftRuleStage{
Stage: name, MinRecharge_7DCoins: min7, MinRecharge_30DCoins: min30,
Tiers: []*luckygiftv1.LuckyGiftRuleTier{
{Stage: name, TierId: name + "_none", MultiplierPpm: 0, BaseWeightPpm: 50_000, Enabled: true},
{Stage: name, TierId: name + "_0_5x", MultiplierPpm: 500_000, BaseWeightPpm: 40_000, Enabled: true},
{Stage: name, TierId: name + "_1x", MultiplierPpm: 1_000_000, BaseWeightPpm: 860_000, Enabled: true},
{Stage: name, TierId: name + "_2x", MultiplierPpm: 2_000_000, BaseWeightPpm: 50_000, Enabled: true},
},
})
}
upserted, err := s.luckyAdmin.UpsertLuckyGiftConfig(ctx, &luckygiftv1.UpsertLuckyGiftConfigRequest{
Meta: s.luckyMeta("rule-upsert"), OperatorAdminId: operatorID,
Config: &luckygiftv1.LuckyGiftRuleConfig{
AppCode: s.config.AppCode, PoolId: demoPoolID, Enabled: true, StrategyVersion: "dynamic_v3",
TargetRtpPpm: 980_000, PoolRatePpm: 980_000, ProfitRatePpm: 10_000, AnchorRatePpm: 10_000,
SettlementWindowWager: 1_000_000, ControlBandPpm: 30_000, GiftPriceReference: demoGiftPrice,
NoviceMaxEquivalentDraws: 2_000, NormalMaxEquivalentDraws: 20_000,
EffectiveFromMs: time.Now().UTC().Add(-time.Second).UnixMilli(), CreatedByAdminId: operatorID,
InitialPoolCoins: 1_000_000, LossStreakGuarantee: 5,
LowWatermarkCoins: 10_000_000, LowWaterNonzeroFactorPpm: 700_000,
HighWatermarkCoins: 20_000_000, HighWaterNonzeroFactorPpm: 1_300_000,
RechargeBoostWindowMs: 300_000, RechargeBoostFactorPpm: 1_100_000,
JackpotMultiplierPpms: []int64{200_000_000, 500_000_000, 1_000_000_000},
JackpotGlobalRtpMaxPpm: 980_000, JackpotUserDayRtpMaxPpm: 960_000, JackpotUser_72HRtpMaxPpm: 960_000,
JackpotSpendThresholdCoins: 5_000, MaxJackpotHitsPerUserDay: 5,
MaxSinglePayout: 1_000_000, UserHourlyPayoutCap: 2_000_000, UserDailyPayoutCap: 5_000_000,
DeviceDailyPayoutCap: 5_000_000, RoomHourlyPayoutCap: 10_000_000, AnchorDailyPayoutCap: 10_000_000,
Stages: stages,
},
})
if err != nil {
return nil, err
}
return upserted.GetConfig(), nil
}
func (s *demoServer) setSenderBalance(ctx context.Context, fixture *demoFixture, wanted int64) error {
current, err := s.gatewayCoinBalance(ctx, fixture.SenderToken)
if err != nil {
return fmt.Errorf("read sender balance: %w", err)
}
delta := wanted - current
if delta == 0 {
return nil
}
adminToken, err := s.adminLogin(ctx)
if err != nil {
return fmt.Errorf("admin login: %w", err)
}
body := map[string]any{
"commandId": commandID("coin-adjust"),
"targetUserId": fixture.Sender.UserID,
"amount": delta,
"reason": "local_lucky_gift_demo",
"remark": fmt.Sprintf("set sender initial COIN balance to %d", wanted),
}
if err := s.adminJSON(ctx, http.MethodPost, "/api/v1/admin/operations/coin-adjustments", adminToken, body, nil); err != nil {
return fmt.Errorf("adjust sender balance by %d: %w", delta, err)
}
after, err := s.gatewayCoinBalance(ctx, fixture.SenderToken)
if err != nil {
return err
}
if after != wanted {
return fmt.Errorf("sender balance mismatch after adjustment: got %d want %d", after, wanted)
}
return nil
}
func (s *demoServer) adminLogin(ctx context.Context) (string, error) {
var data struct {
AccessToken string `json:"accessToken"`
}
if err := s.adminJSON(ctx, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"username": s.config.AdminUsername,
"password": s.config.AdminPassword,
}, &data); err != nil {
return "", err
}
if data.AccessToken == "" {
return "", errors.New("admin login response is missing accessToken")
}
return data.AccessToken, nil
}
func (s *demoServer) gatewayCoinBalance(ctx context.Context, token string) (int64, error) {
var data struct {
Balances []struct {
AssetType string `json:"asset_type"`
AvailableAmount int64 `json:"available_amount"`
} `json:"balances"`
}
if err := s.gatewayJSON(ctx, http.MethodGet, "/api/v1/wallet/me/balances?asset_type=COIN", token, nil, &data); err != nil {
return 0, err
}
for _, balance := range data.Balances {
if strings.EqualFold(balance.AssetType, "COIN") {
return balance.AvailableAmount, nil
}
}
return 0, nil
}
func (s *demoServer) assertGiftPanel(ctx context.Context, fixture *demoFixture) error {
var data struct {
Gifts []struct {
GiftID string `json:"gift_id"`
} `json:"gifts"`
}
if err := s.gatewayJSON(ctx, http.MethodGet, "/api/v1/rooms/"+url.PathEscape(fixture.RoomID)+"/gift-panel", fixture.SenderToken, nil, &data); err != nil {
return fmt.Errorf("load gift panel: %w", err)
}
for _, gift := range data.Gifts {
if gift.GiftID == fixture.GiftID {
return nil
}
}
return fmt.Errorf("real gift panel does not contain %s", fixture.GiftID)
}
func (s *demoServer) handleState(writer http.ResponseWriter, request *http.Request) {
s.setupMu.Lock()
fixture := s.fixture
s.setupMu.Unlock()
if fixture == nil {
s.writeError(writer, http.StatusConflict, "fixture is not ready")
return
}
ctx, cancel := context.WithTimeout(request.Context(), 10*time.Second)
defer cancel()
senderBalance, err := s.gatewayCoinBalance(ctx, fixture.SenderToken)
if err != nil {
s.writeError(writer, http.StatusBadGateway, err.Error())
return
}
userID, err := parsePositiveInt64(fixture.Sender.UserID, "sender user_id")
if err != nil {
s.writeError(writer, http.StatusInternalServerError, err.Error())
return
}
draws, err := s.luckyAdmin.ListLuckyGiftDraws(ctx, &luckygiftv1.ListLuckyGiftDrawsRequest{
Meta: s.luckyMeta("draw-list"), UserId: userID, RoomId: fixture.RoomID, PoolId: fixture.PoolID, Page: 1, PageSize: 20,
})
if err != nil {
s.writeError(writer, http.StatusBadGateway, "list lucky draws: "+err.Error())
return
}
recent := make([]map[string]any, 0, len(draws.GetDraws()))
for _, draw := range draws.GetDraws() {
recent = append(recent, map[string]any{
"draw_id": draw.GetDrawId(), "command_id": draw.GetCommandId(), "tier_id": draw.GetSelectedTierId(),
"multiplier_ppm": draw.GetMultiplierPpm(), "reward_coins": draw.GetEffectiveRewardCoins(),
"reward_status": draw.GetRewardStatus(), "created_at_ms": draw.GetCreatedAtMs(),
})
}
s.writeOK(writer, map[string]any{
"sender_balance": senderBalance,
"draw_total": draws.GetTotal(),
"recent_draws": recent,
"server_time_ms": time.Now().UTC().UnixMilli(),
})
}
func (s *demoServer) gatewayJSON(ctx context.Context, method, path, token string, body any, out any) error {
headers := map[string]string{"X-App-Code": s.config.AppCode, "X-App-Platform": "web", "X-App-Language": "zh-CN"}
if token != "" {
headers["Authorization"] = "Bearer " + token
}
raw, status, err := s.doJSON(ctx, strings.TrimRight(s.config.GatewayBaseURL, "/")+path, method, headers, body)
if err != nil {
return err
}
var envelope gatewayEnvelope
if decodeErr := json.Unmarshal(raw, &envelope); decodeErr != nil {
return fmt.Errorf("gateway returned invalid JSON (status %d): %w", status, decodeErr)
}
if status < 200 || status >= 300 || envelope.Code != "OK" {
return fmt.Errorf("gateway request failed status=%d code=%s message=%s request_id=%s", status, envelope.Code, envelope.Message, envelope.RequestID)
}
if out != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" {
if err := json.Unmarshal(envelope.Data, out); err != nil {
return fmt.Errorf("decode gateway data: %w", err)
}
}
return nil
}
func (s *demoServer) adminJSON(ctx context.Context, method, path, token string, body any, out any) error {
headers := map[string]string{"X-App-Code": s.config.AppCode}
if token != "" {
headers["Authorization"] = "Bearer " + token
}
raw, status, err := s.doJSON(ctx, strings.TrimRight(s.config.AdminBaseURL, "/")+path, method, headers, body)
if err != nil {
return err
}
var envelope adminEnvelope
if decodeErr := json.Unmarshal(raw, &envelope); decodeErr != nil {
return fmt.Errorf("admin returned invalid JSON (status %d): %w", status, decodeErr)
}
if status < 200 || status >= 300 || !adminCodeOK(envelope.Code) {
return fmt.Errorf("admin request failed status=%d code=%v message=%s", status, envelope.Code, envelope.Message)
}
if out != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" {
if err := json.Unmarshal(envelope.Data, out); err != nil {
return fmt.Errorf("decode admin data: %w", err)
}
}
return nil
}
func (s *demoServer) doJSON(ctx context.Context, endpoint, method string, headers map[string]string, body any) ([]byte, int, error) {
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return nil, 0, err
}
reader = bytes.NewReader(raw)
}
request, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
if err != nil {
return nil, 0, err
}
request.Header.Set("Accept", "application/json")
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
for key, value := range headers {
request.Header.Set(key, value)
}
response, err := s.http.Do(request)
if err != nil {
return nil, 0, err
}
defer response.Body.Close()
raw, err := io.ReadAll(io.LimitReader(response.Body, 4<<20))
return raw, response.StatusCode, err
}
func (s *demoServer) luckyMeta(prefix string) *luckygiftv1.RequestMeta {
return &luckygiftv1.RequestMeta{
RequestId: commandID(prefix), Caller: "lucky-gift-room-demo", SentAtMs: time.Now().UTC().UnixMilli(), AppCode: s.config.AppCode,
}
}
func (s *demoServer) writeOK(writer http.ResponseWriter, data any) {
writeJSON(writer, http.StatusOK, map[string]any{"code": "OK", "message": "ok", "data": data})
}
func (s *demoServer) writeError(writer http.ResponseWriter, status int, message string) {
writeJSON(writer, status, map[string]any{"code": "LOCAL_DEMO_ERROR", "message": message, "data": nil})
}
func writeJSON(writer http.ResponseWriter, status int, value any) {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.Header().Set("Cache-Control", "no-store")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(value)
}
func adminCodeOK(code any) bool {
switch value := code.(type) {
case float64:
return value == 0 || value == 200
case string:
return value == "0" || value == "200" || strings.EqualFold(value, "OK")
case nil:
return true
default:
return false
}
}
func parsePositiveInt64(value, label string) (int64, error) {
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil || parsed <= 0 {
return 0, fmt.Errorf("%s must be a positive int64", label)
}
return parsed, nil
}
func commandID(prefix string) string {
randomBytes := make([]byte, 6)
if _, err := rand.Read(randomBytes); err != nil {
return fmt.Sprintf("demo-%s-%d", prefix, time.Now().UTC().UnixNano())
}
return fmt.Sprintf("demo-%s-%d-%s", prefix, time.Now().UTC().UnixMilli(), hex.EncodeToString(randomBytes))
}

View File

@ -0,0 +1,113 @@
package main
import (
"io"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
)
func TestRoutesServeSelfContainedV2Page(t *testing.T) {
server, err := newDemoServer(demoConfig{
GatewayBaseURL: "http://127.0.0.1:13000",
AdminBaseURL: "http://127.0.0.1:13100",
WalletAddr: "127.0.0.1:13004",
LuckyGiftAddr: "127.0.0.1:13013",
AppCode: "lalu",
InitialCoins: 1_000_000,
})
if err != nil {
t.Fatal(err)
}
defer server.Close()
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
server.Routes().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", response.Code, http.StatusOK)
}
body, err := io.ReadAll(response.Result().Body)
if err != nil {
t.Fatal(err)
}
page := string(body)
for _, required := range []string{
`/api/v2/rooms/gift/send`,
`/api/v1/activities/lucky-gifts/check`,
`/__local/heartbeat`,
`target_user_id`,
`idempotent_replay`,
} {
if !strings.Contains(page, required) {
t.Errorf("page does not contain %q", required)
}
}
if strings.Contains(page, "admin123") {
t.Fatal("page must never embed local administrator credentials")
}
}
func TestFixtureEndpointsFailClosedBeforeSetup(t *testing.T) {
server, err := newDemoServer(demoConfig{
GatewayBaseURL: "http://127.0.0.1:13000",
AdminBaseURL: "http://127.0.0.1:13100",
WalletAddr: "127.0.0.1:13004",
LuckyGiftAddr: "127.0.0.1:13013",
AppCode: "lalu",
InitialCoins: 1_000_000,
})
if err != nil {
t.Fatal(err)
}
defer server.Close()
for _, testCase := range []struct {
method string
path string
}{
{method: http.MethodGet, path: "/__local/state"},
{method: http.MethodPost, path: "/__local/heartbeat"},
} {
request := httptest.NewRequest(testCase.method, testCase.path, nil)
response := httptest.NewRecorder()
server.Routes().ServeHTTP(response, request)
if response.Code != http.StatusConflict {
t.Errorf("%s %s status = %d, want %d", testCase.method, testCase.path, response.Code, http.StatusConflict)
}
}
}
func TestCommandIDAndIntegerParsing(t *testing.T) {
first := commandID("gift")
second := commandID("gift")
if first == second {
t.Fatalf("command IDs must be unique: %q", first)
}
if !regexp.MustCompile(`^demo-gift-\d+-[0-9a-f]{12}$`).MatchString(first) {
t.Fatalf("unexpected command ID: %q", first)
}
if value, err := parsePositiveInt64("9223372036854775807", "user_id"); err != nil || value <= 0 {
t.Fatalf("parse max int64: value=%d err=%v", value, err)
}
for _, invalid := range []string{"", "0", "-1", "9007199254740992.0", "abc"} {
if _, err := parsePositiveInt64(invalid, "user_id"); err == nil {
t.Errorf("parsePositiveInt64(%q) unexpectedly succeeded", invalid)
}
}
}
func TestAdminCodeOK(t *testing.T) {
for _, value := range []any{nil, float64(0), float64(200), "0", "200", "OK", "ok"} {
if !adminCodeOK(value) {
t.Errorf("adminCodeOK(%v) = false", value)
}
}
for _, value := range []any{float64(500), "ERROR", true} {
if adminCodeOK(value) {
t.Errorf("adminCodeOK(%v) = true", value)
}
}
}