Merge lucky gift dynamic v3 into test

# Conflicts:
#	api/proto/luckygift/v1/luckygift.pb.go
#	api/proto/luckygift/v1/luckygift.proto
#	api/proto/room/v1/room.pb.go
#	api/proto/wallet/v1/wallet.pb.go
#	services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go
#	services/lucky-gift-service/internal/service/luckygift/service.go
#	services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go
#	services/room-service/internal/room/service/gift_lucky.go
#	services/user-service/internal/service/auth/password.go
#	services/user-service/internal/service/auth/quick_account.go
#	services/user-service/internal/service/auth/third_party.go
#	services/user-service/internal/service/auth/token.go
This commit is contained in:
zhx 2026-07-15 14:55:03 +08:00
commit f3570384f2
67 changed files with 24187 additions and 8151 deletions

File diff suppressed because it is too large Load Diff

View File

@ -36,6 +36,12 @@ message LuckyGiftMeta {
string sender_avatar = 16;
string sender_display_user_id = 17;
string sender_pretty_display_user_id = 18;
//
int64 recharge_7d_coins = 19;
int64 recharge_30d_coins = 20;
int64 last_recharged_at_ms = 21;
// gift_income_coins wallet dynamic_v3
int64 gift_income_coins = 22;
}
message LuckyGiftRuleTier {
@ -50,6 +56,9 @@ message LuckyGiftRuleTier {
message LuckyGiftRuleStage {
string stage = 1;
repeated LuckyGiftRuleTier tiers = 2;
// dynamic_v3 novice 0/0
int64 min_recharge_7d_coins = 3;
int64 min_recharge_30d_coins = 4;
}
message LuckyGiftRuleConfig {
@ -68,6 +77,30 @@ message LuckyGiftRuleConfig {
int64 created_by_admin_id = 13;
int64 created_at_ms = 14;
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 {
@ -87,6 +120,8 @@ message CheckLuckyGiftResponse {
int64 target_rtp_ppm = 6;
string experience_pool = 7;
string pool_id = 8;
// strategy_version lucky-gift owner room saga fixed_v2 dynamic_v3
string strategy_version = 9;
}
message LuckyGiftDrawResult {
@ -146,6 +181,8 @@ message ExternalGiftDrawRequest {
int64 paid_at_ms = 10;
string metadata_json = 11;
string pool_id = 12;
// dynamic_v3 luck-gateway /fixed_v2
string device_id = 13;
}
message ExternalGiftDrawResponse {

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,8 @@ message RequestMeta {
string session_id = 6;
int64 sent_at_ms = 7;
string app_code = 8;
// device_id gateway access JWT claim JWT session_id/command_id
string device_id = 9;
}
// 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;
// balance_version sender command_id
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

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

@ -30,18 +30,41 @@ func New(luckyGift luckygiftadmin.Client, requestTimeout time.Duration, audit sh
}
type configRequest struct {
AppCode string `json:"app_code"`
PoolID string `json:"pool_id"`
Enabled bool `json:"enabled"`
TargetRTPPercent float64 `json:"target_rtp_percent"`
PoolRatePercent float64 `json:"pool_rate_percent"`
SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPercent float64 `json:"control_band_percent"`
GiftPriceReference int64 `json:"gift_price_reference"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
EffectiveFromMS int64 `json:"effective_from_ms"`
Stages []stageDTO `json:"stages"`
AppCode string `json:"app_code"`
PoolID string `json:"pool_id"`
StrategyVersion string `json:"strategy_version"`
Enabled bool `json:"enabled"`
TargetRTPPercent float64 `json:"target_rtp_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"`
ControlBandPercent float64 `json:"control_band_percent"`
GiftPriceReference int64 `json:"gift_price_reference"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
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"`
}
type configDTO struct {
@ -52,8 +75,10 @@ type configDTO struct {
}
type stageDTO struct {
Stage string `json:"stage"`
Tiers []tierDTO `json:"tiers"`
Stage string `json:"stage"`
MinRecharge7DCoins int64 `json:"min_recharge_7d_coins"`
MinRecharge30DCoins int64 `json:"min_recharge_30d_coins"`
Tiers []tierDTO `json:"tiers"`
}
type tierDTO struct {
@ -305,23 +330,48 @@ func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig {
})
}
stages = append(stages, &luckygiftv1.LuckyGiftRuleStage{
Stage: stageName,
Tiers: tiers,
Stage: stageName,
MinRecharge_7DCoins: stage.MinRecharge7DCoins,
MinRecharge_30DCoins: stage.MinRecharge30DCoins,
Tiers: tiers,
})
}
return &luckygiftv1.LuckyGiftRuleConfig{
AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)),
PoolId: strings.TrimSpace(req.PoolID),
Enabled: req.Enabled,
TargetRtpPpm: percentToPPM(req.TargetRTPPercent),
PoolRatePpm: percentToPPM(req.PoolRatePercent),
SettlementWindowWager: req.SettlementWindowWager,
ControlBandPpm: percentToPPM(req.ControlBandPercent),
GiftPriceReference: req.GiftPriceReference,
NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws,
NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws,
EffectiveFromMs: req.EffectiveFromMS,
Stages: stages,
AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)),
PoolId: strings.TrimSpace(req.PoolID),
StrategyVersion: strings.ToLower(strings.TrimSpace(req.StrategyVersion)),
Enabled: req.Enabled,
TargetRtpPpm: percentToPPM(req.TargetRTPPercent),
PoolRatePpm: percentToPPM(req.PoolRatePercent),
ProfitRatePpm: percentToPPM(req.ProfitRatePercent),
AnchorRatePpm: percentToPPM(req.AnchorRatePercent),
SettlementWindowWager: req.SettlementWindowWager,
ControlBandPpm: percentToPPM(req.ControlBandPercent),
GiftPriceReference: req.GiftPriceReference,
NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws,
NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws,
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,
}
}
@ -342,8 +392,10 @@ func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO {
})
}
stages = append(stages, stageDTO{
Stage: stage.GetStage(),
Tiers: tiers,
Stage: stage.GetStage(),
MinRecharge7DCoins: stage.GetMinRecharge_7DCoins(),
MinRecharge30DCoins: stage.GetMinRecharge_30DCoins(),
Tiers: tiers,
})
}
poolID := strings.TrimSpace(config.GetPoolId())
@ -352,18 +404,41 @@ func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO {
CreatedByAdminID: config.GetCreatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(),
configRequest: configRequest{
AppCode: config.GetAppCode(),
PoolID: poolID,
Enabled: config.GetEnabled(),
TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()),
PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()),
SettlementWindowWager: config.GetSettlementWindowWager(),
ControlBandPercent: ppmToPercent(config.GetControlBandPpm()),
GiftPriceReference: config.GetGiftPriceReference(),
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
EffectiveFromMS: config.GetEffectiveFromMs(),
Stages: stages,
AppCode: config.GetAppCode(),
PoolID: poolID,
StrategyVersion: config.GetStrategyVersion(),
Enabled: config.GetEnabled(),
TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()),
PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()),
ProfitRatePercent: ppmToPercent(config.GetProfitRatePpm()),
AnchorRatePercent: ppmToPercent(config.GetAnchorRatePpm()),
SettlementWindowWager: config.GetSettlementWindowWager(),
ControlBandPercent: ppmToPercent(config.GetControlBandPpm()),
GiftPriceReference: config.GetGiftPriceReference(),
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
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,
},
}
}
@ -403,10 +478,26 @@ func multiplierToPPM(value float64) int64 {
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 {
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 {
if draw == nil {
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)
}
}
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
RequestID string
ExternalUserID string
DeviceID string
GiftCount int64
UnitAmount int64
TotalAmount int64
@ -59,6 +60,7 @@ func (c *GRPCClient) SendLuckyGift(ctx context.Context, cmd SendCommand) (SendRe
},
AppCode: cmd.AppCode,
ExternalUserId: cmd.ExternalUserID,
DeviceId: cmd.DeviceID,
RequestId: cmd.RequestID,
GiftCount: cmd.GiftCount,
UnitAmount: cmd.UnitAmount,

View File

@ -17,24 +17,29 @@ type Handler struct {
client luckygiftclient.Client
allowedApps map[string]struct{}
requestTimeout time.Duration
now func() time.Time
}
func New(client luckygiftclient.Client, requestTimeout time.Duration, allowedApps map[string]struct{}) *Handler {
if requestTimeout <= 0 {
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 {
AppCode string `json:"app_code"`
RequestID string `json:"request_id"`
ExternalUserID string `json:"external_user_id"`
GiftCount int64 `json:"gift_count"`
UnitAmount int64 `json:"unit_amount"`
Currency string `json:"currency"`
Metadata map[string]any `json:"metadata"`
AppCode string `json:"app_code"`
RequestID string `json:"request_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"`
UnitAmount int64 `json:"unit_amount"`
Currency string `json:"currency"`
Metadata map[string]any `json:"metadata"`
}
func (h *Handler) Send(w http.ResponseWriter, r *http.Request) {
@ -63,14 +68,14 @@ func (h *Handler) Send(w http.ResponseWriter, r *http.Request) {
AppCode: appCode,
RequestID: strings.TrimSpace(req.RequestID),
ExternalUserID: strings.TrimSpace(req.ExternalUserID),
DeviceID: strings.TrimSpace(req.DeviceID),
GiftCount: req.GiftCount,
UnitAmount: req.UnitAmount,
TotalAmount: req.GiftCount * req.UnitAmount,
Currency: strings.TrimSpace(req.Currency),
MetadataJSON: metadataJSON(req.Metadata),
// 外部 HTTP 契约不暴露 pool_id/paid_at_mspool 由 owner service 默认规则选择,支付完成时间使用网关服务端 UTC 时间,
// 避免外部 App 通过请求体影响奖池分桶、预算日或风控窗口。
PaidAtMS: normalizePaidAt(h.now),
// pool 仍由 owner 默认规则选择paid_at_ms 则必须保持外部账务事实,不能改写成 gateway 收包时间。
PaidAtMS: req.PaidAtMS,
})
if err != nil {
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 {
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 {
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 或恶意调用方传入不一致账面金额。
if r.GiftCount > (1<<63-1)/r.UnitAmount {
return errors.New("gift amount is too large")
@ -127,10 +143,6 @@ func metadataJSON(value map[string]any) string {
return string(body)
}
func normalizePaidAt(now func() time.Time) int64 {
return now().UTC().UnixMilli()
}
func (h *Handler) isAllowedApp(appCode string) bool {
if len(h.allowedApps) == 0 {
return false

View File

@ -30,7 +30,7 @@ func (f *fakeClient) SendLuckyGift(_ context.Context, cmd luckygiftclient.SendCo
func TestSendAcceptsAllowedAppAndComputesAmount(t *testing.T) {
client := &fakeClient{}
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))
rec := httptest.NewRecorder()
@ -41,12 +41,27 @@ func TestSendAcceptsAllowedAppAndComputesAmount(t *testing.T) {
if client.calls != 1 {
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)
}
}
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{}
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}`)
@ -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) {
client := &fakeClient{}
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) {
client := &fakeClient{}
handler := testRouter(client, "aslan")

View File

@ -20,9 +20,11 @@ const (
// Claims 是 gateway 从 access token 中消费的最小用户状态快照。
// 它只用于入口鉴权和 profile gate不替代 user-service 的用户主数据。
type Claims struct {
AppCode string
UserID int64
SessionID string
AppCode string
UserID int64
SessionID string
// DeviceID 是 user-service 从 auth_session 签入 access token 的设备绑定;旧 token 缺字段时保持为空。
DeviceID string
ProfileCompleted bool
OnboardingStatus string
AccessTokenExpiresAtMS int64
@ -84,6 +86,7 @@ func (v *Verifier) Verify(header string) (Claims, error) {
profileCompleted, _ := claims["profile_completed"].(bool)
onboardingStatus, _ := claims["onboarding_status"].(string)
sessionID, _ := claims["sid"].(string)
deviceID, _ := claims["device_id"].(string)
appCode, _ := claims["app_code"].(string)
expiresAtMS := claimUnixSecondsMS(claims["exp"])
@ -91,6 +94,7 @@ func (v *Verifier) Verify(header string) (Claims, error) {
AppCode: appcode.Normalize(appCode),
UserID: userID,
SessionID: strings.TrimSpace(sessionID),
DeviceID: strings.TrimSpace(deviceID),
ProfileCompleted: profileCompleted,
OnboardingStatus: strings.TrimSpace(onboardingStatus),
AccessTokenExpiresAtMS: expiresAtMS,
@ -150,6 +154,13 @@ func SessionIDFromContext(ctx context.Context) string {
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 的判断依据。
func ProfileCompletedFromContext(ctx context.Context) bool {
claims, _ := ctx.Value(claimsContextKey).(Claims)

View File

@ -1,6 +1,7 @@
package auth
import (
"context"
"strconv"
"testing"
@ -12,8 +13,9 @@ func TestVerifierParsesLargeUserIDStringWithoutPrecisionLoss(t *testing.T) {
const userID int64 = 399999999999990001
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": strconv.FormatInt(userID, 10),
"app_code": "lalu",
"user_id": strconv.FormatInt(userID, 10),
"app_code": "lalu",
"device_id": " device-auth-1 ",
})
signed, err := token.SignedString([]byte("secret"))
if err != nil {
@ -27,4 +29,30 @@ func TestVerifierParsesLargeUserIDStringWithoutPrecisionLoss(t *testing.T) {
if 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,7 +241,9 @@ func RoomMeta(request *http.Request, roomID string, commandID string) *roomv1.Re
AppCode: appcode.FromContext(request.Context()),
GatewayNodeId: "gateway-local",
SessionId: sessionID,
SentAtMs: time.Now().UnixMilli(),
// 设备作用域只从已验签 JWT 读取;旧 JWT 返回空值,不用临时 session_id 兜底。
DeviceId: auth.DeviceIDFromContext(request.Context()),
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',
rule_version BIGINT NOT NULL 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',
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 结算窗口流水,单位金币',
control_band_ppm BIGINT NOT NULL COMMENT '正常波动带ppm',
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',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '发布人',
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),
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)
@ -98,6 +121,8 @@ CREATE TABLE IF NOT EXISTS lucky_gift_stage_tiers (
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
rule_version BIGINT NOT NULL COMMENT '规则版本',
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',
multiplier_ppm BIGINT NOT NULL COMMENT '倍率1x = 1000000',
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 (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
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 窗口序号',
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 '窗口已抽次数',
wager_coins BIGINT NOT NULL DEFAULT 0 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 '安全水位',
total_in 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',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, scope_type, scope_id)
) 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 (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
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 '该用户在当前奖池累计消耗金币',
equivalent_draws 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',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, user_id, gift_id)
) 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 (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
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)
) 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 (
app_code VARCHAR(32) NOT NULL COMMENT '外部 App 应用编码',
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_status (app_code, reward_status, created_at_ms)
) 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"
StageAdvanced = "advanced"
// StrategyFixedV2 保留现网不可变规则的原始语义;只有显式发布 StrategyDynamicV3 才启用动态水位、充值加权和多维风控。
StrategyFixedV2 = "fixed_v2"
StrategyDynamicV3 = "dynamic_v3"
EventTypeLuckyGiftDrawn = "LuckyGiftDrawn"
EventTypeLuckyGiftRewardSettlement = "LuckyGiftRewardSettlement"
EventTypeExternalLuckyGiftDrawn = "ExternalLuckyGiftDrawn"
@ -37,28 +41,55 @@ type Tier struct {
// Config 是幸运礼物线上规则快照。金额均为整数金币,比例均为 ppm。
type Config struct {
AppCode string `json:"app_code"`
GiftID string `json:"gift_id"`
PoolID string `json:"pool_id"`
Enabled bool `json:"enabled"`
RuleVersion int64 `json:"rule_version"`
GiftPrice int64 `json:"gift_price"`
TargetRTPPPM int64 `json:"target_rtp_ppm"`
PoolRatePPM int64 `json:"pool_rate_ppm"`
ControlBandPPM int64 `json:"control_band_ppm"`
GlobalWindowDraws int64 `json:"global_window_draws"`
GiftWindowDraws int64 `json:"gift_window_draws"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
HighMultiplier int64 `json:"high_multiplier"`
HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"`
InitialBasePool int64 `json:"initial_base_pool"`
BasePoolReserve int64 `json:"base_pool_reserve"`
MultiplierPPMs []int64 `json:"multiplier_ppms"`
Tiers []Tier `json:"tiers"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
AppCode string `json:"app_code"`
GiftID string `json:"gift_id"`
PoolID string `json:"pool_id"`
Enabled bool `json:"enabled"`
RuleVersion int64 `json:"rule_version"`
StrategyVersion string `json:"strategy_version"`
GiftPrice int64 `json:"gift_price"`
TargetRTPPPM int64 `json:"target_rtp_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"`
// SettlementWindowWager 是 dynamic_v3 结算窗口的真实金币流水阈值;不能按参考礼物价格换算成抽数。
SettlementWindowWager int64 `json:"settlement_window_wager"`
GlobalWindowDraws int64 `json:"global_window_draws"`
GiftWindowDraws int64 `json:"gift_window_draws"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
HighMultiplier int64 `json:"high_multiplier"`
HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"`
InitialBasePool int64 `json:"initial_base_pool"`
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"`
// Stages 保留 dynamic_v3 的充值门槛Tiers 仍是 fixed_v2 候选选择使用的扁平运行结构。
Stages []RuleStage `json:"stages"`
Tiers []Tier `json:"tiers"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
// RuleTier 是 v2 配置契约里的阶段奖档。倍率和概率在服务端内部统一用 ppm 保存。
@ -73,27 +104,53 @@ type RuleTier struct {
// RuleStage 保存某个用户阶段的完整奖档概率。每个阶段的基础概率必须合计 1000000。
type RuleStage struct {
Stage string `json:"stage"`
Tiers []RuleTier `json:"tiers"`
Stage string `json:"stage"`
MinRecharge7DCoins int64 `json:"min_recharge_7d_coins"`
MinRecharge30DCoins int64 `json:"min_recharge_30d_coins"`
Tiers []RuleTier `json:"tiers"`
}
// RuleConfig 是幸运礼物 v2 配置版本快照。比例和倍率均为 ppm业务单位转换发生在发布入口。
// RuleConfig 是幸运礼物不可变配置版本快照。fixed_v2 与 dynamic_v3 共用基础 RTP 契约,动态字段只由后者解释;
// 比例和倍率统一使用 ppm后台百分比/倍率业务单位只在发布入口转换。
type RuleConfig struct {
AppCode string `json:"app_code"`
PoolID string `json:"pool_id"`
RuleVersion int64 `json:"rule_version"`
Enabled bool `json:"enabled"`
TargetRTPPPM int64 `json:"target_rtp_ppm"`
PoolRatePPM int64 `json:"pool_rate_ppm"`
SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPPM int64 `json:"control_band_ppm"`
GiftPriceReference int64 `json:"gift_price_reference"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
EffectiveFromMS int64 `json:"effective_from_ms"`
CreatedByAdminID int64 `json:"created_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
Stages []RuleStage `json:"stages"`
AppCode string `json:"app_code"`
PoolID string `json:"pool_id"`
RuleVersion int64 `json:"rule_version"`
StrategyVersion string `json:"strategy_version"`
Enabled bool `json:"enabled"`
TargetRTPPPM int64 `json:"target_rtp_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"`
ControlBandPPM int64 `json:"control_band_ppm"`
GiftPriceReference int64 `json:"gift_price_reference"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
EffectiveFromMS int64 `json:"effective_from_ms"`
CreatedByAdminID int64 `json:"created_by_admin_id"`
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"`
}
type CheckCommand struct {
@ -104,19 +161,23 @@ type CheckCommand struct {
}
type DrawCommand struct {
CommandID string
PoolID string
UserID int64
TargetUserID int64
DeviceID string
RoomID string
AnchorID string
GiftID string
GiftCount int32
CoinSpent int64
PaidAtMS int64
VisibleRegionID int64
CountryID int64
CommandID string
PoolID string
UserID int64
TargetUserID int64
DeviceID string
RoomID string
AnchorID string
GiftID string
GiftCount int32
CoinSpent int64
PaidAtMS int64
VisibleRegionID int64
CountryID int64
Recharge7DCoins int64
Recharge30DCoins int64
LastRechargedAtMS int64
GiftIncomeCoins int64
// Sender* 是扣费时刻的公开展示快照owner outbox 必须携带快照,避免下游为一条飘屏同步反查 user-service。
SenderName string
SenderAvatar string
@ -125,14 +186,15 @@ type DrawCommand struct {
}
type CheckResult struct {
Enabled bool
Reason string
PoolID string
GiftID string
GiftPrice int64
RuleVersion int64
TargetRTPPPM int64
ExperiencePool string
Enabled bool
Reason string
PoolID string
GiftID string
GiftPrice int64
RuleVersion int64
StrategyVersion string
TargetRTPPPM int64
ExperiencePool string
}
type DrawResult struct {
@ -229,14 +291,16 @@ type DrawQuery struct {
type ExternalDrawCommand struct {
AppCode string
ExternalUserID string
RequestID string
GiftCount int64
UnitAmount int64
TotalAmount int64
Currency string
PaidAtMS int64
MetadataJSON string
PoolID string
// DeviceID 是外部调用方在自己认证/签名边界内绑定的稳定设备标识dynamic_v3 必填。
DeviceID string
RequestID string
GiftCount int64
UnitAmount int64
TotalAmount int64
Currency string
PaidAtMS int64
MetadataJSON string
PoolID string
}
// ExternalDrawResult 是返回给外部 App 的最终抽奖结果。

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
)
// DefaultRuleConfig 返回 v2 配置草稿。草稿默认 disabled只有后台发布后才写入不可变版本表。
// DefaultRuleConfig 返回 dynamic_v3 配置草稿。金额型风控上限和 50 美元等值金币门槛没有跨 App 通用换算,
// 因而保留为 0 且草稿默认 disabled运营显式补齐这些值并启用后才允许发布可运行版本。
func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig {
poolID = normalizePoolID(poolID)
return domain.RuleConfig{
AppCode: appCode,
PoolID: poolID,
Enabled: false,
TargetRTPPPM: 950_000,
PoolRatePPM: 960_000,
SettlementWindowWager: 1_000_000,
ControlBandPPM: 10_000,
GiftPriceReference: 100,
AppCode: appCode,
PoolID: poolID,
StrategyVersion: domain.StrategyDynamicV3,
Enabled: false,
TargetRTPPPM: 980_000,
PoolRatePPM: 980_000,
ProfitRatePPM: 10_000,
AnchorRatePPM: 10_000,
SettlementWindowWager: 1_000_000,
ControlBandPPM: 30_000,
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,
NormalMaxEquivalentDraws: 20_000,
Stages: []domain.RuleStage{
defaultRuleStage(domain.StageNovice, []ruleTierSeed{
{id: "novice_none", multiplierPPM: 0, weightPPM: 100_000},
{id: "novice_0_5x", multiplierPPM: 500_000, weightPPM: 200_000},
{id: "novice_1x", multiplierPPM: 1_000_000, weightPPM: 550_000},
{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},
}),
defaultRuleStage(domain.StageNovice, 0, 0, defaultSafeTierSeeds(domain.StageNovice)),
// 产品尚未给出具体充值金额时,用 (0,1) 与 (1,1) 保证三个阶段可执行且无重叠;运营可按 App 金币口径上调。
defaultRuleStage(domain.StageNormal, 0, 1, defaultSafeTierSeeds(domain.StageNormal)),
defaultRuleStage(domain.StageAdvanced, 1, 1, defaultSafeTierSeeds(domain.StageAdvanced)),
},
}
}
@ -57,8 +60,24 @@ type ruleTierSeed struct {
weightPPM int64
}
func defaultRuleStage(stage string, tiers []ruleTierSeed) domain.RuleStage {
out := domain.RuleStage{Stage: stage, Tiers: make([]domain.RuleTier, 0, len(tiers))}
// defaultSafeTierSeeds 保留明确的 0x 和少量 0.5x 小奖,并让每个阶段的静态期望精确为 98%。
// 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 {
out.Tiers = append(out.Tiers, domain.RuleTier{
Stage: stage,
@ -74,6 +93,11 @@ func defaultRuleStage(stage string, tiers []ruleTierSeed) domain.RuleStage {
func normalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig {
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 {
stage.Stage = strings.TrimSpace(stage.Stage)
for tierIndex, tier := range stage.Tiers {
@ -111,6 +135,13 @@ func validateRuleConfig(config domain.RuleConfig) error {
if config.NoviceMaxEquivalentDraws < 0 || config.NormalMaxEquivalentDraws < config.NoviceMaxEquivalentDraws {
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))
for _, stage := range config.Stages {
if !validRuleStage(stage.Stage) {
@ -133,6 +164,121 @@ func validateRuleConfig(config domain.RuleConfig) error {
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
}

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 {
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) {

View File

@ -184,6 +184,7 @@ func (s *Service) ExecuteExternalDraw(ctx context.Context, cmd domain.ExternalDr
func (s *Service) normalizeExternalDrawCommand(cmd domain.ExternalDrawCommand) (domain.ExternalDrawCommand, error) {
cmd.AppCode = appcode.Normalize(cmd.AppCode)
cmd.ExternalUserID = strings.TrimSpace(cmd.ExternalUserID)
cmd.DeviceID = strings.TrimSpace(cmd.DeviceID)
cmd.RequestID = strings.TrimSpace(cmd.RequestID)
cmd.Currency = strings.ToUpper(strings.TrimSpace(cmd.Currency))
cmd.MetadataJSON = strings.TrimSpace(cmd.MetadataJSON)
@ -191,15 +192,17 @@ func (s *Service) normalizeExternalDrawCommand(cmd domain.ExternalDrawCommand) (
if cmd.AppCode == "" || cmd.ExternalUserID == "" || cmd.RequestID == "" {
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 错误。
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift identity is too long")
}
if cmd.GiftCount <= 0 || cmd.UnitAmount <= 0 || cmd.TotalAmount <= 0 {
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift amount must be positive")
}
if cmd.GiftCount > int64(^uint32(0)>>1) {
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift count is too large")
// dynamic_v3 必须为每个礼物保存一条顺序子抽事实;与内部送礼统一限制 999
// 避免外部请求用一个超大 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 {
// 金额校验必须在 owner service 再做一遍gateway 是外部入口,不能成为唯一可信校验点。
@ -218,10 +221,8 @@ func (s *Service) normalizeExternalDrawCommand(cmd domain.ExternalDrawCommand) (
if cmd.MetadataJSON != "" && !json.Valid([]byte(cmd.MetadataJSON)) {
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift metadata_json is invalid")
}
if cmd.PaidAtMS <= 0 {
// paid_at_ms 表示外部 App 扣费完成时间;缺失时用 UTC 服务端时间兜底,不能使用本地时区。
cmd.PaidAtMS = s.now().UTC().UnixMilli()
}
// paid_at_ms 是否可兼容缺失取决于不可变规则版本service 层保留原值repository
// 读取 fixed_v2 后才可使用 nowMSdynamic_v3 内外调用都必须提供真实扣费时间。
return cmd, nil
}
@ -237,9 +238,14 @@ func (s *Service) normalizeDrawCommand(cmd domain.DrawCommand) (domain.DrawComma
cmd.SenderAvatar = strings.TrimSpace(cmd.SenderAvatar)
cmd.SenderDisplayUserID = strings.TrimSpace(cmd.SenderDisplayUserID)
cmd.SenderPrettyDisplayUserID = strings.TrimSpace(cmd.SenderPrettyDisplayUserID)
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")
}
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 {
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid")
}
@ -248,10 +254,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.
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift count exceeds command limit")
}
if cmd.PaidAtMS <= 0 {
// paid_at_ms 是扣费事实时间,缺失时用 UTC 服务端时间补齐,避免本地时区污染 RTP 窗口。
cmd.PaidAtMS = s.now().UTC().UnixMilli()
}
// 内部 wallet paid_at_ms 不能在 service 层用当前时钟补齐repository 必须先读取策略,
// fixed_v2 才允许兼容降级dynamic_v3 则严格拒绝缺失 owner 事实。
return cmd, nil
}

View File

@ -48,9 +48,16 @@ func externalLuckyScopeID(appCode string) string {
return externalLuckyHashScope("external", appcode.Normalize(appCode))
}
func externalLuckyDeviceID(appCode string, externalUserID string) string {
// 外部用户 ID 最长可到 128 字符hash 后写入审计字段,避免长字符串碰到内部设备字段长度边界。
return externalLuckyHashScope("external_device", appcode.Normalize(appCode), externalUserID)
func externalLuckyDeviceID(appCode string, deviceID string) string {
// 外部设备 ID 最长可到 128 字符hash 后写入内部审计/风控字段,
// 既保持同 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 {
@ -75,6 +82,9 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext
}
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 err != nil {
return domain.ExternalDrawResult{}, err
@ -88,6 +98,16 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext
if err != nil {
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}
rewardAmount := int64(0)
ruleVersion := rule.RuleVersion
@ -159,7 +179,27 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext
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) {
if rule.StrategyVersion == domain.StrategyDynamicV3 {
return r.executeExternalDynamicGiftEconomy(ctx, tx, cmd, rule, nowMS)
}
baseConfig, err := luckyRuntimeConfigFromRuleConfig(rule)
if err != nil {
return externalDrawCandidate{}, 0, err
@ -172,7 +212,7 @@ func (r *Repository) executeExternalGiftEconomy(ctx context.Context, tx *sql.Tx,
CommandID: "external:" + cmd.AppCode + ":" + cmd.RequestID,
PoolID: cmd.PoolID,
UserID: externalUserID,
DeviceID: externalLuckyDeviceID(cmd.AppCode, cmd.ExternalUserID),
DeviceID: externalLuckyFixedCompatibleDeviceID(cmd),
RoomID: roomScopeID,
AnchorID: externalLuckyAnchorID(cmd.AppCode),
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
}
// 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 {
if _, err := tx.ExecContext(ctx, `
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
EquivalentDraws int64
LossStreak int64
// PendingJackpotTokens 是按 UTC 日消费赚取、但允许跨日等待资金的未消费大奖资格。
// 它属于 pool/user 生命周期,不能和“当天消费/当天命中次数”一起在午夜静默清零。
PendingJackpotTokens int64
}
type luckyCandidate struct {
@ -99,8 +102,14 @@ type luckyDrawRecordInput struct {
GlobalWindow luckyRTPWindow
GiftWindow luckyRTPWindow
BasePool luckyPool
RewardStatus string
NowMS int64
// StrategyDecision 只在 dynamic_v3 存在完整权重、动态因子、P/W 重抽和大奖门槛会随 draw fact 固化,
// 后台以后修改规则版本也不会让历史结果失去可解释性。
StrategyDecision *domain.StrategyDecision
FundSplit domain.StrategyFundSplit
UserDayRTP domain.StrategyRTP
User72HourRTP domain.StrategyRTP
RewardStatus string
NowMS int64
}
// luckyBatchDrawState 是批量送礼事务里的聚合账本。
@ -153,14 +162,15 @@ func (r *Repository) CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand
return domain.CheckResult{}, err
}
return domain.CheckResult{
Enabled: config.Enabled,
Reason: luckyEnabledReason(config.Enabled),
PoolID: config.PoolID,
GiftID: cmd.GiftID,
GiftPrice: config.GiftPrice,
RuleVersion: config.RuleVersion,
TargetRTPPPM: config.TargetRTPPPM,
ExperiencePool: luckyExperiencePool(state.EquivalentDraws, config),
Enabled: config.Enabled,
Reason: luckyEnabledReason(config.Enabled),
PoolID: config.PoolID,
GiftID: cmd.GiftID,
GiftPrice: config.GiftPrice,
RuleVersion: config.RuleVersion,
StrategyVersion: config.StrategyVersion,
TargetRTPPPM: config.TargetRTPPPM,
ExperiencePool: luckyExperiencePool(state.EquivalentDraws, config),
}, 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) {
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 份单抽金额。
// 单抽仍走原来的严谨路径,避免为了批量优化扩大普通送礼的行为面。
drawCount := cmd.GiftCount
@ -227,7 +244,6 @@ func (r *Repository) executeLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx,
}
return []domain.DrawResult{result}, nil
}
appCode := appcode.FromContext(ctx)
if drawCount > 1 {
// 旧实现把整笔送礼作为一次抽奖并使用原始 command_id 落库;批量拆分上线后遇到这类重试必须直接返回旧事实,不能追加子抽奖造成重复返奖。
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)
}
// 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 只服务批量命令重试:完整读回已提交的子抽奖,再由外层聚合成响应。
// 这里不重新计算随机结果,避免同一个扣费命令因重试得到不同中奖结果。
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 {
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)
if err != nil {
return nil, err
@ -969,22 +1013,32 @@ func luckyDrawRecordArgs(input luckyDrawRecordInput) []any {
// luckyDrawRecordSnapshots 保存的是抽中当刻的可解释状态,不要求成为完整候选空间。
// 这里重点记录命中奖档、被过滤原因、结算窗口和当时池水位,足够定位“为什么本抽是 0/1/高倍”。
func luckyDrawRecordSnapshots(input luckyDrawRecordInput) (string, string, string) {
candidateSnapshot, _ := json.Marshal(map[string]any{
candidateData := map[string]any{
"base_reward": input.Candidate.BaseReward,
"limited": input.Limited,
"multiplier_ppm": input.Candidate.MultiplierPPM,
"weight": input.Candidate.Weight,
})
poolSnapshot, _ := json.Marshal(map[string]any{
}
poolData := map[string]any{
"base_balance": input.BasePool.Balance,
"base_reserve_floor": input.BasePool.ReserveFloor,
"base_total_in": input.BasePool.TotalIn,
"base_total_out": input.BasePool.TotalOut,
})
rtpSnapshot, _ := json.Marshal(map[string]any{
}
rtpData := map[string]any{
"global_window": input.GlobalWindow.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)
}
@ -1043,6 +1097,20 @@ func (r *Repository) executeSingleLuckyGiftDraw(ctx context.Context, tx *sql.Tx,
if !exists || !ruleConfig.Enabled {
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)
if err != nil {
return domain.DrawResult{}, err
@ -2166,7 +2234,7 @@ func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Conf
if referencePrice <= 0 {
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)
if windowDraws <= 0 {
windowDraws = 1
@ -2178,27 +2246,60 @@ func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Conf
// 单池模型把旧 platform/room/gift 三层责任池合并为 app+pool_id 一口基础池;
// 初始水位和安全水位使用旧三池默认值求和,保证新规则上线后高倍奖支付能力不会因模型收敛而突然降低。
config := domain.Config{
AppCode: ruleConfig.AppCode,
GiftID: poolID,
PoolID: poolID,
Enabled: ruleConfig.Enabled,
RuleVersion: ruleConfig.RuleVersion,
GiftPrice: referencePrice,
TargetRTPPPM: ruleConfig.TargetRTPPPM,
PoolRatePPM: ruleConfig.PoolRatePPM,
ControlBandPPM: ruleConfig.ControlBandPPM,
GlobalWindowDraws: windowDraws,
GiftWindowDraws: windowDraws,
NoviceMaxEquivalentDraws: ruleConfig.NoviceMaxEquivalentDraws,
NormalMaxEquivalentDraws: ruleConfig.NormalMaxEquivalentDraws,
HighMultiplier: 10,
HighWaterPoolMultiple: 2,
InitialBasePool: referencePrice * (950 + 500 + 2_375),
BasePoolReserve: referencePrice * (100 + 10 + 100),
Tiers: tiers,
UpdatedByAdminID: ruleConfig.CreatedByAdminID,
CreatedAtMS: ruleConfig.CreatedAtMS,
UpdatedAtMS: ruleConfig.CreatedAtMS,
AppCode: ruleConfig.AppCode,
GiftID: poolID,
PoolID: poolID,
Enabled: ruleConfig.Enabled,
RuleVersion: ruleConfig.RuleVersion,
StrategyVersion: ruleConfig.StrategyVersion,
GiftPrice: referencePrice,
TargetRTPPPM: ruleConfig.TargetRTPPPM,
PoolRatePPM: ruleConfig.PoolRatePPM,
ProfitRatePPM: ruleConfig.ProfitRatePPM,
AnchorRatePPM: ruleConfig.AnchorRatePPM,
ControlBandPPM: ruleConfig.ControlBandPPM,
SettlementWindowWager: ruleConfig.SettlementWindowWager,
GlobalWindowDraws: windowDraws,
GiftWindowDraws: windowDraws,
NoviceMaxEquivalentDraws: ruleConfig.NoviceMaxEquivalentDraws,
NormalMaxEquivalentDraws: ruleConfig.NormalMaxEquivalentDraws,
HighMultiplier: 10,
HighWaterPoolMultiple: 2,
InitialBasePool: referencePrice * (950 + 500 + 2_375),
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,
Stages: ruleConfig.Stages,
UpdatedByAdminID: ruleConfig.CreatedByAdminID,
CreatedAtMS: 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)
return config, nil
@ -2256,11 +2357,11 @@ func luckyCeilDiv(value, divisor int64) int64 {
func (r *Repository) getLuckyUserState(ctx context.Context, appCode string, userID int64, giftID string) (luckyUserState, error) {
var state luckyUserState
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
WHERE app_code = ? AND user_id = ? AND gift_id = ?`,
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) {
return luckyUserState{}, nil
}
@ -2268,29 +2369,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) {
var state luckyUserState
err := tx.QueryRowContext(ctx, `
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
}
// 先用幂等 INSERT 建行再 SELECT FOR UPDATE两个首次抽奖并发时唯一键会让第二个事务等待
// 而不是两边都先读到 no rows 后由其中一个以 duplicate key 失败。
if _, err := tx.ExecContext(ctx, `
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
) VALUES (?, ?, ?, 0, 0, 0, 0, ?, ?)`,
app_code, user_id, gift_id, paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak,
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,
); err != nil {
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) {
@ -2317,6 +2416,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)
}
// 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) {
lockSQL := ""
if forUpdate {
@ -2343,51 +2471,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) {
window := luckyRTPWindow{
ScopeType: scopeType,
ScopeID: scopeID,
WindowIndex: index,
TargetRTPPPM: targetPPM,
ControlDraws: windowDraws,
CarryPPM: carry,
Status: "open",
}
// 同一 scope 的首次请求可能并发创建同一 window幂等 INSERT 先以唯一键串行化,
// 随后必须重新读取实际行,不能在 duplicate 时返回一个全 0 的本地假快照覆盖已提交进度。
if _, err := tx.ExecContext(ctx, `
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 (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, ?, 'open', ?, ?)`,
appCode, scopeType, scopeID, index, targetPPM, windowDraws, window.CarryPPM, nowMS, nowMS,
) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, ?, 'open', ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
appCode, scopeType, scopeID, index, targetPPM, windowDraws, carry, nowMS, nowMS,
); err != nil {
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) {
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
FROM lucky_pools
WHERE app_code = ? AND scope_type = ? AND scope_id = ?
FOR UPDATE`,
appCode, scopeType, scopeID,
)
var pool luckyPool
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
).Scan(&pool.ScopeType, &pool.ScopeID, &pool.Balance, &pool.ReserveFloor, &pool.TotalIn, &pool.TotalOut)
return pool, err
}
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 (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"hyapp/pkg/appcode"
@ -16,7 +18,7 @@ type luckyRuleQueryer interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
// GetLuckyGiftRuleConfig 读取指定奖池最新 v2 规则版本;旧抽奖运行表不会参与后台配置读取。
// GetLuckyGiftRuleConfig 读取指定奖池最新不可变规则版本;旧抽奖运行表不会参与后台配置读取。
func (r *Repository) GetLuckyGiftRuleConfig(ctx context.Context, poolID string) (domain.RuleConfig, bool, error) {
if r == nil || r.db == nil {
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)
}
// PublishLuckyGiftRuleConfig 只新增不可变版本,不覆盖历史配置;后续 v2 抽奖引擎按版本读取快照。
// PublishLuckyGiftRuleConfig 只新增不可变版本,不覆盖历史配置;运行引擎按 strategy_version 和 rule_version 读取完整快照。
func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error) {
if r == nil || r.db == nil {
return domain.RuleConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
@ -38,6 +40,18 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
appCode := appcode.FromContext(ctx)
config.AppCode = appCode
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.CreatedAtMS = nowMS
if config.EffectiveFromMS <= 0 {
@ -61,15 +75,33 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
}
if _, err := tx.ExecContext(ctx, `
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,
novice_max_equivalent_draws, normal_max_equivalent_draws,
effective_from_ms, created_by_admin_id, created_at_ms
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
config.AppCode, config.PoolID, config.RuleVersion, config.Enabled, config.TargetRTPPPM, config.PoolRatePPM,
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
) 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.NoviceMaxEquivalentDraws, config.NormalMaxEquivalentDraws,
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 {
return domain.RuleConfig{}, err
}
@ -77,10 +109,12 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
for _, tier := range stage.Tiers {
if _, err := tx.ExecContext(ctx, `
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
) VALUES (?,?,?,?,?,?,?,?,?)`,
config.AppCode, config.PoolID, config.RuleVersion, tier.Stage, tier.TierID, tier.MultiplierPPM, tier.BaseWeightPPM,
) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
config.AppCode, config.PoolID, config.RuleVersion, tier.Stage, stage.MinRecharge7DCoins, stage.MinRecharge30DCoins,
tier.TierID, tier.MultiplierPPM, tier.BaseWeightPPM,
tier.HighWaterOnly, tier.Enabled,
); err != nil {
return domain.RuleConfig{}, err
@ -93,7 +127,7 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
return config, nil
}
// ListLuckyGiftRuleConfigs 返回每个奖池的最新 v2 规则版本,用于后台奖池列表。
// ListLuckyGiftRuleConfigs 返回每个奖池的最新规则版本,用于后台奖池列表。
func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode string) ([]domain.RuleConfig, error) {
if r == nil || r.db == nil {
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))
}
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.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
INNER JOIN (
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"
}
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,
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
WHERE app_code = ? AND pool_id = ?
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) {
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
WHERE app_code = ? AND pool_id = ? AND rule_version = ?
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)
for rows.Next() {
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
}
index, exists := stageIndex[tier.Stage]
if !exists {
index = len(stages)
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)
}
@ -205,13 +275,17 @@ func (r *Repository) getLuckyGiftRuleStages(ctx context.Context, q luckyRuleQuer
func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) {
var config domain.RuleConfig
var jackpotMultiplierPPMsJSON []byte
if err := scanner.Scan(
&config.AppCode,
&config.PoolID,
&config.RuleVersion,
&config.Enabled,
&config.StrategyVersion,
&config.TargetRTPPPM,
&config.PoolRatePPM,
&config.ProfitRatePPM,
&config.AnchorRatePPM,
&config.SettlementWindowWager,
&config.ControlBandPPM,
&config.GiftPriceReference,
@ -220,8 +294,40 @@ func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) {
&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 {
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
}

View File

@ -45,6 +45,9 @@ func TestPublishLuckyGiftRuleConfigPersistsVersionsAndTiers(t *testing.T) {
if published.RuleVersion != 1 {
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且两版规则和奖档都能按最新版本读回。
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" {
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

@ -34,14 +34,15 @@ func (s *LuckyGiftServer) CheckLuckyGift(ctx context.Context, req *luckygiftv1.C
return nil, xerr.ToGRPCError(err)
}
return &luckygiftv1.CheckLuckyGiftResponse{
Enabled: result.Enabled,
Reason: result.Reason,
PoolId: result.PoolID,
GiftId: result.GiftID,
GiftPrice: result.GiftPrice,
RuleVersion: result.RuleVersion,
TargetRtpPpm: result.TargetRTPPPM,
ExperiencePool: result.ExperiencePool,
Enabled: result.Enabled,
Reason: result.Reason,
PoolId: result.PoolID,
GiftId: result.GiftID,
GiftPrice: result.GiftPrice,
RuleVersion: result.RuleVersion,
StrategyVersion: result.StrategyVersion,
TargetRtpPpm: result.TargetRTPPPM,
ExperiencePool: result.ExperiencePool,
}, nil
}
@ -88,6 +89,7 @@ func (s *LuckyGiftServer) ExecuteExternalGiftDraw(ctx context.Context, req *luck
result, err := s.svc.ExecuteExternalDraw(ctx, domain.ExternalDrawCommand{
AppCode: req.GetAppCode(),
ExternalUserID: req.GetExternalUserId(),
DeviceID: req.GetDeviceId(),
RequestID: req.GetRequestId(),
GiftCount: req.GetGiftCount(),
UnitAmount: req.GetUnitAmount(),
@ -122,6 +124,12 @@ func luckyDrawCommandFromProto(meta *luckygiftv1.LuckyGiftMeta) domain.DrawComma
SenderAvatar: meta.GetSenderAvatar(),
SenderDisplayUserID: meta.GetSenderDisplayUserId(),
SenderPrettyDisplayUserID: meta.GetSenderPrettyDisplayUserId(),
// 充值分层、短时加权与主播风控只消费 wallet-service 已落账的事实,
// lucky-gift-service 不跨 owner 数据库反查并重新解释同一笔交易。
Recharge7DCoins: meta.GetRecharge_7DCoins(),
Recharge30DCoins: meta.GetRecharge_30DCoins(),
LastRechargedAtMS: meta.GetLastRechargedAtMs(),
GiftIncomeCoins: meta.GetGiftIncomeCoins(),
}
}
@ -251,26 +259,51 @@ func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.Ru
})
}
stages = append(stages, domain.RuleStage{
Stage: stage.GetStage(),
Tiers: tiers,
Stage: stage.GetStage(),
MinRecharge7DCoins: stage.GetMinRecharge_7DCoins(),
MinRecharge30DCoins: stage.GetMinRecharge_30DCoins(),
Tiers: tiers,
})
}
return domain.RuleConfig{
AppCode: config.GetAppCode(),
PoolID: config.GetPoolId(),
RuleVersion: config.GetRuleVersion(),
Enabled: config.GetEnabled(),
TargetRTPPPM: config.GetTargetRtpPpm(),
PoolRatePPM: config.GetPoolRatePpm(),
SettlementWindowWager: config.GetSettlementWindowWager(),
ControlBandPPM: config.GetControlBandPpm(),
GiftPriceReference: config.GetGiftPriceReference(),
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
EffectiveFromMS: config.GetEffectiveFromMs(),
CreatedByAdminID: config.GetCreatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(),
Stages: stages,
AppCode: config.GetAppCode(),
PoolID: config.GetPoolId(),
RuleVersion: config.GetRuleVersion(),
Enabled: config.GetEnabled(),
TargetRTPPPM: config.GetTargetRtpPpm(),
PoolRatePPM: config.GetPoolRatePpm(),
SettlementWindowWager: config.GetSettlementWindowWager(),
ControlBandPPM: config.GetControlBandPpm(),
GiftPriceReference: config.GetGiftPriceReference(),
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
EffectiveFromMS: config.GetEffectiveFromMs(),
CreatedByAdminID: config.GetCreatedByAdminId(),
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,
}
}
@ -289,26 +322,51 @@ func luckyRuleConfigToProto(config domain.RuleConfig) *luckygiftv1.LuckyGiftRule
})
}
stages = append(stages, &luckygiftv1.LuckyGiftRuleStage{
Stage: stage.Stage,
Tiers: tiers,
Stage: stage.Stage,
MinRecharge_7DCoins: stage.MinRecharge7DCoins,
MinRecharge_30DCoins: stage.MinRecharge30DCoins,
Tiers: tiers,
})
}
return &luckygiftv1.LuckyGiftRuleConfig{
AppCode: config.AppCode,
PoolId: config.PoolID,
RuleVersion: config.RuleVersion,
Enabled: config.Enabled,
TargetRtpPpm: config.TargetRTPPPM,
PoolRatePpm: config.PoolRatePPM,
SettlementWindowWager: config.SettlementWindowWager,
ControlBandPpm: config.ControlBandPPM,
GiftPriceReference: config.GiftPriceReference,
NoviceMaxEquivalentDraws: config.NoviceMaxEquivalentDraws,
NormalMaxEquivalentDraws: config.NormalMaxEquivalentDraws,
EffectiveFromMs: config.EffectiveFromMS,
CreatedByAdminId: config.CreatedByAdminID,
CreatedAtMs: config.CreatedAtMS,
Stages: stages,
AppCode: config.AppCode,
PoolId: config.PoolID,
RuleVersion: config.RuleVersion,
Enabled: config.Enabled,
TargetRtpPpm: config.TargetRTPPPM,
PoolRatePpm: config.PoolRatePPM,
SettlementWindowWager: config.SettlementWindowWager,
ControlBandPpm: config.ControlBandPPM,
GiftPriceReference: config.GiftPriceReference,
NoviceMaxEquivalentDraws: config.NoviceMaxEquivalentDraws,
NormalMaxEquivalentDraws: config.NormalMaxEquivalentDraws,
EffectiveFromMs: config.EffectiveFromMS,
CreatedByAdminId: config.CreatedByAdminID,
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,
}
}

View File

@ -34,6 +34,9 @@ type Base struct {
GatewayNodeID string `json:"gateway_node_id"`
// SessionID 记录 gateway 看到的客户端会话,不等同于腾讯云 IM 连接。
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 int64 `json:"sent_at_ms"`
}
@ -439,6 +442,9 @@ type SendGift struct {
GiftID string `json:"gift_id"`
// PoolID 是幸运礼物奖池标识;为空表示普通礼物或默认幸运奖池。
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 int32 `json:"gift_count"`
// EntitlementID 是背包送礼要消耗的用户礼物权益 ID普通金币送礼为空。
@ -652,6 +658,9 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
delete(values, "request_id")
delete(values, "gateway_node_id")
delete(values, "session_id")
// device_id 是首次请求的可信风控快照,会在 gift operation/command log 中按原值恢复;
// 它不属于客户端业务载荷token 旋转后的同 command_id 重试不应因此冲突。
delete(values, "device_id")
delete(values, "sent_at_ms")
// 展示快照来自 gateway 对 user-service 的读取结果,不改变扣费和房间状态语义;重试同一 command_id 时不能因为昵称头像变化冲突。
delete(values, "sender_display_profile")
@ -685,6 +694,7 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
delete(values, "gift_type_code")
delete(values, "host_period_diamond_added")
delete(values, "host_period_cycle_key")
delete(values, "lucky_gift_strategy_version")
delete(values, "rocket_touched")
delete(values, "rocket_added_fuel")
delete(values, "rocket_effective_fuel")

View File

@ -7,7 +7,7 @@ import (
func TestSendGiftIdempotencyIgnoresGatewayResolvedSnapshots(t *testing.T) {
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,
GiftID: "rose", GiftCount: 9, ComboSessionID: "combo-1", BatchSeq: 1,
SenderRegionID: 1001, SenderCountryID: 840,
@ -19,6 +19,7 @@ func TestSendGiftIdempotencyIgnoresGatewayResolvedSnapshots(t *testing.T) {
retry.SenderCountryID = 356
retry.TargetHostScopes = []GiftTargetHostScope{{TargetUserID: 43, TargetIsHost: false}}
retry.SenderDisplayProfile = GiftDisplayProfile{UserID: 42, Username: "after"}
retry.DeviceID = "device-after-token-rotation"
left, err := IdempotencyPayloadForCommand(base)
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) {
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

View File

@ -243,6 +243,15 @@ func (f *giftFlow) prepareRoomFacts() error {
if checkResp == nil || !checkResp.GetEnabled() {
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 为准。
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())
}
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 {
// 抽奖失败时整笔送礼失败并依赖 wallet 幂等重试;不能落普通礼物后丢失幸运礼物抽奖事实。
return err

View File

@ -15,28 +15,37 @@ import (
"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 {
return nil, nil
}
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 {
return nil, err
}
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 {
return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty")
}
// 抽奖必须按每个收礼目标独立落事实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{
LuckyGift: luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling, now),
LuckyGift: luckyMeta,
})
if err != nil {
return nil, err
@ -48,14 +57,18 @@ func (s *Service) executeLuckyGiftDraw(ctx context.Context, meta *roomv1.Request
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))}
for _, targetBilling := range targetBillings {
if targetBilling.Billing == nil {
return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty")
}
// 多人送礼只跨服务调用一次activity-service 在一个事务内按这个顺序推进锁和抽奖事实。
req.LuckyGifts = append(req.LuckyGifts, luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling, now))
luckyMeta, err := luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling)
if err != nil {
return nil, err
}
// 多人送礼只跨服务调用一次lucky-gift-service 在一个事务内按这个顺序推进锁和抽奖事实。
req.LuckyGifts = append(req.LuckyGifts, luckyMeta)
}
resp, err := s.luckyGift.BatchExecuteLuckyGiftDraw(ctx, req)
if err != nil {
@ -74,21 +87,29 @@ func (s *Service) executeLuckyGiftDrawBatch(ctx context.Context, meta *roomv1.Re
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{
Meta: luckyGiftMetaFromRoom(ctx, meta),
CommandId: targetBilling.CommandID,
UserId: cmd.ActorUserID(),
TargetUserId: targetBilling.TargetUserID,
// 目前没有独立设备 ID 字段,优先用 session_id没有时退回目标子 command_id保证每次抽奖 scope 不为空。
DeviceId: luckyGiftDeviceID(cmd, targetBilling.CommandID),
// DeviceID 已在首次请求进入 gift operation 时随 command.Base 持久化,恢复必须继续使用该值。
// 空值原样传给 ownerfixed_v2 兼容旧 JWTdynamic_v3 拒绝;绝不用 session_id/command_id 伪造设备。
DeviceId: cmd.DeviceID,
RoomId: cmd.RoomID(),
AnchorId: luckyGiftAnchorID(roomMeta),
GiftId: cmd.GiftID,
GiftCount: cmd.GiftCount,
CoinSpent: targetBilling.Billing.GetCoinSpent(),
// 房间链路统一使用 UTC epoch ms不能把本地时区时间传给幸运礼物 RTP 窗口。
PaidAtMs: now.UTC().UnixMilli(),
// PaidAtMs 只来自当前目标的 wallet transactionbatch 每个 target 也保留自己的 owner 回执,
// 不使用聚合回执、Room Cell mutate 时钟或 gift_operation.created_at_ms。
PaidAtMs: paidAtMS,
PoolId: cmd.PoolID,
VisibleRegionId: roomMeta.VisibleRegionID,
CountryId: cmd.SenderCountryID,
@ -97,7 +118,21 @@ func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd c
SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar),
SenderDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.DisplayUserID),
SenderPrettyDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.PrettyDisplayUserID),
// 充值分层和短时加权只消费 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 {
@ -140,16 +175,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 {
if roomMeta.OwnerUserID <= 0 {
return roomMeta.RoomID

View File

@ -19,9 +19,11 @@ import (
type concurrentV2Wallet struct {
followTestWallet
calls atomic.Int32
started chan struct{}
release chan struct{}
calls atomic.Int32
started chan struct{}
release chan struct{}
walletPaidAtMS int64
omitPaidAt bool
}
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:
}
}
paidAtMS := w.walletPaidAtMS
if paidAtMS <= 0 && !w.omitPaidAt {
paidAtMS = 1_700_000_000_000
}
return &walletv1.DebitGiftResponse{
BillingReceiptId: "receipt-v2-stable",
TransactionId: "wallet-tx-v2-stable",
@ -45,9 +51,38 @@ func (w *concurrentV2Wallet) DebitGift(ctx context.Context, _ *walletv1.DebitGif
BalanceVersion: 17,
GiftIncomeBalanceAfter: 25,
GiftTypeCode: "normal",
PaidAtMs: paidAtMS,
}, 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) {
ctx := appcode.WithContext(context.Background(), appcode.Default)
repository := mysqltest.NewRepository(t)
@ -119,18 +154,23 @@ func TestSendGiftConcurrentCommandReplaysOriginalV2AndKeepsV1Fields(t *testing.T
}
type recoverableLuckyClient struct {
calls atomic.Int32
calls atomic.Int32
paidAtMS [2]atomic.Int64
}
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) {
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")
}
meta := req.GetLuckyGift()
return &luckygiftv1.ExecuteLuckyGiftDrawResponse{Result: &luckygiftv1.LuckyGiftDrawResult{
DrawId: "draw-recovered", CommandId: meta.GetCommandId(), GiftId: meta.GetGiftId(), PoolId: meta.GetPoolId(),
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) {
ctx := appcode.WithContext(context.Background(), appcode.Default)
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{}
clock := &fixedRoomRocketClock{now: time.Date(2026, 7, 12, 23, 59, 59, 800_000_000, time.UTC)}
svc := roomservice.New(roomservice.Config{
NodeID: "node-gift-v2-recovery",
LeaseTTL: 10 * time.Second,
LeaseTTL: 48 * time.Hour,
RankLimit: 20,
SnapshotEveryN: 1,
Clock: clock,
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), lucky)
roomID := "room-gift-v2-recovery"
@ -167,7 +210,7 @@ func TestGiftOperationWorkerFinishesDebitedRequestWithoutClientRetry(t *testing.
createRocketRoom(t, ctx, svc, roomID, senderID, 9001)
joinRocketRoom(t, ctx, svc, roomID, targetID)
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",
}
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 {
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{
Meta: &roomv1.RequestMeta{CommandId: "close-during-gift-recovery", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default},
}); !xerr.IsCode(err, xerr.Conflict) {
@ -203,6 +252,12 @@ func TestGiftOperationWorkerFinishesDebitedRequestWithoutClientRetry(t *testing.
}
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)
if err != nil {

View File

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

View File

@ -10,6 +10,7 @@ import (
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/integration"
roomservice "hyapp/services/room-service/internal/room/service"
"hyapp/services/room-service/internal/router"
@ -21,17 +22,20 @@ type luckyGiftTestClient struct {
draws []*luckygiftv1.ExecuteLuckyGiftDrawRequest
batchDraws []*luckygiftv1.BatchExecuteLuckyGiftDrawRequest
drawResults []*luckygiftv1.LuckyGiftDrawResult
// checkStrategy 让 room 测试显式区分 fixed_v2 滚动兼容与 dynamic_v3 强设备事实。
checkStrategy string
}
func (c *luckyGiftTestClient) CheckLuckyGift(_ context.Context, req *luckygiftv1.CheckLuckyGiftRequest) (*luckygiftv1.CheckLuckyGiftResponse, error) {
c.checks = append(c.checks, req)
return &luckygiftv1.CheckLuckyGiftResponse{
Enabled: true,
Reason: "enabled",
GiftId: req.GetGiftId(),
PoolId: req.GetPoolId(),
RuleVersion: 12,
ExperiencePool: "novice",
Enabled: true,
Reason: "enabled",
GiftId: req.GetGiftId(),
PoolId: req.GetPoolId(),
RuleVersion: 12,
ExperiencePool: "novice",
StrategyVersion: c.checkStrategy,
}, nil
}
@ -116,13 +120,22 @@ func (c *luckyGiftTestClient) BatchExecuteLuckyGiftDraw(_ context.Context, req *
func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{
BillingReceiptId: "receipt-lucky",
CoinSpent: 100,
GiftPointAdded: 100,
HeatValue: 100,
GiftTypeCode: "super_lucky",
}}}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{
BillingReceiptId: "receipt-lucky",
CoinSpent: 100,
GiftPointAdded: 100,
HeatValue: 100,
GiftTypeCode: "super_lucky",
},
{
BillingReceiptId: "receipt-lucky-legacy-token",
CoinSpent: 100,
GiftPointAdded: 100,
HeatValue: 100,
GiftTypeCode: "super_lucky",
},
}}
luckyGift := &luckyGiftTestClient{}
svc := roomservice.New(roomservice.Config{
NodeID: "node-lucky-test",
@ -143,7 +156,8 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
CommandId: "cmd-lucky",
ActorUserId: ownerID,
RoomId: roomID,
SessionId: "device-session-1",
SessionId: "sess-must-not-be-device-1",
DeviceId: "device-auth-session-1",
AppCode: appcode.Default,
SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(),
},
@ -178,7 +192,7 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
drawMeta := luckyGift.draws[0].GetLuckyGift()
if drawMeta.GetCommandId() != "cmd-lucky" ||
drawMeta.GetCoinSpent() != 100 ||
drawMeta.GetDeviceId() != "device-session-1" ||
drawMeta.GetDeviceId() != "device-auth-session-1" ||
drawMeta.GetAnchorId() != "101" ||
drawMeta.GetVisibleRegionId() != 9001 ||
drawMeta.GetSenderName() != "lucky sender" ||
@ -187,6 +201,40 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
drawMeta.GetSenderPrettyDisplayUserId() != "HY-123456" {
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) {
@ -221,7 +269,8 @@ func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) {
CommandId: "cmd-lucky-multi",
ActorUserId: ownerID,
RoomId: roomID,
SessionId: "device-session-multi",
SessionId: "sess-must-not-be-device-multi",
DeviceId: "device-auth-session-multi",
AppCode: appcode.Default,
SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(),
},
@ -251,6 +300,9 @@ func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) {
if secondDraw.GetCommandId() != "cmd-lucky-multi:target:303" || secondDraw.GetTargetUserId() != secondTargetID || secondDraw.GetCoinSpent() != 200 {
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" ||
resp.GetLuckyGift().GetTargetUserId() != 0 ||
resp.GetLuckyGift().GetSelectedTierId() != "batch" ||

View File

@ -69,7 +69,7 @@ func (s *Service) LoginPassword(ctx context.Context, displayUserID string, passw
s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "")
// 登录成功即刷新设备档案;写入是 best-effort不影响 token 返回。
s.recordUserDevice(ctx, meta, user.UserID, deviceID)
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 {
return authdomain.Token{}, err
}

View File

@ -123,7 +123,7 @@ func (s *Service) QuickCreateAccount(ctx context.Context, password string, regis
s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "")
// 快捷账号也是可登录主体,设备档案与普通注册保持一致;机器人分支在上方已提前返回。
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID)
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 {
return authdomain.Token{}, tokenErr
}

View File

@ -40,6 +40,23 @@ func accessTokenExpUnix(t *testing.T, accessToken string) int64 {
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 {
mu sync.Mutex
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 {
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) {
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 {
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 auditedBuildNumber string
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 {
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" {
t.Fatalf("refresh must denylist rotated access session, got %q", got)
}
@ -1196,6 +1223,10 @@ func TestIssueAccessTokenForSessionCarriesProfileRequiredOnboardingStatusWithout
if reissued.SessionID != token.SessionID || reissued.RefreshToken != "" {
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) {

View File

@ -248,7 +248,7 @@ func (s *Service) loginExistingThirdParty(ctx context.Context, identity authdoma
// 老用户换机三方登录也要立即出现在设备 tabheader 缺失时回退 body 设备字段。
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID)
// 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 {
return authdomain.Token{}, false, err
}
@ -300,7 +300,7 @@ func (s *Service) registerExistingThirdParty(ctx context.Context, identity authd
s.audit(ctx, meta, updated.UserID, loginThird, identity.Provider, resultSuccess, "")
// 补齐资料完成注册同样属于认证成功,设备档案与 session 创建保持同步。
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), updated.UserID, registration.DeviceID)
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 {
return authdomain.Token{}, false, err
}
@ -360,7 +360,7 @@ func (s *Service) createThirdPartyUser(ctx context.Context, provider string, pro
s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "")
// 新注册用户的首个设备档案与注册事务同请求写入,后台无需等下次登录。
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID)
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 {
return authdomain.Token{}, false, tokenErr
}
@ -444,7 +444,7 @@ func (s *Service) createRegisteredThirdPartyUser(ctx context.Context, provider s
s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "")
// 注册专用入口创建的 completed 用户同样落首个设备档案。
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID)
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 {
return authdomain.Token{}, false, tokenErr
}

View File

@ -82,7 +82,7 @@ func (s *Service) RefreshToken(ctx context.Context, refreshToken string, deviceI
s.audit(ctx, meta, session.UserID, loginRefresh, "", resultSuccess, "")
// refresh 是登录态存续的主路径;老设备升级新客户端后,设备档案主要在这里补齐。
s.recordUserDevice(ctx, meta, session.UserID, deviceID)
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。
@ -124,7 +124,7 @@ func (s *Service) IssueAccessTokenForSession(ctx context.Context, userID int64,
}
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)
}
// AdminIssueUserAccessToken 供后台按用户当前最新 active session 重签 access token。
@ -159,7 +159,7 @@ func (s *Service) AdminIssueUserAccessToken(ctx context.Context, userID int64, m
}
// refreshToken 传空:后台没有轮换会话的理由,响应中也绝不携带 refresh 原文。
token, err := s.issueToken(user, session.SessionID, "", session.ExpiresAtMs)
token, err := s.issueToken(user, session.SessionID, session.DeviceID, "", session.ExpiresAtMs)
if err != nil {
return authdomain.Token{}, authdomain.Session{}, err
}
@ -273,7 +273,7 @@ func (s *Service) newSession(appCode string, userID int64, deviceID string) (aut
}, 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()
expiresInSec := s.cfg.AccessTokenTTLSec
expiresAt := now.Add(time.Duration(expiresInSec) * time.Second)
@ -294,6 +294,8 @@ func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToke
onboardingStatus := tokenOnboardingStatus(user)
// JWT 只携带内部 user_id客户端展示和人工输入统一使用 display_user_id。
// user_id 必须写成字符串:雪花 ID 超过 JS/JSON number 的安全整数范围,写成 number 会在 gateway 解析时丢精度。
// device_id 只能来自已落库 auth_session 的绑定值;心跳/资料完成重签也必须从同一 session 回读,
// 不允许 gateway 用 sid 或业务 command_id 伪造设备风控作用域。
claims := jwt.MapClaims{
"iss": s.cfg.Issuer,
"app_code": appcode.Normalize(user.AppCode),
@ -306,6 +308,7 @@ func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToke
"profile_completed": user.ProfileCompleted,
"onboarding_status": onboardingStatus,
"sid": sessionID,
"device_id": strings.TrimSpace(deviceID),
"typ": "access",
"iat": now.Unix(),
"exp": expiresAt.Unix(),

View File

@ -106,6 +106,14 @@ type Receipt struct {
GiftIncomeCoinAmount int64
// GiftIncomeBalanceAfter 是收礼人 COIN 入账后的余额;没有返币时为 0。
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 保留批量送礼里每个目标的独立交易回执。

View File

@ -398,6 +398,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 {
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} {
balances, err := svc.GetBalances(context.Background(), userID, []string{ledger.AssetCoin})
if err != nil {
@ -1575,6 +1584,8 @@ func TestDebitGiftIsIdempotent(t *testing.T) {
if err != nil {
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)
if err != nil {
t.Fatalf("second debit failed: %v", err)
@ -1583,6 +1594,11 @@ func TestDebitGiftIsIdempotent(t *testing.T) {
if first.BillingReceiptID != second.BillingReceiptID || second.BalanceAfter != 80 || second.CoinSpent != 20 {
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 {
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) {
first := receiptFromGiftMetadata("tx-1", giftMetadata{BillingReceipt: "receipt-1", BalanceAfter: 900, BalanceVersion: 11})
last := receiptFromGiftMetadata("tx-2", giftMetadata{BillingReceipt: "receipt-2", BalanceAfter: 800, BalanceVersion: 12})
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, PaidAtMS: 1_700_000_000_000})
if first.BalanceVersion != 11 || last.BalanceVersion != 12 {
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 {
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)
}
}
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

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

View File

@ -65,6 +65,14 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
if err != nil {
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)
if err != nil {
@ -274,6 +282,10 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
GiftIncomeRatioPercent: giftIncomeRatio.Percent,
GiftIncomeRatioRegionID: giftIncomeRatioRegionID,
GiftIncomeBalanceAfter: giftIncomeBalanceAfter,
RechargeSevenDayCoins: rechargeSnapshot.SevenDayCoins,
RechargeThirtyDayCoins: rechargeSnapshot.ThirtyDayCoins,
LastRechargedAtMS: rechargeSnapshot.LastAtMS,
PaidAtMS: nowMs,
BillingReceipt: billingReceiptID(command.AppCode, command.CommandID),
SenderUserID: command.SenderUserID,
SenderRegionID: command.SenderRegionID,

View File

@ -100,6 +100,13 @@ func aggregateGiftReceipts(targets []ledger.BatchGiftTargetReceipt) (ledger.Rece
if aggregate.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.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

@ -31,11 +31,18 @@ type giftMetadata struct {
HeatValue int64 `json:"heat_value"`
BalanceAfter int64 `json:"balance_after"`
// BalanceVersion 与 BalanceAfter 一起固化 sender 账后快照;重试不能读取当前账户版本冒充首次结果。
BalanceVersion int64 `json:"balance_version,omitempty"`
GiftIncomeCoinAmount int64 `json:"gift_income_coin_amount"`
GiftIncomeRatioPercent string `json:"gift_income_ratio_percent,omitempty"`
GiftIncomeRatioRegionID int64 `json:"gift_income_ratio_region_id,omitempty"`
GiftIncomeBalanceAfter int64 `json:"gift_income_balance_after,omitempty"`
BalanceVersion int64 `json:"balance_version,omitempty"`
GiftIncomeCoinAmount int64 `json:"gift_income_coin_amount"`
GiftIncomeRatioPercent string `json:"gift_income_ratio_percent,omitempty"`
GiftIncomeRatioRegionID int64 `json:"gift_income_ratio_region_id,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"`
SenderUserID int64 `json:"sender_user_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) {
var metadataJSON string
var createdAtMS int64
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
WHERE app_code = ? AND transaction_id = ?`,
appcode.FromContext(ctx),
transactionID,
).Scan(&metadataJSON); err != nil {
).Scan(&metadataJSON, &createdAtMS); err != nil {
return ledger.Receipt{}, err
}
var metadata giftMetadata
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return ledger.Receipt{}, err
}
// wallet_transactions.created_at_ms 始终是 owner 权威时间。metadata 的副本只用于首次事务内
// 直接构造回执;幂等回读无条件以主表覆盖,旧交易缺字段和异常副本都不能改变扣费归属。
metadata.PaidAtMS = createdAtMS
return receiptFromGiftMetadata(transactionID, metadata), nil
}
@ -67,6 +71,10 @@ func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger
ChargeSource: normalizeGiftChargeSource(metadata.ChargeSource, metadata.EntitlementID),
GiftIncomeCoinAmount: metadata.GiftIncomeCoinAmount,
GiftIncomeBalanceAfter: metadata.GiftIncomeBalanceAfter,
RechargeSevenDayCoins: metadata.RechargeSevenDayCoins,
RechargeThirtyDayCoins: metadata.RechargeThirtyDayCoins,
LastRechargedAtMS: metadata.LastRechargedAtMS,
PaidAtMS: metadata.PaidAtMS,
}
}

View File

@ -72,6 +72,34 @@ func (r *Repository) CountRows(table string, where string, args ...any) int {
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.
func (r *Repository) SetRechargeProductCode(productID int64, productCode string) {
r.t.Helper()

View File

@ -159,5 +159,9 @@ func debitGiftResponseFromReceipt(receipt ledger.Receipt) *walletv1.DebitGiftRes
ChargeSource: receipt.ChargeSource,
GiftIncomeCoinAmount: receipt.GiftIncomeCoinAmount,
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) {
response := debitGiftResponseFromReceipt(ledger.Receipt{BalanceAfter: 1234, BalanceVersion: 17})
if response.GetBalanceAfter() != 1234 || response.GetBalanceVersion() != 17 {
response := debitGiftResponseFromReceipt(ledger.Receipt{BalanceAfter: 1234, BalanceVersion: 17, PaidAtMS: 1_700_000_000_000})
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)
}
}

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)
}
}
}