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:
commit
f3570384f2
File diff suppressed because it is too large
Load Diff
@ -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
@ -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
@ -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 是一笔批量送礼中的单个接收方账务快照。
|
||||
|
||||
305
docs/幸运礼物动态策略V3实现与模拟.md
Normal file
305
docs/幸运礼物动态策略V3实现与模拟.md
Normal 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 JWT;gateway 验签后写入 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 删除自身+0;1000x 继续删除;最终不透支 |
|
||||
| P/W 边界 | `W=P-1/P/P+1`、全部正奖不可付 | 等于可付,大于重抽,候选耗尽回 0,`P` 永不为负 |
|
||||
| 连 0 保底 | streak 0~5;第 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%。
|
||||
1914
docs/幸运礼物动态策略V3模拟结果-20260712.json
Normal file
1914
docs/幸运礼物动态策略V3模拟结果-20260712.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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{}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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_ms;pool 由 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
|
||||
|
||||
@ -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_v3,lucky-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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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_id;gateway 仍应允许其进入 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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
143
services/lucky-gift-service/cmd/strategy-sim/main.go
Normal file
143
services/lucky-gift-service/cmd/strategy-sim/main.go
Normal 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.5x(2650%),不能作为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)
|
||||
}
|
||||
763
services/lucky-gift-service/cmd/strategy-sim/scenarios.go
Normal file
763
services/lucky-gift-service/cmd/strategy-sim/scenarios.go
Normal 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/%d;10金币按公共池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=%v;P=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}
|
||||
}
|
||||
@ -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顺序子抽审计';
|
||||
|
||||
@ -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 最长 96;RTP 还拼接 :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='外部幸运礼物并发幂等锁行';
|
||||
@ -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 的最终抽奖结果。
|
||||
|
||||
1053
services/lucky-gift-service/internal/domain/luckygift/strategy.go
Normal file
1053
services/lucky-gift-service/internal/domain/luckygift/strategy.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -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{}
|
||||
}
|
||||
@ -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_v3;fixed_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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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 后才可使用 nowMS,dynamic_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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 持久化,恢复必须继续使用该值。
|
||||
// 空值原样传给 owner:fixed_v2 兼容旧 JWT,dynamic_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 transaction;batch 每个 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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
@ -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" ||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -248,7 +248,7 @@ func (s *Service) loginExistingThirdParty(ctx context.Context, identity authdoma
|
||||
// 老用户换机三方登录也要立即出现在设备 tab;header 缺失时回退 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
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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 保留批量送礼里每个目标的独立交易回执。
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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"`
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
1051
tools/lucky-gift-room-demo/index.html
Normal file
1051
tools/lucky-gift-room-demo/index.html
Normal file
File diff suppressed because it is too large
Load Diff
66
tools/lucky-gift-room-demo/main.go
Normal file
66
tools/lucky-gift-room-demo/main.go
Normal 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
|
||||
}
|
||||
811
tools/lucky-gift-room-demo/server.go
Normal file
811
tools/lucky-gift-room-demo/server.go
Normal 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, ¤t); 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))
|
||||
}
|
||||
113
tools/lucky-gift-room-demo/server_test.go
Normal file
113
tools/lucky-gift-room-demo/server_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user