feat(lucky-gift): support optional device and cross-strategy experiments
This commit is contained in:
parent
bf6d7d4f1e
commit
b7378c98db
@ -868,12 +868,14 @@ func (x *LuckyGiftRuleConfig) GetV2HighWaterBoostRecoverCoins() int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CheckLuckyGiftRequest struct {
|
type CheckLuckyGiftRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||||
UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||||
RoomId string `protobuf:"bytes,3,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"`
|
RoomId string `protobuf:"bytes,3,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"`
|
||||||
GiftId string `protobuf:"bytes,4,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"`
|
GiftId string `protobuf:"bytes,4,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"`
|
||||||
PoolId string `protobuf:"bytes,5,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
|
PoolId string `protobuf:"bytes,5,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
|
||||||
|
// 可选的可信设备 claim。旧 token 缺失时保持空值;实验分组只按用户键,不得按设备改变组别。
|
||||||
|
DeviceId string `protobuf:"bytes,6,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -943,6 +945,13 @@ func (x *CheckLuckyGiftRequest) GetPoolId() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *CheckLuckyGiftRequest) GetDeviceId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.DeviceId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type CheckLuckyGiftResponse struct {
|
type CheckLuckyGiftResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"`
|
Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"`
|
||||||
@ -5478,13 +5487,14 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
|
|||||||
" user_24h_ordinary_win_factor_ppm\x18( \x01(\x03R\x1buser24hOrdinaryWinFactorPpm\x12K\n" +
|
" user_24h_ordinary_win_factor_ppm\x18( \x01(\x03R\x1buser24hOrdinaryWinFactorPpm\x12K\n" +
|
||||||
"#v2_high_water_boost_threshold_coins\x18) \x01(\x03R\x1ev2HighWaterBoostThresholdCoins\x12A\n" +
|
"#v2_high_water_boost_threshold_coins\x18) \x01(\x03R\x1ev2HighWaterBoostThresholdCoins\x12A\n" +
|
||||||
"\x1ev2_high_water_boost_factor_ppm\x18* \x01(\x03R\x19v2HighWaterBoostFactorPpm\x12G\n" +
|
"\x1ev2_high_water_boost_factor_ppm\x18* \x01(\x03R\x19v2HighWaterBoostFactorPpm\x12G\n" +
|
||||||
"!v2_high_water_boost_recover_coins\x18+ \x01(\x03R\x1cv2HighWaterBoostRecoverCoins\"\xb0\x01\n" +
|
"!v2_high_water_boost_recover_coins\x18+ \x01(\x03R\x1cv2HighWaterBoostRecoverCoins\"\xcd\x01\n" +
|
||||||
"\x15CheckLuckyGiftRequest\x123\n" +
|
"\x15CheckLuckyGiftRequest\x123\n" +
|
||||||
"\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\n" +
|
"\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\n" +
|
||||||
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x17\n" +
|
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x17\n" +
|
||||||
"\aroom_id\x18\x03 \x01(\tR\x06roomId\x12\x17\n" +
|
"\aroom_id\x18\x03 \x01(\tR\x06roomId\x12\x17\n" +
|
||||||
"\agift_id\x18\x04 \x01(\tR\x06giftId\x12\x17\n" +
|
"\agift_id\x18\x04 \x01(\tR\x06giftId\x12\x17\n" +
|
||||||
"\apool_id\x18\x05 \x01(\tR\x06poolId\"\xb8\x02\n" +
|
"\apool_id\x18\x05 \x01(\tR\x06poolId\x12\x1b\n" +
|
||||||
|
"\tdevice_id\x18\x06 \x01(\tR\bdeviceId\"\xb8\x02\n" +
|
||||||
"\x16CheckLuckyGiftResponse\x12\x18\n" +
|
"\x16CheckLuckyGiftResponse\x12\x18\n" +
|
||||||
"\aenabled\x18\x01 \x01(\bR\aenabled\x12\x16\n" +
|
"\aenabled\x18\x01 \x01(\bR\aenabled\x12\x16\n" +
|
||||||
"\x06reason\x18\x02 \x01(\tR\x06reason\x12\x17\n" +
|
"\x06reason\x18\x02 \x01(\tR\x06reason\x12\x17\n" +
|
||||||
|
|||||||
@ -124,6 +124,8 @@ message CheckLuckyGiftRequest {
|
|||||||
string room_id = 3;
|
string room_id = 3;
|
||||||
string gift_id = 4;
|
string gift_id = 4;
|
||||||
string pool_id = 5;
|
string pool_id = 5;
|
||||||
|
// 可选的可信设备 claim。旧 token 缺失时保持空值;实验分组只按用户键,不得按设备改变组别。
|
||||||
|
string device_id = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CheckLuckyGiftResponse {
|
message CheckLuckyGiftResponse {
|
||||||
|
|||||||
@ -28,10 +28,10 @@ AB 实验在其上加一层"版本钉住"语义,不改变版本流本身:
|
|||||||
- userKey:内部用户为十进制 user_id;外部 App 用户为 `ext:` + external_user_id。两条抽奖链路共用同一分流。
|
- userKey:内部用户为十进制 user_id;外部 App 用户为 `ext:` + external_user_id。两条抽奖链路共用同一分流。
|
||||||
白名单 user_key 列使用 utf8mb4_bin(字节精确),与 FNV 分桶口径一致。
|
白名单 user_key 列使用 utf8mb4_bin(字节精确),与 FNV 分桶口径一致。
|
||||||
- **确定性**:实验状态不变时,同一用户在 Check 与 Draw 解析到同一组(room-service 送礼 saga 在 CheckLuckyGift
|
- **确定性**:实验状态不变时,同一用户在 Check 与 Draw 解析到同一组(room-service 送礼 saga 在 CheckLuckyGift
|
||||||
固化 strategy_version、恢复阶段不重新 Check)。放量/白名单/暂停操作会让分组在两次解析间漂移,因此——
|
固化 strategy_version、恢复阶段不重新 Check)。放量/白名单/暂停操作会让分组在两次解析间漂移。
|
||||||
- **两组必须同策略族**(创建实验强制校验):同策略下分组漂移只是参数差异,不会击穿 room saga 对
|
- **允许跨策略对照**:control 与 treatment 可以分别使用 fixed_v2、dynamic_v3,用于真实比较两代策略。旧客户端
|
||||||
device_id/paid_at 的扣费前拦截。fixed_v2 → dynamic_v3 的**策略迁移走常规发布**,不走实验
|
缺少可信 device_id 时,dynamic_v3 只跳过设备日返奖上限,用户/房间/主播等其他风控照常执行;paid_at_ms
|
||||||
(旧客户端本就无法进入 dynamic 组,混合策略实验的分组天然有偏差,统计上也不成立)。
|
始终来自扣费后的 wallet 回执,所以分组在 Check 与 Draw 之间漂移也不会造成已扣费命令无法开奖。
|
||||||
- **单调放量**:`bucket < traffic` 的比较方式保证调大流量时已在实验组的用户全部保留,只有新用户迁入;
|
- **单调放量**:`bucket < traffic` 的比较方式保证调大流量时已在实验组的用户全部保留,只有新用户迁入;
|
||||||
调小流量会把部分用户迁回对照组(用户状态跨版本共享,见下)。
|
调小流量会把部分用户迁回对照组(用户状态跨版本共享,见下)。
|
||||||
- 白名单(`lucky_gift_experiment_overrides`)优先于哈希,可强制任一组,用于放量前的真机验证(流量 0% + 白名单);
|
- 白名单(`lucky_gift_experiment_overrides`)优先于哈希,可强制任一组,用于放量前的真机验证(流量 0% + 白名单);
|
||||||
@ -43,7 +43,7 @@ AB 实验在其上加一层"版本钉住"语义,不改变版本流本身:
|
|||||||
| 机制 | 维度 | 实验语义 |
|
| 机制 | 维度 | 实验语义 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| RTP 窗口 `lucky_rtp_windows` | `pool:v{rule_version}` | 天然按组隔离,两组 RTP 观察互不污染 |
|
| RTP 窗口 `lucky_rtp_windows` | `pool:v{rule_version}` | 天然按组隔离,两组 RTP 观察互不污染 |
|
||||||
| 资金池 `lucky_pools` | fixed_v2: `pool`+poolID;dynamic_v3: `pool_dynamic_v3`+poolID | 两组同策略(强制),共享同一口支付能力,按组核算走抽奖事实 |
|
| 资金池 `lucky_pools` | fixed_v2: `pool`+poolID;dynamic_v3: `pool_dynamic_v3`+poolID | 同策略组共享对应资金账本;跨策略实验分别使用 V2 与 V3 独立账本,按组核算走抽奖事实 |
|
||||||
| 用户状态 `lucky_user_states`、日/72h RTP、六维风控 | 按用户/设备/房间,不含版本 | 跨组共享;用户粘在一组内,状态连续,不因实验重置保底/风控额度 |
|
| 用户状态 `lucky_user_states`、日/72h RTP、六维风控 | 按用户/设备/房间,不含版本 | 跨组共享;用户粘在一组内,状态连续,不因实验重置保底/风控额度 |
|
||||||
| 抽奖事实 `lucky_draw_records` | 每抽落 `rule_version` | 分组统计免费:按 rule_version 聚合即得两组口径 |
|
| 抽奖事实 `lucky_draw_records` | 每抽落 `rule_version` | 分组统计免费:按 rule_version 聚合即得两组口径 |
|
||||||
|
|
||||||
|
|||||||
@ -196,7 +196,7 @@ adjusted_weight = base_weight × water_factor × recharge_factor
|
|||||||
- 一批内共享状态只锁一次并按顺序在内存推进,最终批量持久化;性能优化不能改变顺序语义。
|
- 一批内共享状态只锁一次并按顺序在内存推进,最终批量持久化;性能优化不能改变顺序语义。
|
||||||
- 聚合钱包返奖和房间表现可以按送礼命令合并,但审计仍保留 N 条单抽事实。
|
- 聚合钱包返奖和房间表现可以按送礼命令合并,但审计仍保留 N 条单抽事实。
|
||||||
|
|
||||||
外部 App 的 `/lucky-gifts/send` 也遵守同一语义:`dynamic_v3` 逐次执行最多 999 抽、写 `external_lucky_gift_draw_items`,最后只把总奖励聚合返回;外部调用方自行入账,因此不生成 HyApp wallet 返奖 outbox。外部 `dynamic_v3` 必须提交稳定 `device_id`、真实扣费 `paid_at_ms` 和账号真实 `user_registered_at_ms`,同设备多账号共享设备日上限;但 luck-gateway 目前只有 App allowlist,不能独立证明这些调用方事实,所以接入方必须在自身认证/请求签名边界内把它们绑定到 `external_user_id`。owner 不得用 `request_id`、首次送礼时间或请求到达时间兜底注册年龄。当前外部协议没有可验证的充值 owner 快照,运行侧明确按 `novice` 且不加最近充值因子,不能从 `metadata` 猜充值额。`fixed_v2` 外部请求继续保持缺少新事实的历史兼容路径。
|
外部 App 的 `/lucky-gifts/send` 也遵守同一语义:`dynamic_v3` 逐次执行最多 999 抽、写 `external_lucky_gift_draw_items`,最后只把总奖励聚合返回;外部调用方自行入账,因此不生成 HyApp wallet 返奖 outbox。外部 `dynamic_v3` 必须提交真实扣费 `paid_at_ms`,并应在自身认证/签名边界内提供稳定 `device_id` 和真实 `user_registered_at_ms`。可信 `device_id` 存在时同设备多账号共享设备日上限;缺失时保持空值并只跳过设备维度,owner 不得用 `external_user_id`、`request_id` 或会话值伪造设备 scope。缺失注册事实不阻断普通开奖,但关闭 RTP 补偿大奖资格;owner 不得用首次送礼时间或请求到达时间兜底注册年龄。当前外部协议没有可验证的充值 owner 快照,运行侧明确按 `novice` 且不加最近充值因子,不能从 `metadata` 猜充值额。`fixed_v2` 外部请求继续保持缺少新事实的历史兼容路径。
|
||||||
|
|
||||||
## 9. 接口和配置字段
|
## 9. 接口和配置字段
|
||||||
|
|
||||||
|
|||||||
@ -45,7 +45,7 @@ GET https://lucky-api.global-interaction.com/healthz/ready
|
|||||||
1. 向平台申请唯一 `app_code`,并加入幸运礼物网关白名单。
|
1. 向平台申请唯一 `app_code`,并加入幸运礼物网关白名单。
|
||||||
2. 确认该 App 的幸运礼物规则已启用。
|
2. 确认该 App 的幸运礼物规则已启用。
|
||||||
3. 对接 App 必须先扣费,再调用抽奖接口。
|
3. 对接 App 必须先扣费,再调用抽奖接口。
|
||||||
4. 如果使用 `dynamic_v3`,对接 App 必须能够提供真实 `paid_at_ms`、稳定 `device_id` 和真实 `user_registered_at_ms`。
|
4. 如果使用 `dynamic_v3`,对接 App 必须提供真实 `paid_at_ms`;应尽量提供稳定 `device_id` 和真实 `user_registered_at_ms`,以启用完整设备风控与 RTP 补偿资格判断。
|
||||||
5. 本接口只允许对接 App 服务端调用,禁止将请求能力下发到 App 或 Web 客户端。
|
5. 本接口只允许对接 App 服务端调用,禁止将请求能力下发到 App 或 Web 客户端。
|
||||||
|
|
||||||
### 3.1 真实性与信任边界
|
### 3.1 真实性与信任边界
|
||||||
@ -55,7 +55,7 @@ GET https://lucky-api.global-interaction.com/healthz/ready
|
|||||||
对接 App 必须在自己的服务端边界内完成以下保障:
|
对接 App 必须在自己的服务端边界内完成以下保障:
|
||||||
|
|
||||||
- `external_user_id` 来自已登录用户,不直接信任客户端自报值。
|
- `external_user_id` 来自已登录用户,不直接信任客户端自报值。
|
||||||
- `device_id` 与该次已鉴权的用户/设备关系绑定,并在同一设备的后续请求中保持稳定;不能用随机数、请求号或会话 token 临时代替。
|
- 提供 `device_id` 时,必须与该次已鉴权的用户/设备关系绑定,并在同一设备的后续请求中保持稳定;不能用随机数、请求号或会话 token 临时代替。无法提供时应保持为空,平台会跳过设备日上限而不是伪造设备。
|
||||||
- `paid_at_ms` 由扣费成功的账务事实产生,`user_registered_at_ms` 从用户主数据读取,不使用幸运礼物接口的收包时间或用户首次抽奖时间代替。
|
- `paid_at_ms` 由扣费成功的账务事实产生,`user_registered_at_ms` 从用户主数据读取,不使用幸运礼物接口的收包时间或用户首次抽奖时间代替。
|
||||||
- 如果对接 App 自身使用请求签名,签名内容必须覆盖这些字段;重试时必须保持原始业务事实不变。
|
- 如果对接 App 自身使用请求签名,签名内容必须覆盖这些字段;重试时必须保持原始业务事实不变。
|
||||||
|
|
||||||
@ -100,7 +100,7 @@ X-Request-Id: trace-20260715-000001
|
|||||||
| `app_code` | string | 是 | 去除首尾空格后 1–32 字符 | 平台分配的 App 编码;按小写存储和校验。 |
|
| `app_code` | string | 是 | 去除首尾空格后 1–32 字符 | 平台分配的 App 编码;按小写存储和校验。 |
|
||||||
| `request_id` | string | 是 | 去除首尾空格后 1–128 字符 | 对接 App 生成的业务幂等 ID;建议直接使用送礼订单号或 UUID。 |
|
| `request_id` | string | 是 | 去除首尾空格后 1–128 字符 | 对接 App 生成的业务幂等 ID;建议直接使用送礼订单号或 UUID。 |
|
||||||
| `external_user_id` | string | 是 | 去除首尾空格后 1–128 字符 | 用户在对接 App 中的稳定唯一 ID。 |
|
| `external_user_id` | string | 是 | 去除首尾空格后 1–128 字符 | 用户在对接 App 中的稳定唯一 ID。 |
|
||||||
| `device_id` | string | `dynamic_v3` 是 | 去除首尾空格后 1–128 字符 | 对接 App 在自己鉴权/签名边界内绑定的稳定设备标识;用于设备级风控,不得每次请求重新生成。 |
|
| `device_id` | string | 否 | 空或去除首尾空格后 1–128 字符 | 提供可信稳定值时启用设备日返奖上限;缺失时只跳过设备维度,其他返奖上限继续生效。不得每次请求重新生成。 |
|
||||||
| `gift_count` | int64 | 是 | `> 0` | 本次已扣费的礼物数量。 |
|
| `gift_count` | int64 | 是 | `> 0` | 本次已扣费的礼物数量。 |
|
||||||
| `unit_amount` | int64 | 是 | `> 0` | 单个礼物的金币整数价格。 |
|
| `unit_amount` | int64 | 是 | `> 0` | 单个礼物的金币整数价格。 |
|
||||||
| `paid_at_ms` | int64 | `dynamic_v3` 是 | `> 0`,Unix epoch milliseconds | 对接 App 账务系统完成本次扣费的真实 UTC 时间;系统不会用接口收到请求的时间代替。 |
|
| `paid_at_ms` | int64 | `dynamic_v3` 是 | `> 0`,Unix epoch milliseconds | 对接 App 账务系统完成本次扣费的真实 UTC 时间;系统不会用接口收到请求的时间代替。 |
|
||||||
@ -116,7 +116,7 @@ total_amount = gift_count * unit_amount
|
|||||||
|
|
||||||
请求体不得传入 `total_amount` 或 `pool_id`。`total_amount` 由系统根据数量和单价计算,奖池由平台已发布的规则选择。接口会拒绝所有未定义的顶层字段。
|
请求体不得传入 `total_amount` 或 `pool_id`。`total_amount` 由系统根据数量和单价计算,奖池由平台已发布的规则选择。接口会拒绝所有未定义的顶层字段。
|
||||||
|
|
||||||
`dynamic_v3` 下,缺失 `paid_at_ms` 或 `device_id` 会使整次抽奖失败;缺失、不合理或尚未满 48 小时的 `user_registered_at_ms` 不阻断普通抽奖,但 RTP 补偿大奖资格会严格按“不符合”处理。`fixed_v2` 为历史接入保留了缺省兼容,对接方不应依赖该兼容,否则规则切换到 `dynamic_v3` 后会失败或丢失大奖资格。
|
`dynamic_v3` 下,缺失 `paid_at_ms` 会使整次抽奖失败;缺失 `device_id` 不阻断开奖,只跳过设备日返奖上限,用户、房间、主播、单次等其他上限仍生效。缺失、不合理或尚未满 48 小时的 `user_registered_at_ms` 不阻断普通抽奖,但 RTP 补偿大奖资格会严格按“不符合”处理。
|
||||||
|
|
||||||
一个请求只返回一个聚合抽奖结果;`gift_count` 不会产生多个独立响应。
|
一个请求只返回一个聚合抽奖结果;`gift_count` 不会产生多个独立响应。
|
||||||
|
|
||||||
@ -203,7 +203,7 @@ app_code + request_id
|
|||||||
| 400 | 40000 | JSON 无效、出现未定义字段、必填字段缺失、金额非正数或币种不支持 | 修正请求,不要原样重试。 |
|
| 400 | 40000 | JSON 无效、出现未定义字段、必填字段缺失、金额非正数或币种不支持 | 修正请求,不要原样重试。 |
|
||||||
| 403 | 40300 | `app_code` 未加入当前环境白名单 | 联系平台开通或检查环境。 |
|
| 403 | 40300 | `app_code` 未加入当前环境白名单 | 联系平台开通或检查环境。 |
|
||||||
| 404 | 无统一业务码 | 请求路径或 HTTP 方法错误 | 检查 Base URL、路径和请求方法。 |
|
| 404 | 无统一业务码 | 请求路径或 HTTP 方法错误 | 检查 Base URL、路径和请求方法。 |
|
||||||
| 500 | 50000 | 抽奖服务超时、内部处理失败,或 `dynamic_v3` 在规则层发现 `paid_at_ms` / `device_id` 缺失 | 先本地校验 V3 业务事实已齐全;事实齐全时保持原 `request_id` 重试,不得重新扣费。 |
|
| 500 | 50000 | 抽奖服务超时、内部处理失败,或 `dynamic_v3` 在规则层发现 `paid_at_ms` 缺失 | 先本地校验 V3 扣费事实已齐全;事实齐全时保持原 `request_id` 重试,不得重新扣费。 |
|
||||||
|
|
||||||
业务成功必须同时满足 HTTP Status 为 `200` 且响应 `code` 为 `0`。
|
业务成功必须同时满足 HTTP Status 为 `200` 且响应 `code` 为 `0`。
|
||||||
|
|
||||||
|
|||||||
@ -49,11 +49,12 @@ func (h *Handler) checkLuckyGift(writer http.ResponseWriter, request *http.Reque
|
|||||||
resp, err := h.luckyGift.CheckLuckyGift(request.Context(), &luckygiftv1.CheckLuckyGiftRequest{
|
resp, err := h.luckyGift.CheckLuckyGift(request.Context(), &luckygiftv1.CheckLuckyGiftRequest{
|
||||||
// gateway 只做送礼前协议适配;资格、规则版本和奖池判断统一交给 lucky-gift-service,
|
// gateway 只做送礼前协议适配;资格、规则版本和奖池判断统一交给 lucky-gift-service,
|
||||||
// 避免 activity-service 继续持有旧 owner 逻辑,也避免 HTTP 层复制风控/RTP 边界。
|
// 避免 activity-service 继续持有旧 owner 逻辑,也避免 HTTP 层复制风控/RTP 边界。
|
||||||
Meta: httpkit.LuckyGiftMeta(request),
|
Meta: httpkit.LuckyGiftMeta(request),
|
||||||
UserId: auth.UserIDFromContext(request.Context()),
|
UserId: auth.UserIDFromContext(request.Context()),
|
||||||
RoomId: body.RoomID,
|
DeviceId: auth.DeviceIDFromContext(request.Context()),
|
||||||
GiftId: body.GiftID,
|
RoomId: body.RoomID,
|
||||||
PoolId: body.PoolID,
|
GiftId: body.GiftID,
|
||||||
|
PoolId: body.PoolID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpkit.WriteRPCError(writer, request, err)
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
|
|||||||
@ -116,8 +116,8 @@ func ExperimentExternalUserKey(externalUserID string) string {
|
|||||||
|
|
||||||
// ExperimentBucketPPM 把用户确定性地映射到 [0, 1000000) 的分桶坐标。
|
// ExperimentBucketPPM 把用户确定性地映射到 [0, 1000000) 的分桶坐标。
|
||||||
// 对同一 salt+userKey 必须稳定:实验状态不变时 Check 与 Draw 解析到同一组。
|
// 对同一 salt+userKey 必须稳定:实验状态不变时 Check 与 Draw 解析到同一组。
|
||||||
// 放量/白名单/暂停操作仍会让分组在两次解析间漂移——这正是创建实验强制两组同策略的原因:
|
// 放量/白名单/暂停操作仍可能让两次解析落入不同组,因此两代策略都必须兼容旧客户端缺少的可选事实;
|
||||||
// 同策略下漂移只是参数差异,不会击穿 room saga 固化 strategy_version 后对 device/paid_at 的前置拦截。
|
// dynamic_v3 的 paid_at_ms 始终来自扣费后的 wallet 回执,不能依赖 Check 阶段尚不存在的支付事实。
|
||||||
func ExperimentBucketPPM(salt string, userKey string) int64 {
|
func ExperimentBucketPPM(salt string, userKey string) int64 {
|
||||||
hasher := fnv.New64a()
|
hasher := fnv.New64a()
|
||||||
_, _ = hasher.Write([]byte(salt))
|
_, _ = hasher.Write([]byte(salt))
|
||||||
|
|||||||
@ -184,10 +184,11 @@ type RuleConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CheckCommand struct {
|
type CheckCommand struct {
|
||||||
PoolID string
|
PoolID string
|
||||||
GiftID string
|
GiftID string
|
||||||
UserID int64
|
UserID int64
|
||||||
RoomID string
|
DeviceID string
|
||||||
|
RoomID string
|
||||||
}
|
}
|
||||||
|
|
||||||
type DrawCommand struct {
|
type DrawCommand struct {
|
||||||
@ -518,7 +519,7 @@ type UserProfileDetail struct {
|
|||||||
type ExternalDrawCommand struct {
|
type ExternalDrawCommand struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
ExternalUserID string
|
ExternalUserID string
|
||||||
// DeviceID 是外部调用方在自己认证/签名边界内绑定的稳定设备标识;dynamic_v3 必填。
|
// DeviceID 是外部调用方在自己认证/签名边界内绑定的稳定设备标识;缺失时 dynamic_v3 仅跳过设备日限额。
|
||||||
DeviceID string
|
DeviceID string
|
||||||
RequestID string
|
RequestID string
|
||||||
GiftCount int64
|
GiftCount int64
|
||||||
|
|||||||
@ -189,12 +189,14 @@ type StrategyState struct {
|
|||||||
Version int64 `json:"version"`
|
Version int64 `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StrategyRiskCapacity contains the six hard payout ceilings from the product rule.
|
// StrategyRiskCapacity contains the six payout ceilings from the product rule.
|
||||||
// Enabled=false means the caller has not configured this gate. Once enabled, zero is
|
// Enabled=false means the caller has not configured this gate. Once enabled, zero is
|
||||||
// a real zero capacity (not “unlimited”), which prevents missing counters from silently
|
// a real zero capacity (not “unlimited”). SkipDeviceDay is the only dimension-level
|
||||||
// disabling risk control.
|
// exception: legacy clients without a trusted device claim keep all other ceilings while
|
||||||
|
// intentionally omitting the device ceiling instead of inventing a resettable scope.
|
||||||
type StrategyRiskCapacity struct {
|
type StrategyRiskCapacity struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
SkipDeviceDay bool `json:"skip_device_day,omitempty"`
|
||||||
SingleDrawCoins int64 `json:"single_draw_coins"`
|
SingleDrawCoins int64 `json:"single_draw_coins"`
|
||||||
UserHourCoins int64 `json:"user_hour_coins"`
|
UserHourCoins int64 `json:"user_hour_coins"`
|
||||||
UserDayCoins int64 `json:"user_day_coins"`
|
UserDayCoins int64 `json:"user_day_coins"`
|
||||||
@ -1219,7 +1221,11 @@ func (r StrategyRTP) add(wager, payout int64) (StrategyRTP, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c StrategyRiskCapacity) values() []int64 {
|
func (c StrategyRiskCapacity) values() []int64 {
|
||||||
return []int64{c.SingleDrawCoins, c.UserHourCoins, c.UserDayCoins, c.DeviceDayCoins, c.RoomHourCoins, c.AnchorDayCoins}
|
values := []int64{c.SingleDrawCoins, c.UserHourCoins, c.UserDayCoins, c.RoomHourCoins, c.AnchorDayCoins}
|
||||||
|
if !c.SkipDeviceDay {
|
||||||
|
values = append(values, c.DeviceDayCoins)
|
||||||
|
}
|
||||||
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c StrategyRiskCapacity) capacity() int64 {
|
func (c StrategyRiskCapacity) capacity() int64 {
|
||||||
|
|||||||
@ -136,10 +136,14 @@ func (s *Service) Check(ctx context.Context, cmd domain.CheckCommand) (domain.Ch
|
|||||||
// Check 是送礼前拦截,不产生抽奖事实;这里只归一化入参,最终启停仍以仓储中的当前规则为准。
|
// Check 是送礼前拦截,不产生抽奖事实;这里只归一化入参,最终启停仍以仓储中的当前规则为准。
|
||||||
cmd.PoolID = normalizePoolID(cmd.PoolID)
|
cmd.PoolID = normalizePoolID(cmd.PoolID)
|
||||||
cmd.GiftID = strings.TrimSpace(cmd.GiftID)
|
cmd.GiftID = strings.TrimSpace(cmd.GiftID)
|
||||||
|
cmd.DeviceID = strings.TrimSpace(cmd.DeviceID)
|
||||||
cmd.RoomID = strings.TrimSpace(cmd.RoomID)
|
cmd.RoomID = strings.TrimSpace(cmd.RoomID)
|
||||||
if cmd.GiftID == "" || cmd.UserID <= 0 || cmd.RoomID == "" {
|
if cmd.GiftID == "" || cmd.UserID <= 0 || cmd.RoomID == "" {
|
||||||
return domain.CheckResult{}, xerr.New(xerr.InvalidArgument, "lucky gift check command is incomplete")
|
return domain.CheckResult{}, xerr.New(xerr.InvalidArgument, "lucky gift check command is incomplete")
|
||||||
}
|
}
|
||||||
|
if len(cmd.DeviceID) > 128 {
|
||||||
|
return domain.CheckResult{}, xerr.New(xerr.InvalidArgument, "device_id is too long")
|
||||||
|
}
|
||||||
return s.repository.CheckLuckyGift(ctx, cmd)
|
return s.repository.CheckLuckyGift(ctx, cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -260,8 +264,8 @@ func (s *Service) normalizeDrawCommand(cmd domain.DrawCommand) (domain.DrawComma
|
|||||||
if len(cmd.DeviceID) > 128 {
|
if len(cmd.DeviceID) > 128 {
|
||||||
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw device_id is too long")
|
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw device_id is too long")
|
||||||
}
|
}
|
||||||
// 这一层不能把空 device_id 一概拒绝:存量 fixed_v2 请求/恢复命令没有该字段。
|
// device_id 可为空以兼容旧 JWT;dynamic_v3 只在可信值存在时启用设备日额度,
|
||||||
// 仓储读到不可变规则后才能对 dynamic_v3 做权威 fail-close,期间不用 session/command 兜底。
|
// 其他风控维度不受影响,且任何路径都不得用 session/command 伪造设备。
|
||||||
if cmd.TargetUserID < 0 {
|
if cmd.TargetUserID < 0 {
|
||||||
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid")
|
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -300,10 +300,8 @@ func (r *Repository) executeExternalGiftEconomy(ctx context.Context, tx *sql.Tx,
|
|||||||
// executeExternalDynamicGiftEconomy 让外部 App 复用与房间送礼完全相同的 dynamic_v3 内核。
|
// executeExternalDynamicGiftEconomy 让外部 App 复用与房间送礼完全相同的 dynamic_v3 内核。
|
||||||
// 外部调用方自己负责余额和主播收益结算,因此本服务按配置拆账、返回聚合 reward,且只写 external 子抽审计,绝不生成 HyApp wallet outbox。
|
// 外部调用方自己负责余额和主播收益结算,因此本服务按配置拆账、返回聚合 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) {
|
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)
|
trustedDeviceID, err := normalizeOptionalLuckyDynamicDeviceID(cmd.DeviceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// luck-gateway 目前无法独立验证外部设备;dynamic_v3 至少强制调用方提供经其认证/签名绑定的稳定值。
|
|
||||||
// 缺失时不得用 external_user_id/request_id 伪造设备 scope。
|
|
||||||
return externalDrawCandidate{}, 0, err
|
return externalDrawCandidate{}, 0, err
|
||||||
}
|
}
|
||||||
config, err := luckyRuntimeConfigFromRuleConfig(rule)
|
config, err := luckyRuntimeConfigFromRuleConfig(rule)
|
||||||
@ -316,11 +314,17 @@ func (r *Repository) executeExternalDynamicGiftEconomy(ctx context.Context, tx *
|
|||||||
return externalDrawCandidate{}, 0, err
|
return externalDrawCandidate{}, 0, err
|
||||||
}
|
}
|
||||||
externalUserID := externalLuckyUserID(cmd.AppCode, cmd.ExternalUserID)
|
externalUserID := externalLuckyUserID(cmd.AppCode, cmd.ExternalUserID)
|
||||||
|
dynamicDeviceID := ""
|
||||||
|
if trustedDeviceID != "" {
|
||||||
|
// 外部原始设备值只在调用方实际提供时生成内部稳定 scope;缺失时必须保持空值,
|
||||||
|
// 否则所有无设备请求会错误共享一个“空设备”额度。
|
||||||
|
dynamicDeviceID = externalLuckyDeviceID(cmd.AppCode, trustedDeviceID)
|
||||||
|
}
|
||||||
drawCommand := domain.DrawCommand{
|
drawCommand := domain.DrawCommand{
|
||||||
CommandID: "external:" + cmd.AppCode + ":" + cmd.RequestID,
|
CommandID: "external:" + cmd.AppCode + ":" + cmd.RequestID,
|
||||||
PoolID: config.PoolID,
|
PoolID: config.PoolID,
|
||||||
UserID: externalUserID,
|
UserID: externalUserID,
|
||||||
DeviceID: externalLuckyDeviceID(cmd.AppCode, trustedDeviceID),
|
DeviceID: dynamicDeviceID,
|
||||||
RoomID: externalLuckyScopeID(cmd.AppCode),
|
RoomID: externalLuckyScopeID(cmd.AppCode),
|
||||||
AnchorID: externalLuckyAnchorID(cmd.AppCode),
|
AnchorID: externalLuckyAnchorID(cmd.AppCode),
|
||||||
GiftID: config.GiftID,
|
GiftID: config.GiftID,
|
||||||
|
|||||||
@ -66,9 +66,9 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
|
|||||||
if cmd.UserRegisteredAtMS <= 0 || cmd.UserRegisteredAtMS > cmd.PaidAtMS {
|
if cmd.UserRegisteredAtMS <= 0 || cmd.UserRegisteredAtMS > cmd.PaidAtMS {
|
||||||
cmd.UserRegisteredAtMS = 0
|
cmd.UserRegisteredAtMS = 0
|
||||||
}
|
}
|
||||||
// 设备日额度是 dynamic_v3 的硬风控;只有显式的可信 device_id 才能进入事务状态。
|
// 可信 device_id 存在时才启用设备日额度。旧 JWT 缺少该 claim 时保留空值并跳过这一维,
|
||||||
// 不允许用 session_id/command_id 填充,否则换 token 或换命令即可重置设备日上限。
|
// 但用户、房间、主播、单次等其余上限仍照常生效;绝不能用 session_id/command_id 伪造设备作用域。
|
||||||
deviceID, err := normalizeLuckyDynamicDeviceID(cmd.DeviceID)
|
deviceID, err := normalizeOptionalLuckyDynamicDeviceID(cmd.DeviceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -254,6 +254,14 @@ func normalizeLuckyDynamicDeviceID(value string) (string, error) {
|
|||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeOptionalLuckyDynamicDeviceID(value string) (string, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return normalizeLuckyDynamicDeviceID(value)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) rollLuckyDynamicWindows(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, cmd domain.DrawCommand, state *luckyDynamicBatchState, nowMS int64) error {
|
func (r *Repository) rollLuckyDynamicWindows(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, cmd domain.DrawCommand, state *luckyDynamicBatchState, nowMS int64) error {
|
||||||
if config.SettlementWindowWager <= 0 {
|
if config.SettlementWindowWager <= 0 {
|
||||||
return xerr.New(xerr.Internal, "dynamic lucky gift RTP window wager must be positive")
|
return xerr.New(xerr.Internal, "dynamic lucky gift RTP window wager must be positive")
|
||||||
|
|||||||
@ -63,12 +63,23 @@ func TestDynamicLuckyGiftMySQLBatchAndExternalPaths(t *testing.T) {
|
|||||||
missingDevice := command
|
missingDevice := command
|
||||||
missingDevice.CommandID = "dynamic-missing-device"
|
missingDevice.CommandID = "dynamic-missing-device"
|
||||||
missingDevice.DeviceID = ""
|
missingDevice.DeviceID = ""
|
||||||
if _, err := repo.ExecuteLuckyGiftDraw(ctx, missingDevice, now); !xerr.IsCode(err, xerr.InvalidArgument) {
|
missingDevice.PoolID = "internal-dynamic-no-device"
|
||||||
t.Fatalf("dynamic_v3 missing signed device_id should fail closed, got %v", err)
|
missingDevice.GiftCount = 1
|
||||||
|
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, dynamicMySQLTestRule(missingDevice.PoolID), now-700); err != nil {
|
||||||
|
t.Fatalf("publish internal no-device dynamic rule: %v", err)
|
||||||
}
|
}
|
||||||
var missingDeviceDraws int64
|
if _, err := repo.ExecuteLuckyGiftDraw(ctx, missingDevice, now); err != nil {
|
||||||
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("dynamic_v3 missing signed device_id should skip only device cap: %v", err)
|
||||||
t.Fatalf("missing device_id draw facts=%d err=%v", missingDeviceDraws, err)
|
}
|
||||||
|
var missingDeviceDraws, missingDeviceCounters, remainingRiskCounters int64
|
||||||
|
if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id=? AND device_id=''`, missingDevice.CommandID).Scan(&missingDeviceDraws); err != nil || missingDeviceDraws != 1 {
|
||||||
|
t.Fatalf("missing device_id draw facts=%d err=%v, want one completed draw", missingDeviceDraws, err)
|
||||||
|
}
|
||||||
|
if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM lucky_risk_counters WHERE app_code='lalu' AND scope_type='device' AND scope_id LIKE ?`, missingDevice.PoolID+":%").Scan(&missingDeviceCounters); err != nil || missingDeviceCounters != 0 {
|
||||||
|
t.Fatalf("missing device_id counters=%d err=%v, want device dimension skipped", missingDeviceCounters, err)
|
||||||
|
}
|
||||||
|
if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM lucky_risk_counters WHERE app_code='lalu' AND scope_type<>'device' AND scope_id LIKE ?`, missingDevice.PoolID+":%").Scan(&remainingRiskCounters); err != nil || remainingRiskCounters != 4 {
|
||||||
|
t.Fatalf("remaining no-device risk counters=%d err=%v, want user hour/day, room and anchor", remainingRiskCounters, err)
|
||||||
}
|
}
|
||||||
// 两个相同 command_id 的首次请求同时进入:两者都必须成功,后到者等待 command lock 后
|
// 两个相同 command_id 的首次请求同时进入:两者都必须成功,后到者等待 command lock 后
|
||||||
// 从首次事务回读同一批 99 条事实,不能以唯一键冲突失败,更不能额外推进奖池/RTP/风控。
|
// 从首次事务回读同一批 99 条事实,不能以唯一键冲突失败,更不能额外推进奖池/RTP/风控。
|
||||||
@ -156,12 +167,18 @@ func TestDynamicLuckyGiftMySQLBatchAndExternalPaths(t *testing.T) {
|
|||||||
missingExternalDevice := external
|
missingExternalDevice := external
|
||||||
missingExternalDevice.RequestID = "external-dynamic-missing-device"
|
missingExternalDevice.RequestID = "external-dynamic-missing-device"
|
||||||
missingExternalDevice.DeviceID = ""
|
missingExternalDevice.DeviceID = ""
|
||||||
if _, err := repo.ExecuteExternalGiftDraw(ctx, missingExternalDevice, now); !xerr.IsCode(err, xerr.InvalidArgument) {
|
missingExternalDevice.PoolID = "external-dynamic-no-device"
|
||||||
t.Fatalf("external dynamic_v3 missing caller-bound device_id should fail closed, got %v", err)
|
missingExternalDevice.GiftCount = 1
|
||||||
|
missingExternalDevice.TotalAmount = missingExternalDevice.UnitAmount
|
||||||
|
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, dynamicMySQLTestRule(missingExternalDevice.PoolID), now-400); err != nil {
|
||||||
|
t.Fatalf("publish external no-device dynamic rule: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.ExecuteExternalGiftDraw(ctx, missingExternalDevice, now); err != nil {
|
||||||
|
t.Fatalf("external dynamic_v3 missing caller-bound device_id should skip only device cap: %v", err)
|
||||||
}
|
}
|
||||||
var missingExternalDeviceDraws int64
|
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 {
|
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 != 1 {
|
||||||
t.Fatalf("missing external device draw facts=%d err=%v", missingExternalDeviceDraws, err)
|
t.Fatalf("missing external device draw facts=%d err=%v, want one completed aggregate", missingExternalDeviceDraws, err)
|
||||||
}
|
}
|
||||||
var walletSettlementEventsBefore int64
|
var walletSettlementEventsBefore int64
|
||||||
if err := schema.DB.QueryRow(`
|
if err := schema.DB.QueryRow(`
|
||||||
|
|||||||
@ -183,10 +183,15 @@ func (r *Repository) getLuckyDynamicRiskCounters(ctx context.Context, tx *sql.Tx
|
|||||||
specs := []luckyDynamicRiskCounter{
|
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: "hour", BucketKey: hour},
|
||||||
{ScopeType: "user", ScopeID: luckyPoolScopedID(poolID, fmt.Sprintf("%d", cmd.UserID)), WindowType: "day", BucketKey: day},
|
{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: "room", ScopeID: luckyPoolScopedID(poolID, cmd.RoomID), WindowType: "hour", BucketKey: hour},
|
||||||
{ScopeType: "anchor", ScopeID: luckyPoolScopedID(poolID, cmd.AnchorID), WindowType: "day", BucketKey: day},
|
{ScopeType: "anchor", ScopeID: luckyPoolScopedID(poolID, cmd.AnchorID), WindowType: "day", BucketKey: day},
|
||||||
}
|
}
|
||||||
|
if cmd.DeviceID != "" {
|
||||||
|
// 空设备不能聚合到一个共享的空 scope,更不能用会话/命令临时值拆出可无限刷新的 scope。
|
||||||
|
specs = append(specs, luckyDynamicRiskCounter{
|
||||||
|
ScopeType: "device", ScopeID: luckyPoolScopedID(poolID, cmd.DeviceID), WindowType: "day", BucketKey: day,
|
||||||
|
})
|
||||||
|
}
|
||||||
out := make(map[string]luckyDynamicRiskCounter, len(specs))
|
out := make(map[string]luckyDynamicRiskCounter, len(specs))
|
||||||
for _, spec := range specs {
|
for _, spec := range specs {
|
||||||
counter, err := r.getOrCreateLuckyDynamicRiskCounter(ctx, tx, appCode, spec, nowMS)
|
counter, err := r.getOrCreateLuckyDynamicRiskCounter(ctx, tx, appCode, spec, nowMS)
|
||||||
@ -221,12 +226,14 @@ func (r *Repository) getOrCreateLuckyDynamicRiskCounter(ctx context.Context, tx
|
|||||||
|
|
||||||
func luckyDynamicRiskCapacity(config domain.Config, counters map[string]luckyDynamicRiskCounter) domain.StrategyRiskCapacity {
|
func luckyDynamicRiskCapacity(config domain.Config, counters map[string]luckyDynamicRiskCounter) domain.StrategyRiskCapacity {
|
||||||
// 每个值都是“本抽还能支付多少”,而不是配置总额;0 是真实硬锁,不能被解释为 unlimited。
|
// 每个值都是“本抽还能支付多少”,而不是配置总额;0 是真实硬锁,不能被解释为 unlimited。
|
||||||
|
deviceCounter, hasDeviceCounter := counters[luckyDynamicRiskCounterKey("device", "day")]
|
||||||
return domain.StrategyRiskCapacity{
|
return domain.StrategyRiskCapacity{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
SkipDeviceDay: !hasDeviceCounter,
|
||||||
SingleDrawCoins: config.MaxSinglePayout,
|
SingleDrawCoins: config.MaxSinglePayout,
|
||||||
UserHourCoins: maxInt64(0, config.UserHourlyPayoutCap-counters[luckyDynamicRiskCounterKey("user", "hour")].Payout),
|
UserHourCoins: maxInt64(0, config.UserHourlyPayoutCap-counters[luckyDynamicRiskCounterKey("user", "hour")].Payout),
|
||||||
UserDayCoins: maxInt64(0, config.UserDailyPayoutCap-counters[luckyDynamicRiskCounterKey("user", "day")].Payout),
|
UserDayCoins: maxInt64(0, config.UserDailyPayoutCap-counters[luckyDynamicRiskCounterKey("user", "day")].Payout),
|
||||||
DeviceDayCoins: maxInt64(0, config.DeviceDailyPayoutCap-counters[luckyDynamicRiskCounterKey("device", "day")].Payout),
|
DeviceDayCoins: maxInt64(0, config.DeviceDailyPayoutCap-deviceCounter.Payout),
|
||||||
RoomHourCoins: maxInt64(0, config.RoomHourlyPayoutCap-counters[luckyDynamicRiskCounterKey("room", "hour")].Payout),
|
RoomHourCoins: maxInt64(0, config.RoomHourlyPayoutCap-counters[luckyDynamicRiskCounterKey("room", "hour")].Payout),
|
||||||
AnchorDayCoins: maxInt64(0, config.AnchorDailyPayoutCap-counters[luckyDynamicRiskCounterKey("anchor", "day")].Payout),
|
AnchorDayCoins: maxInt64(0, config.AnchorDailyPayoutCap-counters[luckyDynamicRiskCounterKey("anchor", "day")].Payout),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,8 @@ const luckyExperimentSelectColumns = `app_code, experiment_id, pool_id, name,
|
|||||||
// 事务开奖先锁最新规则行,再一致性读取实验:这与创建/结束实验保持"规则行→实验行"顺序,
|
// 事务开奖先锁最新规则行,再一致性读取实验:这与创建/结束实验保持"规则行→实验行"顺序,
|
||||||
// 同时确保 REPEATABLE READ 快照建立在前一笔同奖池结算提交之后,滚动 RTP 不会漏掉刚完成的抽奖。
|
// 同时确保 REPEATABLE READ 快照建立在前一笔同奖池结算提交之后,滚动 RTP 不会漏掉刚完成的抽奖。
|
||||||
// Check 等只读入口不加锁,仍使用普通一致性快照,避免把低频配置检查串行化。
|
// Check 等只读入口不加锁,仍使用普通一致性快照,避免把低频配置检查串行化。
|
||||||
// 实验状态(放量/白名单/暂停)变更会让分组在 Check 与 Draw 之间漂移;创建入口强制两组同策略,漂移无害。
|
// 实验状态(放量/白名单/暂停)变更可能让分组在 Check 与 Draw 之间漂移;两代策略都接受旧 JWT 的空设备,
|
||||||
|
// 而 dynamic_v3 的 paid_at_ms 来自扣费后的 wallet 回执,因此跨策略漂移不会再把已扣费命令卡死。
|
||||||
// 钉住的版本行缺失表示不可变事实被外部破坏,必须 fail-close,不能静默回落最新版本改变实验语义。
|
// 钉住的版本行缺失表示不可变事实被外部破坏,必须 fail-close,不能静默回落最新版本改变实验语义。
|
||||||
func (r *Repository) resolveLuckyGiftRuleConfigForUser(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, userKey string, forUpdate bool) (domain.RuleConfig, bool, error) {
|
func (r *Repository) resolveLuckyGiftRuleConfigForUser(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, userKey string, forUpdate bool) (domain.RuleConfig, bool, error) {
|
||||||
var lockedLatest domain.RuleConfig
|
var lockedLatest domain.RuleConfig
|
||||||
@ -148,20 +149,15 @@ func (r *Repository) CreateLuckyGiftExperiment(ctx context.Context, cmd domain.C
|
|||||||
if !control.Enabled {
|
if !control.Enabled {
|
||||||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment control version must be enabled")
|
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment control version must be enabled")
|
||||||
}
|
}
|
||||||
// 两组必须同策略族。room saga 在 Check 固化 strategy_version 且恢复不重新 Check,而分组会随放量/白名单/
|
|
||||||
// 暂停操作漂移;若两组策略不同,已扣费命令可能翻进 dynamic_v3 组后被 device/paid_at fail-close 永久卡死。
|
|
||||||
// 同策略下分组漂移只是参数差异,两组对命令的强校验要求一致,漂移无害。fixed_v2 -> dynamic_v3 的策略迁移
|
|
||||||
// 走常规发布(room 端已有滚动发布防护),不走实验;何况旧客户端无法进入 dynamic 组,混合策略实验的分组本身就有偏差。
|
|
||||||
if control.StrategyVersion != cmd.TreatmentConfig.StrategyVersion {
|
|
||||||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment arms must share the same strategy version; migrate strategy via a regular publish instead")
|
|
||||||
}
|
|
||||||
// v2 高水位加权滞回标志按 app+pool 单行存储(lucky_pools.v2_boost_active),不随 rule_version 键控;
|
// v2 高水位加权滞回标志按 app+pool 单行存储(lucky_pools.v2_boost_active),不随 rule_version 键控;
|
||||||
// advance 用"当前用户被钉住的组内三元组"推进这份共享状态。两组三元组不同(尤其 control 关/treatment 开
|
// advance 用"当前用户被钉住的组内三元组"推进这份共享状态。两组三元组不同(尤其 control 关/treatment 开
|
||||||
// 的灰度形态)时:control 的每一抽都会清掉 treatment 刚置位的标志,滞回带内加权几乎恒为关;两组阈值不同
|
// 的灰度形态)时:control 的每一抽都会清掉 treatment 刚置位的标志,滞回带内加权几乎恒为关;两组阈值不同
|
||||||
// 还会让一组在自己从未越过阈值的水位按对方的状态加权付奖。加权特性的灰度必须走常规发布,不能走实验。
|
// 还会让一组在自己从未越过阈值的水位按对方的状态加权付奖。该限制只适用于两组都是 fixed_v2;
|
||||||
if control.V2HighWaterBoostThresholdCoins != cmd.TreatmentConfig.V2HighWaterBoostThresholdCoins ||
|
// V2/V3 混合实验的 dynamic_v3 使用独立资金账本,不读取或推进 V2 的滞回标志。
|
||||||
control.V2HighWaterBoostFactorPPM != cmd.TreatmentConfig.V2HighWaterBoostFactorPPM ||
|
if control.StrategyVersion == domain.StrategyFixedV2 && cmd.TreatmentConfig.StrategyVersion == domain.StrategyFixedV2 &&
|
||||||
control.V2HighWaterBoostRecoverCoins != cmd.TreatmentConfig.V2HighWaterBoostRecoverCoins {
|
(control.V2HighWaterBoostThresholdCoins != cmd.TreatmentConfig.V2HighWaterBoostThresholdCoins ||
|
||||||
|
control.V2HighWaterBoostFactorPPM != cmd.TreatmentConfig.V2HighWaterBoostFactorPPM ||
|
||||||
|
control.V2HighWaterBoostRecoverCoins != cmd.TreatmentConfig.V2HighWaterBoostRecoverCoins) {
|
||||||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment arms must share identical v2 high-water boost settings; the pool-level hysteresis flag cannot serve two different boost configs")
|
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment arms must share identical v2 high-water boost settings; the pool-level hysteresis flag cannot serve two different boost configs")
|
||||||
}
|
}
|
||||||
// 锁读确保看见其他事务刚提交的实验行;同奖池并发建实验最终也会被唯一键兜底拒绝。
|
// 锁读确保看见其他事务刚提交的实验行;同奖池并发建实验最终也会被唯一键兜底拒绝。
|
||||||
|
|||||||
@ -215,9 +215,9 @@ func TestLuckyGiftExperimentArmStats(t *testing.T) {
|
|||||||
_ = experiment
|
_ = experiment
|
||||||
}
|
}
|
||||||
|
|
||||||
// 回归:两组必须同策略族。room saga 在 Check 固化 strategy_version 且恢复不重新 Check,
|
// 回归:实验组允许跨代策略。旧客户端缺 device_id 时 V3 只跳过设备限额,wallet 回执仍提供 paid_at,
|
||||||
// 混合策略实验的分组漂移会把已扣费命令打进 dynamic_v3 的 fail-close,必须在创建入口拒绝。
|
// 因此 fixed_v2 与 dynamic_v3 可以安全按同一用户键分流并保持各自不可变版本。
|
||||||
func TestCreateExperimentRejectsMixedStrategyArms(t *testing.T) {
|
func TestCreateExperimentAllowsMixedStrategyArms(t *testing.T) {
|
||||||
caller := mysqlschema.CallerFile(t, 1)
|
caller := mysqlschema.CallerFile(t, 1)
|
||||||
schema := mysqlschema.New(t, mysqlschema.Config{
|
schema := mysqlschema.New(t, mysqlschema.Config{
|
||||||
EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN",
|
EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN",
|
||||||
@ -230,10 +230,23 @@ func TestCreateExperimentRejectsMixedStrategyArms(t *testing.T) {
|
|||||||
t.Fatalf("publish fixed control: %v", err)
|
t.Fatalf("publish fixed control: %v", err)
|
||||||
}
|
}
|
||||||
dynamicTreatment := dynamicMySQLTestRule("lucky")
|
dynamicTreatment := dynamicMySQLTestRule("lucky")
|
||||||
if _, _, err := repo.CreateLuckyGiftExperiment(ctx, domain.CreateExperimentCommand{
|
experiment, published, err := repo.CreateLuckyGiftExperiment(ctx, domain.CreateExperimentCommand{
|
||||||
Name: "mixed", TreatmentConfig: dynamicTreatment, OperatorAdminID: 7,
|
Name: "mixed", TreatmentConfig: dynamicTreatment, OperatorAdminID: 7,
|
||||||
}, 1_700_000_100_000); err == nil {
|
Overrides: []domain.ExperimentOverride{{UserKey: "1001", Arm: domain.ExperimentArmTreatment}},
|
||||||
t.Fatal("mixed-strategy experiment must be rejected")
|
}, 1_700_000_100_000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create mixed-strategy experiment: %v", err)
|
||||||
|
}
|
||||||
|
if experiment.ControlRuleVersion != 1 || experiment.TreatmentRuleVersion != 2 || published.StrategyVersion != domain.StrategyDynamicV3 {
|
||||||
|
t.Fatalf("mixed experiment versions=%d/%d published strategy=%q", experiment.ControlRuleVersion, experiment.TreatmentRuleVersion, published.StrategyVersion)
|
||||||
|
}
|
||||||
|
control, exists, err := repo.resolveLuckyGiftRuleConfigForUser(ctx, schema.DB, "lalu", "lucky", "2002", false)
|
||||||
|
if err != nil || !exists || control.StrategyVersion != domain.StrategyFixedV2 {
|
||||||
|
t.Fatalf("mixed control resolution=%+v exists=%v err=%v", control, exists, err)
|
||||||
|
}
|
||||||
|
treatment, exists, err := repo.resolveLuckyGiftRuleConfigForUser(ctx, schema.DB, "lalu", "lucky", "1001", false)
|
||||||
|
if err != nil || !exists || treatment.StrategyVersion != domain.StrategyDynamicV3 {
|
||||||
|
t.Fatalf("mixed treatment resolution=%+v exists=%v err=%v", treatment, exists, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -25,10 +25,11 @@ func NewLuckyGiftServer(svc *service.Service) *LuckyGiftServer {
|
|||||||
func (s *LuckyGiftServer) CheckLuckyGift(ctx context.Context, req *luckygiftv1.CheckLuckyGiftRequest) (*luckygiftv1.CheckLuckyGiftResponse, error) {
|
func (s *LuckyGiftServer) CheckLuckyGift(ctx context.Context, req *luckygiftv1.CheckLuckyGiftRequest) (*luckygiftv1.CheckLuckyGiftResponse, error) {
|
||||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
result, err := s.svc.Check(ctx, domain.CheckCommand{
|
result, err := s.svc.Check(ctx, domain.CheckCommand{
|
||||||
PoolID: req.GetPoolId(),
|
PoolID: req.GetPoolId(),
|
||||||
GiftID: req.GetGiftId(),
|
GiftID: req.GetGiftId(),
|
||||||
UserID: req.GetUserId(),
|
UserID: req.GetUserId(),
|
||||||
RoomID: req.GetRoomId(),
|
DeviceID: req.GetDeviceId(),
|
||||||
|
RoomID: req.GetRoomId(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, xerr.ToGRPCError(err)
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
|||||||
@ -234,11 +234,12 @@ func (f *giftFlow) prepareRoomFacts() error {
|
|||||||
return xerr.New(xerr.Unavailable, "lucky gift service is not configured")
|
return xerr.New(xerr.Unavailable, "lucky gift service is not configured")
|
||||||
}
|
}
|
||||||
checkResp, err := f.s.luckyGift.CheckLuckyGift(f.ctx, &luckygiftv1.CheckLuckyGiftRequest{
|
checkResp, err := f.s.luckyGift.CheckLuckyGift(f.ctx, &luckygiftv1.CheckLuckyGiftRequest{
|
||||||
Meta: luckyGiftMetaFromRoom(f.ctx, f.req.GetMeta()),
|
Meta: luckyGiftMetaFromRoom(f.ctx, f.req.GetMeta()),
|
||||||
UserId: f.cmd.ActorUserID(),
|
UserId: f.cmd.ActorUserID(),
|
||||||
RoomId: f.cmd.RoomID(),
|
DeviceId: f.cmd.DeviceID,
|
||||||
GiftId: f.cmd.GiftID,
|
RoomId: f.cmd.RoomID(),
|
||||||
PoolId: f.cmd.PoolID,
|
GiftId: f.cmd.GiftID,
|
||||||
|
PoolId: f.cmd.PoolID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -250,11 +251,6 @@ func (f *giftFlow) prepareRoomFacts() error {
|
|||||||
// 防止后台切换策略后把一笔已扣费命令从 fixed_v2 兼容语义改成 dynamic_v3。
|
// 防止后台切换策略后把一笔已扣费命令从 fixed_v2 兼容语义改成 dynamic_v3。
|
||||||
f.cmd.LuckyGiftStrategyVersion = normalizeLuckyGiftStrategyVersion(checkResp.GetStrategyVersion())
|
f.cmd.LuckyGiftStrategyVersion = normalizeLuckyGiftStrategyVersion(checkResp.GetStrategyVersion())
|
||||||
f.settledCommand.LuckyGiftStrategyVersion = f.cmd.LuckyGiftStrategyVersion
|
f.settledCommand.LuckyGiftStrategyVersion = f.cmd.LuckyGiftStrategyVersion
|
||||||
if f.cmd.LuckyGiftStrategyVersion == luckyGiftStrategyDynamicV3 && strings.TrimSpace(f.cmd.DeviceID) == "" {
|
|
||||||
// 新 dynamic_v3 请求在 wallet 扣费前拒绝旧 JWT,避免创建一笔因缺设备作用域而无法完成的扣费 saga。
|
|
||||||
// 已扣费恢复仍由 lucky-gift owner 的同一校验 fail-close,不得用 sid/command_id 补值。
|
|
||||||
return xerr.New(xerr.InvalidArgument, "device_id is required for dynamic lucky gift")
|
|
||||||
}
|
|
||||||
// Check 只说明“允许按该 pool 抽奖”,真实价格、区域可用性和扣费仍以 wallet-service 为准。
|
// Check 只说明“允许按该 pool 抽奖”,真实价格、区域可用性和扣费仍以 wallet-service 为准。
|
||||||
f.luckyEnabled = true
|
f.luckyEnabled = true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,7 +100,7 @@ func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd c
|
|||||||
UserId: cmd.ActorUserID(),
|
UserId: cmd.ActorUserID(),
|
||||||
TargetUserId: targetBilling.TargetUserID,
|
TargetUserId: targetBilling.TargetUserID,
|
||||||
// DeviceID 已在首次请求进入 gift operation 时随 command.Base 持久化,恢复必须继续使用该值。
|
// DeviceID 已在首次请求进入 gift operation 时随 command.Base 持久化,恢复必须继续使用该值。
|
||||||
// 空值原样传给 owner:fixed_v2 兼容旧 JWT,dynamic_v3 拒绝;绝不用 session_id/command_id 伪造设备。
|
// 空值原样传给 owner:dynamic_v3 会跳过设备日额度但保留其他风控;绝不用 session_id/command_id 伪造设备。
|
||||||
DeviceId: cmd.DeviceID,
|
DeviceId: cmd.DeviceID,
|
||||||
RoomId: cmd.RoomID(),
|
RoomId: cmd.RoomID(),
|
||||||
AnchorId: luckyGiftAnchorID(roomMeta),
|
AnchorId: luckyGiftAnchorID(roomMeta),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user