From b7378c98db0b576f310acf807bf41bed1367fedc Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 17 Jul 2026 11:45:25 +0800 Subject: [PATCH] feat(lucky-gift): support optional device and cross-strategy experiments --- api/proto/luckygift/v1/luckygift.pb.go | 26 +++++++++----- api/proto/luckygift/v1/luckygift.proto | 2 ++ docs/幸运礼物AB实验.md | 10 +++--- docs/幸运礼物动态策略V3实现与模拟.md | 2 +- docs/幸运礼物外接接口对接文档.md | 10 +++--- .../http/activityapi/lucky_gift_handler.go | 11 +++--- .../internal/domain/luckygift/experiment.go | 4 +-- .../internal/domain/luckygift/lucky_gift.go | 11 +++--- .../internal/domain/luckygift/strategy.go | 14 +++++--- .../internal/service/luckygift/service.go | 8 +++-- .../storage/mysql/external_repository.go | 12 ++++--- .../mysql/lucky_gift_dynamic_repository.go | 14 ++++++-- .../lucky_gift_dynamic_repository_test.go | 35 ++++++++++++++----- .../storage/mysql/lucky_gift_dynamic_state.go | 11 ++++-- .../mysql/lucky_gift_experiment_repository.go | 20 +++++------ .../lucky_gift_experiment_repository_test.go | 25 +++++++++---- .../transport/grpc/lucky_gift_server.go | 9 ++--- .../internal/room/service/gift_flow.go | 16 ++++----- .../internal/room/service/gift_lucky.go | 2 +- 19 files changed, 154 insertions(+), 88 deletions(-) diff --git a/api/proto/luckygift/v1/luckygift.pb.go b/api/proto/luckygift/v1/luckygift.pb.go index c7cb4800..c8560c23 100644 --- a/api/proto/luckygift/v1/luckygift.pb.go +++ b/api/proto/luckygift/v1/luckygift.pb.go @@ -868,12 +868,14 @@ func (x *LuckyGiftRuleConfig) GetV2HighWaterBoostRecoverCoins() int64 { } type CheckLuckyGiftRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - 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"` - 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"` - PoolId string `protobuf:"bytes,5,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + 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"` + 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"` + 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 sizeCache protoimpl.SizeCache } @@ -943,6 +945,13 @@ func (x *CheckLuckyGiftRequest) GetPoolId() string { return "" } +func (x *CheckLuckyGiftRequest) GetDeviceId() string { + if x != nil { + return x.DeviceId + } + return "" +} + type CheckLuckyGiftResponse struct { state protoimpl.MessageState `protogen:"open.v1"` 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" + "#v2_high_water_boost_threshold_coins\x18) \x01(\x03R\x1ev2HighWaterBoostThresholdCoins\x12A\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" + "\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\n" + "\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x17\n" + "\aroom_id\x18\x03 \x01(\tR\x06roomId\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" + "\aenabled\x18\x01 \x01(\bR\aenabled\x12\x16\n" + "\x06reason\x18\x02 \x01(\tR\x06reason\x12\x17\n" + diff --git a/api/proto/luckygift/v1/luckygift.proto b/api/proto/luckygift/v1/luckygift.proto index c2660594..1dc5c275 100644 --- a/api/proto/luckygift/v1/luckygift.proto +++ b/api/proto/luckygift/v1/luckygift.proto @@ -124,6 +124,8 @@ message CheckLuckyGiftRequest { string room_id = 3; string gift_id = 4; string pool_id = 5; + // 可选的可信设备 claim。旧 token 缺失时保持空值;实验分组只按用户键,不得按设备改变组别。 + string device_id = 6; } message CheckLuckyGiftResponse { diff --git a/docs/幸运礼物AB实验.md b/docs/幸运礼物AB实验.md index d8d73076..55faef46 100644 --- a/docs/幸运礼物AB实验.md +++ b/docs/幸运礼物AB实验.md @@ -28,10 +28,10 @@ AB 实验在其上加一层"版本钉住"语义,不改变版本流本身: - userKey:内部用户为十进制 user_id;外部 App 用户为 `ext:` + external_user_id。两条抽奖链路共用同一分流。 白名单 user_key 列使用 utf8mb4_bin(字节精确),与 FNV 分桶口径一致。 - **确定性**:实验状态不变时,同一用户在 Check 与 Draw 解析到同一组(room-service 送礼 saga 在 CheckLuckyGift - 固化 strategy_version、恢复阶段不重新 Check)。放量/白名单/暂停操作会让分组在两次解析间漂移,因此—— -- **两组必须同策略族**(创建实验强制校验):同策略下分组漂移只是参数差异,不会击穿 room saga 对 - device_id/paid_at 的扣费前拦截。fixed_v2 → dynamic_v3 的**策略迁移走常规发布**,不走实验 - (旧客户端本就无法进入 dynamic 组,混合策略实验的分组天然有偏差,统计上也不成立)。 + 固化 strategy_version、恢复阶段不重新 Check)。放量/白名单/暂停操作会让分组在两次解析间漂移。 +- **允许跨策略对照**:control 与 treatment 可以分别使用 fixed_v2、dynamic_v3,用于真实比较两代策略。旧客户端 + 缺少可信 device_id 时,dynamic_v3 只跳过设备日返奖上限,用户/房间/主播等其他风控照常执行;paid_at_ms + 始终来自扣费后的 wallet 回执,所以分组在 Check 与 Draw 之间漂移也不会造成已扣费命令无法开奖。 - **单调放量**:`bucket < traffic` 的比较方式保证调大流量时已在实验组的用户全部保留,只有新用户迁入; 调小流量会把部分用户迁回对照组(用户状态跨版本共享,见下)。 - 白名单(`lucky_gift_experiment_overrides`)优先于哈希,可强制任一组,用于放量前的真机验证(流量 0% + 白名单); @@ -43,7 +43,7 @@ AB 实验在其上加一层"版本钉住"语义,不改变版本流本身: | 机制 | 维度 | 实验语义 | |---|---|---| | 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_draw_records` | 每抽落 `rule_version` | 分组统计免费:按 rule_version 聚合即得两组口径 | diff --git a/docs/幸运礼物动态策略V3实现与模拟.md b/docs/幸运礼物动态策略V3实现与模拟.md index 8435a9d8..1f8b163b 100644 --- a/docs/幸运礼物动态策略V3实现与模拟.md +++ b/docs/幸运礼物动态策略V3实现与模拟.md @@ -196,7 +196,7 @@ adjusted_weight = base_weight × water_factor × recharge_factor - 一批内共享状态只锁一次并按顺序在内存推进,最终批量持久化;性能优化不能改变顺序语义。 - 聚合钱包返奖和房间表现可以按送礼命令合并,但审计仍保留 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. 接口和配置字段 diff --git a/docs/幸运礼物外接接口对接文档.md b/docs/幸运礼物外接接口对接文档.md index e7590008..4a9280e5 100644 --- a/docs/幸运礼物外接接口对接文档.md +++ b/docs/幸运礼物外接接口对接文档.md @@ -45,7 +45,7 @@ GET https://lucky-api.global-interaction.com/healthz/ready 1. 向平台申请唯一 `app_code`,并加入幸运礼物网关白名单。 2. 确认该 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 客户端。 ### 3.1 真实性与信任边界 @@ -55,7 +55,7 @@ GET https://lucky-api.global-interaction.com/healthz/ready 对接 App 必须在自己的服务端边界内完成以下保障: - `external_user_id` 来自已登录用户,不直接信任客户端自报值。 -- `device_id` 与该次已鉴权的用户/设备关系绑定,并在同一设备的后续请求中保持稳定;不能用随机数、请求号或会话 token 临时代替。 +- 提供 `device_id` 时,必须与该次已鉴权的用户/设备关系绑定,并在同一设备的后续请求中保持稳定;不能用随机数、请求号或会话 token 临时代替。无法提供时应保持为空,平台会跳过设备日上限而不是伪造设备。 - `paid_at_ms` 由扣费成功的账务事实产生,`user_registered_at_ms` 从用户主数据读取,不使用幸运礼物接口的收包时间或用户首次抽奖时间代替。 - 如果对接 App 自身使用请求签名,签名内容必须覆盖这些字段;重试时必须保持原始业务事实不变。 @@ -100,7 +100,7 @@ X-Request-Id: trace-20260715-000001 | `app_code` | string | 是 | 去除首尾空格后 1–32 字符 | 平台分配的 App 编码;按小写存储和校验。 | | `request_id` | string | 是 | 去除首尾空格后 1–128 字符 | 对接 App 生成的业务幂等 ID;建议直接使用送礼订单号或 UUID。 | | `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` | 本次已扣费的礼物数量。 | | `unit_amount` | int64 | 是 | `> 0` | 单个礼物的金币整数价格。 | | `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` 由系统根据数量和单价计算,奖池由平台已发布的规则选择。接口会拒绝所有未定义的顶层字段。 -`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` 不会产生多个独立响应。 @@ -203,7 +203,7 @@ app_code + request_id | 400 | 40000 | JSON 无效、出现未定义字段、必填字段缺失、金额非正数或币种不支持 | 修正请求,不要原样重试。 | | 403 | 40300 | `app_code` 未加入当前环境白名单 | 联系平台开通或检查环境。 | | 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`。 diff --git a/services/gateway-service/internal/transport/http/activityapi/lucky_gift_handler.go b/services/gateway-service/internal/transport/http/activityapi/lucky_gift_handler.go index 5f623410..2718975d 100644 --- a/services/gateway-service/internal/transport/http/activityapi/lucky_gift_handler.go +++ b/services/gateway-service/internal/transport/http/activityapi/lucky_gift_handler.go @@ -49,11 +49,12 @@ func (h *Handler) checkLuckyGift(writer http.ResponseWriter, request *http.Reque resp, err := h.luckyGift.CheckLuckyGift(request.Context(), &luckygiftv1.CheckLuckyGiftRequest{ // gateway 只做送礼前协议适配;资格、规则版本和奖池判断统一交给 lucky-gift-service, // 避免 activity-service 继续持有旧 owner 逻辑,也避免 HTTP 层复制风控/RTP 边界。 - Meta: httpkit.LuckyGiftMeta(request), - UserId: auth.UserIDFromContext(request.Context()), - RoomId: body.RoomID, - GiftId: body.GiftID, - PoolId: body.PoolID, + Meta: httpkit.LuckyGiftMeta(request), + UserId: auth.UserIDFromContext(request.Context()), + DeviceId: auth.DeviceIDFromContext(request.Context()), + RoomId: body.RoomID, + GiftId: body.GiftID, + PoolId: body.PoolID, }) if err != nil { httpkit.WriteRPCError(writer, request, err) diff --git a/services/lucky-gift-service/internal/domain/luckygift/experiment.go b/services/lucky-gift-service/internal/domain/luckygift/experiment.go index a7f775b7..62a2ed9f 100644 --- a/services/lucky-gift-service/internal/domain/luckygift/experiment.go +++ b/services/lucky-gift-service/internal/domain/luckygift/experiment.go @@ -116,8 +116,8 @@ func ExperimentExternalUserKey(externalUserID string) string { // ExperimentBucketPPM 把用户确定性地映射到 [0, 1000000) 的分桶坐标。 // 对同一 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 { hasher := fnv.New64a() _, _ = hasher.Write([]byte(salt)) diff --git a/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go b/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go index e42cbe75..04c75eb3 100644 --- a/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go +++ b/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go @@ -184,10 +184,11 @@ type RuleConfig struct { } type CheckCommand struct { - PoolID string - GiftID string - UserID int64 - RoomID string + PoolID string + GiftID string + UserID int64 + DeviceID string + RoomID string } type DrawCommand struct { @@ -518,7 +519,7 @@ type UserProfileDetail struct { type ExternalDrawCommand struct { AppCode string ExternalUserID string - // DeviceID 是外部调用方在自己认证/签名边界内绑定的稳定设备标识;dynamic_v3 必填。 + // DeviceID 是外部调用方在自己认证/签名边界内绑定的稳定设备标识;缺失时 dynamic_v3 仅跳过设备日限额。 DeviceID string RequestID string GiftCount int64 diff --git a/services/lucky-gift-service/internal/domain/luckygift/strategy.go b/services/lucky-gift-service/internal/domain/luckygift/strategy.go index ebf08c75..f57dd24c 100644 --- a/services/lucky-gift-service/internal/domain/luckygift/strategy.go +++ b/services/lucky-gift-service/internal/domain/luckygift/strategy.go @@ -189,12 +189,14 @@ type StrategyState struct { 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 -// a real zero capacity (not “unlimited”), which prevents missing counters from silently -// disabling risk control. +// a real zero capacity (not “unlimited”). SkipDeviceDay is the only dimension-level +// 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 { Enabled bool `json:"enabled"` + SkipDeviceDay bool `json:"skip_device_day,omitempty"` SingleDrawCoins int64 `json:"single_draw_coins"` UserHourCoins int64 `json:"user_hour_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 { - 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 { diff --git a/services/lucky-gift-service/internal/service/luckygift/service.go b/services/lucky-gift-service/internal/service/luckygift/service.go index 273c8bdc..362e8c97 100644 --- a/services/lucky-gift-service/internal/service/luckygift/service.go +++ b/services/lucky-gift-service/internal/service/luckygift/service.go @@ -136,10 +136,14 @@ func (s *Service) Check(ctx context.Context, cmd domain.CheckCommand) (domain.Ch // Check 是送礼前拦截,不产生抽奖事实;这里只归一化入参,最终启停仍以仓储中的当前规则为准。 cmd.PoolID = normalizePoolID(cmd.PoolID) cmd.GiftID = strings.TrimSpace(cmd.GiftID) + cmd.DeviceID = strings.TrimSpace(cmd.DeviceID) cmd.RoomID = strings.TrimSpace(cmd.RoomID) if cmd.GiftID == "" || cmd.UserID <= 0 || cmd.RoomID == "" { 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) } @@ -260,8 +264,8 @@ func (s *Service) normalizeDrawCommand(cmd domain.DrawCommand) (domain.DrawComma 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 兜底。 + // device_id 可为空以兼容旧 JWT;dynamic_v3 只在可信值存在时启用设备日额度, + // 其他风控维度不受影响,且任何路径都不得用 session/command 伪造设备。 if cmd.TargetUserID < 0 { return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid") } diff --git a/services/lucky-gift-service/internal/storage/mysql/external_repository.go b/services/lucky-gift-service/internal/storage/mysql/external_repository.go index d77e0b40..c6e34b27 100644 --- a/services/lucky-gift-service/internal/storage/mysql/external_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/external_repository.go @@ -300,10 +300,8 @@ func (r *Repository) executeExternalGiftEconomy(ctx context.Context, tx *sql.Tx, // 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) + trustedDeviceID, err := normalizeOptionalLuckyDynamicDeviceID(cmd.DeviceID) if err != nil { - // luck-gateway 目前无法独立验证外部设备;dynamic_v3 至少强制调用方提供经其认证/签名绑定的稳定值。 - // 缺失时不得用 external_user_id/request_id 伪造设备 scope。 return externalDrawCandidate{}, 0, err } config, err := luckyRuntimeConfigFromRuleConfig(rule) @@ -316,11 +314,17 @@ func (r *Repository) executeExternalDynamicGiftEconomy(ctx context.Context, tx * return externalDrawCandidate{}, 0, err } externalUserID := externalLuckyUserID(cmd.AppCode, cmd.ExternalUserID) + dynamicDeviceID := "" + if trustedDeviceID != "" { + // 外部原始设备值只在调用方实际提供时生成内部稳定 scope;缺失时必须保持空值, + // 否则所有无设备请求会错误共享一个“空设备”额度。 + dynamicDeviceID = externalLuckyDeviceID(cmd.AppCode, trustedDeviceID) + } drawCommand := domain.DrawCommand{ CommandID: "external:" + cmd.AppCode + ":" + cmd.RequestID, PoolID: config.PoolID, UserID: externalUserID, - DeviceID: externalLuckyDeviceID(cmd.AppCode, trustedDeviceID), + DeviceID: dynamicDeviceID, RoomID: externalLuckyScopeID(cmd.AppCode), AnchorID: externalLuckyAnchorID(cmd.AppCode), GiftID: config.GiftID, diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go index 8b3d45cc..0bf08251 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go @@ -66,9 +66,9 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s if cmd.UserRegisteredAtMS <= 0 || cmd.UserRegisteredAtMS > cmd.PaidAtMS { cmd.UserRegisteredAtMS = 0 } - // 设备日额度是 dynamic_v3 的硬风控;只有显式的可信 device_id 才能进入事务状态。 - // 不允许用 session_id/command_id 填充,否则换 token 或换命令即可重置设备日上限。 - deviceID, err := normalizeLuckyDynamicDeviceID(cmd.DeviceID) + // 可信 device_id 存在时才启用设备日额度。旧 JWT 缺少该 claim 时保留空值并跳过这一维, + // 但用户、房间、主播、单次等其余上限仍照常生效;绝不能用 session_id/command_id 伪造设备作用域。 + deviceID, err := normalizeOptionalLuckyDynamicDeviceID(cmd.DeviceID) if err != nil { return nil, err } @@ -254,6 +254,14 @@ func normalizeLuckyDynamicDeviceID(value string) (string, error) { 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 { if config.SettlementWindowWager <= 0 { return xerr.New(xerr.Internal, "dynamic lucky gift RTP window wager must be positive") diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go index c9863001..5022b585 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go @@ -63,12 +63,23 @@ func TestDynamicLuckyGiftMySQLBatchAndExternalPaths(t *testing.T) { 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) + missingDevice.PoolID = "internal-dynamic-no-device" + 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 := 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) + if _, err := repo.ExecuteLuckyGiftDraw(ctx, missingDevice, now); err != nil { + t.Fatalf("dynamic_v3 missing signed device_id should skip only device cap: %v", 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 后 // 从首次事务回读同一批 99 条事实,不能以唯一键冲突失败,更不能额外推进奖池/RTP/风控。 @@ -156,12 +167,18 @@ func TestDynamicLuckyGiftMySQLBatchAndExternalPaths(t *testing.T) { 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) + missingExternalDevice.PoolID = "external-dynamic-no-device" + 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 - 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) + 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, want one completed aggregate", missingExternalDeviceDraws, err) } var walletSettlementEventsBefore int64 if err := schema.DB.QueryRow(` diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_state.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_state.go index 356d77fb..d7733306 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_state.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_state.go @@ -183,10 +183,15 @@ func (r *Repository) getLuckyDynamicRiskCounters(ctx context.Context, tx *sql.Tx 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}, } + 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)) for _, spec := range specs { 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 { // 每个值都是“本抽还能支付多少”,而不是配置总额;0 是真实硬锁,不能被解释为 unlimited。 + deviceCounter, hasDeviceCounter := counters[luckyDynamicRiskCounterKey("device", "day")] return domain.StrategyRiskCapacity{ Enabled: true, + SkipDeviceDay: !hasDeviceCounter, 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), + DeviceDayCoins: maxInt64(0, config.DeviceDailyPayoutCap-deviceCounter.Payout), RoomHourCoins: maxInt64(0, config.RoomHourlyPayoutCap-counters[luckyDynamicRiskCounterKey("room", "hour")].Payout), AnchorDayCoins: maxInt64(0, config.AnchorDailyPayoutCap-counters[luckyDynamicRiskCounterKey("anchor", "day")].Payout), } diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository.go index 83418cfa..11d72637 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository.go @@ -22,7 +22,8 @@ const luckyExperimentSelectColumns = `app_code, experiment_id, pool_id, name, // 事务开奖先锁最新规则行,再一致性读取实验:这与创建/结束实验保持"规则行→实验行"顺序, // 同时确保 REPEATABLE READ 快照建立在前一笔同奖池结算提交之后,滚动 RTP 不会漏掉刚完成的抽奖。 // Check 等只读入口不加锁,仍使用普通一致性快照,避免把低频配置检查串行化。 -// 实验状态(放量/白名单/暂停)变更会让分组在 Check 与 Draw 之间漂移;创建入口强制两组同策略,漂移无害。 +// 实验状态(放量/白名单/暂停)变更可能让分组在 Check 与 Draw 之间漂移;两代策略都接受旧 JWT 的空设备, +// 而 dynamic_v3 的 paid_at_ms 来自扣费后的 wallet 回执,因此跨策略漂移不会再把已扣费命令卡死。 // 钉住的版本行缺失表示不可变事实被外部破坏,必须 fail-close,不能静默回落最新版本改变实验语义。 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 @@ -148,20 +149,15 @@ func (r *Repository) CreateLuckyGiftExperiment(ctx context.Context, cmd domain.C if !control.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 键控; // advance 用"当前用户被钉住的组内三元组"推进这份共享状态。两组三元组不同(尤其 control 关/treatment 开 // 的灰度形态)时:control 的每一抽都会清掉 treatment 刚置位的标志,滞回带内加权几乎恒为关;两组阈值不同 - // 还会让一组在自己从未越过阈值的水位按对方的状态加权付奖。加权特性的灰度必须走常规发布,不能走实验。 - if control.V2HighWaterBoostThresholdCoins != cmd.TreatmentConfig.V2HighWaterBoostThresholdCoins || - control.V2HighWaterBoostFactorPPM != cmd.TreatmentConfig.V2HighWaterBoostFactorPPM || - control.V2HighWaterBoostRecoverCoins != cmd.TreatmentConfig.V2HighWaterBoostRecoverCoins { + // 还会让一组在自己从未越过阈值的水位按对方的状态加权付奖。该限制只适用于两组都是 fixed_v2; + // 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.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") } // 锁读确保看见其他事务刚提交的实验行;同奖池并发建实验最终也会被唯一键兜底拒绝。 diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository_test.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository_test.go index d1b4d14e..6eec69bb 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository_test.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_experiment_repository_test.go @@ -215,9 +215,9 @@ func TestLuckyGiftExperimentArmStats(t *testing.T) { _ = experiment } -// 回归:两组必须同策略族。room saga 在 Check 固化 strategy_version 且恢复不重新 Check, -// 混合策略实验的分组漂移会把已扣费命令打进 dynamic_v3 的 fail-close,必须在创建入口拒绝。 -func TestCreateExperimentRejectsMixedStrategyArms(t *testing.T) { +// 回归:实验组允许跨代策略。旧客户端缺 device_id 时 V3 只跳过设备限额,wallet 回执仍提供 paid_at, +// 因此 fixed_v2 与 dynamic_v3 可以安全按同一用户键分流并保持各自不可变版本。 +func TestCreateExperimentAllowsMixedStrategyArms(t *testing.T) { caller := mysqlschema.CallerFile(t, 1) schema := mysqlschema.New(t, mysqlschema.Config{ EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN", @@ -230,10 +230,23 @@ func TestCreateExperimentRejectsMixedStrategyArms(t *testing.T) { t.Fatalf("publish fixed control: %v", err) } dynamicTreatment := dynamicMySQLTestRule("lucky") - if _, _, err := repo.CreateLuckyGiftExperiment(ctx, domain.CreateExperimentCommand{ + experiment, published, err := repo.CreateLuckyGiftExperiment(ctx, domain.CreateExperimentCommand{ Name: "mixed", TreatmentConfig: dynamicTreatment, OperatorAdminID: 7, - }, 1_700_000_100_000); err == nil { - t.Fatal("mixed-strategy experiment must be rejected") + Overrides: []domain.ExperimentOverride{{UserKey: "1001", Arm: domain.ExperimentArmTreatment}}, + }, 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) } } diff --git a/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go b/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go index 0b5f06de..84ca3800 100644 --- a/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go +++ b/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go @@ -25,10 +25,11 @@ func NewLuckyGiftServer(svc *service.Service) *LuckyGiftServer { func (s *LuckyGiftServer) CheckLuckyGift(ctx context.Context, req *luckygiftv1.CheckLuckyGiftRequest) (*luckygiftv1.CheckLuckyGiftResponse, error) { ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) result, err := s.svc.Check(ctx, domain.CheckCommand{ - PoolID: req.GetPoolId(), - GiftID: req.GetGiftId(), - UserID: req.GetUserId(), - RoomID: req.GetRoomId(), + PoolID: req.GetPoolId(), + GiftID: req.GetGiftId(), + UserID: req.GetUserId(), + DeviceID: req.GetDeviceId(), + RoomID: req.GetRoomId(), }) if err != nil { return nil, xerr.ToGRPCError(err) diff --git a/services/room-service/internal/room/service/gift_flow.go b/services/room-service/internal/room/service/gift_flow.go index c9cc4f2f..6f46a3d4 100644 --- a/services/room-service/internal/room/service/gift_flow.go +++ b/services/room-service/internal/room/service/gift_flow.go @@ -234,11 +234,12 @@ func (f *giftFlow) prepareRoomFacts() error { return xerr.New(xerr.Unavailable, "lucky gift service is not configured") } checkResp, err := f.s.luckyGift.CheckLuckyGift(f.ctx, &luckygiftv1.CheckLuckyGiftRequest{ - Meta: luckyGiftMetaFromRoom(f.ctx, f.req.GetMeta()), - UserId: f.cmd.ActorUserID(), - RoomId: f.cmd.RoomID(), - GiftId: f.cmd.GiftID, - PoolId: f.cmd.PoolID, + Meta: luckyGiftMetaFromRoom(f.ctx, f.req.GetMeta()), + UserId: f.cmd.ActorUserID(), + DeviceId: f.cmd.DeviceID, + RoomId: f.cmd.RoomID(), + GiftId: f.cmd.GiftID, + PoolId: f.cmd.PoolID, }) if err != nil { return err @@ -250,11 +251,6 @@ func (f *giftFlow) prepareRoomFacts() error { // 防止后台切换策略后把一笔已扣费命令从 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 } diff --git a/services/room-service/internal/room/service/gift_lucky.go b/services/room-service/internal/room/service/gift_lucky.go index e55f64cc..a61a0ffb 100644 --- a/services/room-service/internal/room/service/gift_lucky.go +++ b/services/room-service/internal/room/service/gift_lucky.go @@ -100,7 +100,7 @@ func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd c UserId: cmd.ActorUserID(), TargetUserId: targetBilling.TargetUserID, // 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, RoomId: cmd.RoomID(), AnchorId: luckyGiftAnchorID(roomMeta),