feat(lucky-gift): support optional device and cross-strategy experiments

This commit is contained in:
zhx 2026-07-17 11:45:25 +08:00
parent bf6d7d4f1e
commit b7378c98db
19 changed files with 154 additions and 88 deletions

View File

@ -874,6 +874,8 @@ type CheckLuckyGiftRequest struct {
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" +

View File

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

View File

@ -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`+poolIDdynamic_v3: `pool_dynamic_v3`+poolID | 两组同策略(强制),共享同一口支付能力,按组核算走抽奖事实 | | 资金池 `lucky_pools` | fixed_v2: `pool`+poolIDdynamic_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 聚合即得两组口径 |

View File

@ -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. 接口和配置字段

View File

@ -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 | 是 | 去除首尾空格后 132 字符 | 平台分配的 App 编码;按小写存储和校验。 | | `app_code` | string | 是 | 去除首尾空格后 132 字符 | 平台分配的 App 编码;按小写存储和校验。 |
| `request_id` | string | 是 | 去除首尾空格后 1128 字符 | 对接 App 生成的业务幂等 ID建议直接使用送礼订单号或 UUID。 | | `request_id` | string | 是 | 去除首尾空格后 1128 字符 | 对接 App 生成的业务幂等 ID建议直接使用送礼订单号或 UUID。 |
| `external_user_id` | string | 是 | 去除首尾空格后 1128 字符 | 用户在对接 App 中的稳定唯一 ID。 | | `external_user_id` | string | 是 | 去除首尾空格后 1128 字符 | 用户在对接 App 中的稳定唯一 ID。 |
| `device_id` | string | `dynamic_v3` 是 | 去除首尾空格后 1128 字符 | 对接 App 在自己鉴权/签名边界内绑定的稳定设备标识;用于设备级风控,不得每次请求重新生成。 | | `device_id` | string | 否 | 空或去除首尾空格后 1128 字符 | 提供可信稳定值时启用设备日返奖上限;缺失时只跳过设备维度,其他返奖上限继续生效。不得每次请求重新生成。 |
| `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`

View File

@ -51,6 +51,7 @@ func (h *Handler) checkLuckyGift(writer http.ResponseWriter, request *http.Reque
// 避免 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()),
DeviceId: auth.DeviceIDFromContext(request.Context()),
RoomId: body.RoomID, RoomId: body.RoomID,
GiftId: body.GiftID, GiftId: body.GiftID,
PoolId: body.PoolID, PoolId: body.PoolID,

View File

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

View File

@ -187,6 +187,7 @@ type CheckCommand struct {
PoolID string PoolID string
GiftID string GiftID string
UserID int64 UserID int64
DeviceID string
RoomID string RoomID string
} }
@ -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

View File

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

View File

@ -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 可为空以兼容旧 JWTdynamic_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")
} }

View File

@ -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,

View File

@ -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")

View File

@ -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(`

View File

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

View File

@ -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 的滞回标志。
if control.StrategyVersion == domain.StrategyFixedV2 && cmd.TreatmentConfig.StrategyVersion == domain.StrategyFixedV2 &&
(control.V2HighWaterBoostThresholdCoins != cmd.TreatmentConfig.V2HighWaterBoostThresholdCoins ||
control.V2HighWaterBoostFactorPPM != cmd.TreatmentConfig.V2HighWaterBoostFactorPPM || control.V2HighWaterBoostFactorPPM != cmd.TreatmentConfig.V2HighWaterBoostFactorPPM ||
control.V2HighWaterBoostRecoverCoins != cmd.TreatmentConfig.V2HighWaterBoostRecoverCoins { 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")
} }
// 锁读确保看见其他事务刚提交的实验行;同奖池并发建实验最终也会被唯一键兜底拒绝。 // 锁读确保看见其他事务刚提交的实验行;同奖池并发建实验最终也会被唯一键兜底拒绝。

View File

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

View File

@ -28,6 +28,7 @@ func (s *LuckyGiftServer) CheckLuckyGift(ctx context.Context, req *luckygiftv1.C
PoolID: req.GetPoolId(), PoolID: req.GetPoolId(),
GiftID: req.GetGiftId(), GiftID: req.GetGiftId(),
UserID: req.GetUserId(), UserID: req.GetUserId(),
DeviceID: req.GetDeviceId(),
RoomID: req.GetRoomId(), RoomID: req.GetRoomId(),
}) })
if err != nil { if err != nil {

View File

@ -236,6 +236,7 @@ func (f *giftFlow) prepareRoomFacts() error {
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(),
DeviceId: f.cmd.DeviceID,
RoomId: f.cmd.RoomID(), RoomId: f.cmd.RoomID(),
GiftId: f.cmd.GiftID, GiftId: f.cmd.GiftID,
PoolId: f.cmd.PoolID, PoolId: f.cmd.PoolID,
@ -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
} }

View File

@ -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 持久化,恢复必须继续使用该值。
// 空值原样传给 ownerfixed_v2 兼容旧 JWTdynamic_v3 拒绝;绝不用 session_id/command_id 伪造设备。 // 空值原样传给 ownerdynamic_v3 会跳过设备日额度但保留其他风控;绝不用 session_id/command_id 伪造设备。
DeviceId: cmd.DeviceID, DeviceId: cmd.DeviceID,
RoomId: cmd.RoomID(), RoomId: cmd.RoomID(),
AnchorId: luckyGiftAnchorID(roomMeta), AnchorId: luckyGiftAnchorID(roomMeta),