diff --git a/README.md b/README.md index e0aa3c94..59d27669 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连 核心边界: - `gateway-service` 是客户端 HTTP JSON 入口,负责鉴权、协议转换、签发腾讯云 IM UserSig,以及调用内部 gRPC 服务。 -- `room-service` 是房间业务状态 owner,负责 Room Cell、麦位、房间 presence、本地礼物榜、送礼落房间、语音房宝箱、snapshot、command log、outbox、Redis lease、RocketMQ room outbox relay,以及腾讯云 IM 群组/系统消息桥接。 +- `room-service` 是房间业务状态 owner,负责 Room Cell、麦位、房间 presence、本地礼物榜、送礼落房间、语音房火箭、snapshot、command log、outbox、Redis lease、RocketMQ room outbox relay,以及腾讯云 IM 群组/系统消息桥接。 - `wallet-service` 当前提供 `DebitGift` 送礼扣费语义,余额、流水和幂等记录落 MySQL;`room-service` 的 `SendGift` 同步调用它。 - `user-service` 当前承接登录、三方注册、密码、refresh session、用户主数据、默认短号和临时靓号基础能力。 - `activity-service` 当前承接每日任务、活动/系统消息基础能力和房间事件 consumer;可通过 room outbox direct gRPC 或 RocketMQ `hyapp_room_outbox` 独立 consumer group 消费房间事实。 @@ -143,7 +143,7 @@ Authorization: Bearer - `gateway-service` 创建房间时从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id`;`room-service` 由该 actor 收敛 owner,外部 CreateRoom 请求体不接收 `owner_user_id`。 - `X-Request-ID`/`request_id` 由后端生成,只做链路追踪;写操作的 `command_id` 由前端按用户动作生成,用于服务端幂等。 - `room-service` 在 `CreateRoom` 成功后写 `room_outbox`,由 outbox worker 异步创建或补偿同名腾讯云 IM 群组。 -- `room-service` 在上下麦、禁言、踢人、送礼、宝箱进度和开箱等命令提交后只写房间状态、command log 和 outbox;腾讯云 IM 房间系统自定义消息由 outbox worker direct 投递,或由 IM bridge consumer 从 RocketMQ 消费后投递。 +- `room-service` 在上下麦、禁言、踢人、送礼、火箭进度和发射等命令提交后只写房间状态、command log 和 outbox;腾讯云 IM 房间系统自定义消息由 outbox worker direct 投递,或由 IM bridge consumer 从 RocketMQ 消费后投递。 - `notice-service` 消费钱包余额变更 outbox 和房间踢人 outbox,向单个用户发送 `wallet_notice`、`room_notice` 私有自定义消息;owner service 不直接调用腾讯云 IM 单聊。 - `gateway-service` 已提供 `/api/v1/tencent-im/callback`,支持腾讯云 IM 入群前和发言前回调,并分别回查 `VerifyRoomPresence` 和 `CheckSpeakPermission`。 @@ -183,7 +183,7 @@ docs/flutter对接/Flutter App 通知与钱包余额对接.md | Topic | Producer | Consumer Group | 用途 | | --- | --- | --- | --- | -| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-activity-room-outbox` | activity-service 消费 `RoomGiftSent`、`RoomTreasureCountdownStarted` 等房间事实 | +| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-activity-room-outbox` | activity-service 消费 `RoomGiftSent`、`RoomRocketIgnited` 等房间事实 | | `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-notice-room-outbox` | notice-service 消费 `RoomUserKicked` 等私有通知事实 | | `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-room-im-bridge` | room-service IM bridge 消费房间群系统消息和群成员控制事实 | | `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-user-mictime-room-outbox` | user-service 消费 `RoomMicChanged` 并更新用户麦时读模型 | @@ -195,9 +195,9 @@ docs/flutter对接/Flutter App 通知与钱包余额对接.md | `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-notice-wallet-outbox` | notice-service 消费余额、账务等私有通知事实 | | `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-statistics-wallet-outbox` | statistics-service 消费 `WalletRechargeRecorded` 并更新充值聚合 | | `hyapp_game_outbox` | `hyapp-game-outbox-producer` | `hyapp-statistics-game-outbox` | statistics-service 消费 `GameOrderSettled` 并更新游戏聚合 | -| `hyapp_room_treasure_open` | `hyapp-room-treasure-open-producer` | `hyapp-room-treasure-open` | 宝箱倒计时到点唤醒 room-service 开箱命令 | +| `hyapp_room_rocket_launch` | `hyapp-room-rocket-launch-producer` | `hyapp-room-rocket-launch` | 火箭点火后到点唤醒 room-service 发射命令 | -RocketMQ 不拥有业务状态。`room_outbox`、`wallet_outbox`、`user_outbox`、`game_outbox` 仍是各 owner service MySQL 内的可靠事实源;MQ 发布失败时 outbox worker 退避重试,到达重试上限后进入死信。宝箱延迟消息只负责唤醒,到点后 room-service 会重新校验 `box_id`、等级、`open_at_ms`、状态和 UTC 重置边界。 +RocketMQ 不拥有业务状态。`room_outbox`、`wallet_outbox`、`user_outbox`、`game_outbox` 仍是各 owner service MySQL 内的可靠事实源;MQ 发布失败时 outbox worker 退避重试,到达重试上限后进入死信。火箭延迟消息只负责唤醒,到点后 room-service 会重新校验 `rocket_id`、等级、`launch_at_ms`、状态和 UTC 重置边界。 ## Storage Model diff --git a/api/proto/events/room/v1/events.pb.go b/api/proto/events/room/v1/events.pb.go index 02adbad7..d5b88b7a 100644 --- a/api/proto/events/room/v1/events.pb.go +++ b/api/proto/events/room/v1/events.pb.go @@ -1651,6 +1651,7 @@ type RoomRocketIgnited struct { CommandId string `protobuf:"bytes,11,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` RoomShortId string `protobuf:"bytes,12,opt,name=room_short_id,json=roomShortId,proto3" json:"room_short_id,omitempty"` RocketCoverUrl string `protobuf:"bytes,13,opt,name=rocket_cover_url,json=rocketCoverUrl,proto3" json:"rocket_cover_url,omitempty"` + ResetAtMs int64 `protobuf:"varint,14,opt,name=reset_at_ms,json=resetAtMs,proto3" json:"reset_at_ms,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1776,6 +1777,13 @@ func (x *RoomRocketIgnited) GetRocketCoverUrl() string { return "" } +func (x *RoomRocketIgnited) GetResetAtMs() int64 { + if x != nil { + return x.ResetAtMs + } + return 0 +} + // RoomRocketLaunched 表达火箭已经发射,在线用户名单按发射瞬间 Room Cell presence 结算。 type RoomRocketLaunched struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2094,7 +2102,7 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" + "gift_count\x18\f \x01(\x05R\tgiftCount\x12\x1d\n" + "\n" + "command_id\x18\r \x01(\tR\tcommandId\x12*\n" + - "\x11visible_region_id\x18\x0e \x01(\x03R\x0fvisibleRegionId\"\xe2\x03\n" + + "\x11visible_region_id\x18\x0e \x01(\x03R\x0fvisibleRegionId\"\x82\x04\n" + "\x11RoomRocketIgnited\x12\x1b\n" + "\trocket_id\x18\x01 \x01(\tR\brocketId\x12\x14\n" + "\x05level\x18\x02 \x01(\x05R\x05level\x12!\n" + @@ -2112,7 +2120,8 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" + "\n" + "command_id\x18\v \x01(\tR\tcommandId\x12\"\n" + "\rroom_short_id\x18\f \x01(\tR\vroomShortId\x12(\n" + - "\x10rocket_cover_url\x18\r \x01(\tR\x0erocketCoverUrl\"\xe6\x02\n" + + "\x10rocket_cover_url\x18\r \x01(\tR\x0erocketCoverUrl\x12\x1e\n" + + "\vreset_at_ms\x18\x0e \x01(\x03R\tresetAtMs\"\xe6\x02\n" + "\x12RoomRocketLaunched\x12\x1b\n" + "\trocket_id\x18\x01 \x01(\tR\brocketId\x12\x14\n" + "\x05level\x18\x02 \x01(\x05R\x05level\x12\x1d\n" + diff --git a/api/proto/room/v1/room.pb.go b/api/proto/room/v1/room.pb.go index 488289fe..86dccba6 100644 --- a/api/proto/room/v1/room.pb.go +++ b/api/proto/room/v1/room.pb.go @@ -935,7 +935,7 @@ func (x *RoomRocketLevel) GetIgniterRewards() []*RoomRocketRewardItem { return nil } -// RoomRocketRewardGrant 是一次火箭打开后给某个用户结算出的具体奖励。 +// RoomRocketRewardGrant 是一次火箭发射后给某个用户结算出的具体奖励。 type RoomRocketRewardGrant struct { state protoimpl.MessageState `protogen:"open.v1"` RewardRole string `protobuf:"bytes,1,opt,name=reward_role,json=rewardRole,proto3" json:"reward_role,omitempty"` diff --git a/api/proto/room/v1/room.proto b/api/proto/room/v1/room.proto index 829764f2..997b7013 100644 --- a/api/proto/room/v1/room.proto +++ b/api/proto/room/v1/room.proto @@ -124,7 +124,7 @@ message RoomRocketLevel { repeated RoomRocketRewardItem igniter_rewards = 9; } -// RoomRocketRewardGrant 是一次火箭打开后给某个用户结算出的具体奖励。 +// RoomRocketRewardGrant 是一次火箭发射后给某个用户结算出的具体奖励。 message RoomRocketRewardGrant { string reward_role = 1; int64 user_id = 2; diff --git a/docs/IM全局与区域广播架构.md b/docs/IM全局与区域广播架构.md index 6505b9d9..2bcedf12 100644 --- a/docs/IM全局与区域广播架构.md +++ b/docs/IM全局与区域广播架构.md @@ -38,7 +38,7 @@ docs/房间Outbox补偿开发.md | 播报发送能力 | `activity-service` | 服务端向全局/区域播报群发送自定义消息 | | 旧区域群成员移除 | `activity-service` | 用户区域变化后,对旧区域群执行 `delete_group_member(user_id)` | | 贵重礼物播报触发 | `activity-service` consuming room outbox | 基于 `RoomGiftSent` 事实判断阈值并发区域播报 | -| 火箭倒计时播报触发 | `activity-service` consuming room outbox | 基于 `RoomRocketCountdownStarted` 事实按后台配置发区域或全局播报 | +| 火箭倒计时播报触发 | `activity-service` consuming room outbox | 基于 `RoomRocketIgnited` 事实按后台配置发区域或全局播报 | 本阶段不做: @@ -107,7 +107,7 @@ func Parse(groupID string) ParsedGroup | 红包资金事实 | `wallet-service` 或独立 red-packet domain | IM 只携带入口和展示字段 | | 客户端 IM 长连接 | Tencent IM SDK | 本仓库不自建 WebSocket | -`room-service` 只需要继续产出 `RoomGiftSent`、`RoomRocketCountdownStarted` 等 outbox 事实。贵重礼物和火箭倒计时是否需要区域/全局播报,由 `activity-service` 通过 direct gRPC 或 RocketMQ `hyapp_room_outbox` consumer group 消费后判断,这样不会把区域播报策略塞进 Room Cell 主链路。 +`room-service` 只需要继续产出 `RoomGiftSent`、`RoomRocketIgnited` 等 outbox 事实。贵重礼物和火箭倒计时是否需要区域/全局播报,由 `activity-service` 通过 direct gRPC 或 RocketMQ `hyapp_room_outbox` consumer group 消费后判断,这样不会把区域播报策略塞进 Room Cell 主链路。 ## Client Flow @@ -406,7 +406,7 @@ sequenceDiagram ## Room Rocket Broadcast -火箭燃料满进入倒计时时,Room Cell 写 `RoomRocketCountdownStarted` outbox。activity-service 消费该事实后按后台配置决定发区域播报还是全局播报;发射到点、奖励结算和火箭状态推进仍由 room-service 负责。 +火箭燃料满进入倒计时时,Room Cell 写 `RoomRocketIgnited` outbox。activity-service 消费该事实后按后台配置决定发区域播报还是全局播报;发射到点、奖励结算和火箭状态推进仍由 room-service 负责。 ```mermaid sequenceDiagram @@ -421,11 +421,12 @@ sequenceDiagram C->>G: POST /api/v1/rooms/gift/send G->>R: SendGift R->>W: DebitGift - W-->>R: receipt + heat/gift point - R->>R: add effective rocket energy - R->>R: status=countdown when threshold reached + W-->>R: receipt + heat_value + R->>R: add effective rocket fuel + R->>R: append pending_launch when threshold reached + R->>R: start next level immediately R-->>G: SendGiftResponse - R->>MQ: RoomRocketCountdownStarted + R->>MQ: RoomRocketIgnited MQ->>A: consume by hyapp-activity-room-outbox A->>IM: send_group_msg(region/global broadcast group) ``` @@ -434,15 +435,22 @@ sequenceDiagram ```json { - "event_id": "room_rocket:lalu_room_xxx:box_1", + "event_id": "room_rocket:lalu_room_xxx:rocket_1", "broadcast_type": "room_rocket", "scope": "region", "app_code": "lalu", "region_id": 1001, "room_id": "lalu_room_xxx", - "box_id": "box_1", - "level": 3, - "open_at_ms": 1770000000000, + "rocket_id": "rocket_1", + "rocket_level": 3, + "rocket_cover_url": "https://cdn.example/rocket-l3.png", + "launch_at_ms": 1770000000000, + "igniter": { + "user_id": "10001", + "short_id": "80001", + "nickname": "Alice", + "avatar": "https://cdn.example/avatar.png" + }, "action": { "type": "enter_room", "room_id": "lalu_room_xxx" @@ -450,7 +458,7 @@ sequenceDiagram } ``` -倒计时期间继续送礼不会累加到下一火箭,溢出燃料也不会跨级;这些规则由 room-service 在 `RoomRocketProgressChanged` 和 `RoomRocketCountdownStarted` 事实中表达,activity-service 不能自行推导火箭进度。 +当前等级满阈值后,room-service 立刻把该火箭加入 `pending_launches`,并把下一等级作为当前进度继续累计;单次送礼溢出的燃料作废,不顺延。activity-service 只按 `RoomRocketIgnited` 做飘屏,不自行推导火箭进度。 ## Red Packet Broadcast @@ -464,7 +472,7 @@ sequenceDiagram | 过期退款 | wallet/red-packet domain + cron | | IM 播报 | activity-service broadcast | -红包创建成功后,红包领域写 `RedPacketCreated` 事件,activity-service 转成区域/全局播报。客户端点击播报只打开红包入口,领取时必须调用后端接口,不能信任 IM payload 中的金额。 +红包创建成功后,红包领域写 `RedPacketCreated` 事件,activity-service 转成区域/全局播报。客户端点击播报只发射红包入口,领取时必须调用后端接口,不能信任 IM payload 中的金额。 ## App Code And Region Safety @@ -496,7 +504,7 @@ sequenceDiagram 5. `activity-service` 增加 broadcast group reconciler,启动时确保全局和 active 区域群。 6. `activity-service` 增加 `PublishRegionBroadcast/PublishGlobalBroadcast` 和 broadcast outbox。 7. 扩展 `RoomGiftSent` 事件字段,activity-service 消费后按阈值发 `super_gift` 区域播报。 -8. 消费 `RoomRocketCountdownStarted`,按后台配置发 `room_rocket` 区域或全局播报。 +8. 消费 `RoomRocketIgnited`,按后台配置发 `room_rocket` 区域或全局播报。 9. 红包领域落账务事实后,复用同一播报能力发送 `red_packet` 消息。 ## Test Expectations @@ -513,5 +521,5 @@ sequenceDiagram | group reconciler | 已存在群视为成功,失败可重试 | | broadcast outbox | 同一 `event_id` 幂等,失败后 retry | | super gift | 未达阈值不播报,达阈值发送到用户房间所在区域群 | -| room rocket | 火箭燃料满后只按 `RoomRocketCountdownStarted` 播报一次;区域/全局 scope 来自后台配置 | +| room rocket | 火箭燃料满后只按 `RoomRocketIgnited` 播报一次;区域/全局 scope 来自后台配置 | | red packet | IM payload 不影响领取接口的后端金额校验 | diff --git a/docs/flutter对接/幸运礼物Flutter对接.md b/docs/flutter对接/幸运礼物Flutter对接.md index d405ca71..b4c47373 100644 --- a/docs/flutter对接/幸运礼物Flutter对接.md +++ b/docs/flutter对接/幸运礼物Flutter对接.md @@ -115,7 +115,7 @@ Content-Type: application/json | `room_heat` | int64 | 送礼后的房间热度。 | | `gift_rank` | array | 当前房间礼物榜。 | | `room` | object | 房间快照。 | -| `treasure` | object | 房间宝箱状态。 | +| `rocket` | object | 房间火箭状态。 | | `lucky_gift` | object | 幸运礼物抽奖结果;普通礼物可为空。 | `lucky_gift` 返回字段: @@ -159,7 +159,7 @@ Content-Type: application/json "room_heat": 123456, "gift_rank": [], "room": {}, - "treasure": {}, + "rocket": {}, "lucky_gift": { "enabled": true, "draw_id": "lucky_draw_test", @@ -328,4 +328,3 @@ Content-Type: application/json | `reward_status=pending` / `granting` | 到账处理中;等待钱包通知、刷新余额或后端补偿。 | | `reward_status=failed` | 展示异常状态,记录 `request_id/draw_id/command_id` 方便排查。 | | HTTP 超时 | 保留原 `command_id` 重试,不生成新命令。 | - diff --git a/docs/flutter对接/礼物TabFlutter对接.md b/docs/flutter对接/礼物TabFlutter对接.md index 0b874510..8bb6c2f6 100644 --- a/docs/flutter对接/礼物TabFlutter对接.md +++ b/docs/flutter对接/礼物TabFlutter对接.md @@ -20,11 +20,11 @@ 1. 用户进入房间并完成 `JoinRoom`。 2. App 可在启动或进入房间前调用 `GET /api/v1/gift-tabs` 预加载 Tab 配置。 -3. 打开礼物 Tab 时调用 `GET /api/v1/rooms/{room_id}/gift-panel`。 +3. 发射礼物 Tab 时调用 `GET /api/v1/rooms/{room_id}/gift-panel`。 4. 用 `tabs` 渲染当前房间分类,用 `gifts` 渲染礼物网格,用 `recipients` 渲染收礼人。 5. 用户选择收礼人、礼物和数量。 6. 点击发送时生成稳定 `command_id`,调用 `POST /api/v1/rooms/gift/send`。 -7. 发送成功后用响应里的 `room_heat`、`gift_rank`、`treasure` 更新房间 UI;如果有 `lucky_gift` 且已入账,用 `coin_balance_after` 立即刷新金币余额。 +7. 发送成功后用响应里的 `room_heat`、`gift_rank`、`rocket` 更新房间 UI;如果有 `lucky_gift` 且已入账,用 `coin_balance_after` 立即刷新金币余额。 8. 监听房间 IM `room_gift_sent`,同步播放礼物动效。 9. 普通礼物和未中奖余额仍监听私有钱包通知或主动刷新余额,不本地扣减余额作为最终值。 @@ -202,7 +202,7 @@ X-App-Code: lalu | 字段 | 说明 | | --- | --- | -| `coin_balance` | 当前登录用户 COIN 可用余额快照。只用于打开面板时展示,最终余额仍以后续钱包事实为准。 | +| `coin_balance` | 当前登录用户 COIN 可用余额快照。只用于发射面板时展示,最终余额仍以后续钱包事实为准。 | | `recipients` | 当前房间可收礼人。当前主流程只启用 `target_type=user` 的单个用户。 | | `tabs` | 礼物分类。`key=all` 是全量分类;其他 tab 通常对应 `gift_type_code`。 | | `gifts` | 当前房间国家维度可用的 active 礼物列表。 | @@ -295,7 +295,7 @@ Content-Type: application/json } ], "room": {}, - "treasure": {}, + "rocket": {}, "lucky_gift": { "enabled": true, "draw_id": "lucky_draw_test", @@ -329,7 +329,7 @@ Content-Type: application/json | `room_heat` | 最新房间热度。 | | `gift_rank` | 最新房间礼物榜。 | | `room` | 房间快照,字段较大;App 可按已有房间快照解析逻辑局部更新。 | -| `treasure` | 房间宝箱状态;宝箱 UI 以该字段或宝箱 IM 事件更新。 | +| `rocket` | 房间火箭状态;火箭 UI 以该字段或火箭 IM 事件更新。 | | `lucky_gift` | 幸运礼物抽奖结果;普通礼物可为空。`reward_status=granted` 且中奖时,`coin_balance_after` 是当前用户返奖入账后的余额。 | 发送后处理: diff --git a/docs/flutter对接/语音房火箭Flutter对接.md b/docs/flutter对接/语音房火箭Flutter对接.md index 5d3f10fa..a211f3e4 100644 --- a/docs/flutter对接/语音房火箭Flutter对接.md +++ b/docs/flutter对接/语音房火箭Flutter对接.md @@ -1,611 +1,277 @@ -# 语音房火箭 Flutter 对接 +# 语音房火箭接口对接 -本文描述 Flutter App 对接语音房火箭所需的 HTTP 接口、请求参数、返回值、错误处理和腾讯云 IM 自定义消息 key。火箭进度、发射和发奖事实由 `room-service` Room Cell 持有;客户端只展示服务端返回或 IM 推送的状态,不能本地计算燃料、奖励或发射结果。 +## 规则 -## 基础约定 +- 火箭最高 5 级。 +- 房间送礼成功后,按礼物 `heat_value` 增加当前等级火箭燃料。 +- 当前等级满后立刻点火,并把该等级火箭放入 `pending_launches` 等待发射。 +- 点火后下一等级马上开始累计燃料。 +- 单次送礼超过当前等级阈值时,超过部分直接作废,不顺延到下一等级。 +- 发射延迟由后台 `launch_delay_ms` 配置。 +- 发射结算三类奖励:点火人、贡献第一、在房用户。在房用户按发射瞬间房间 presence 抽奖。 -本地开发地址: +## 获取火箭 -```text -http://127.0.0.1:13000 -``` - -业务接口统一响应 envelope: - -```json -{ - "code": "OK", - "message": "ok", - "request_id": "req_xxx", - "data": {} -} -``` - -| 字段 | 说明 | -| --- | --- | -| `code` | 成功固定 `OK`;失败为稳定错误码。 | -| `message` | 短错误文案,不用于客户端分支判断。 | -| `request_id` | 服务端链路追踪 ID;客户端日志要记录。 | -| `data` | 成功业务数据;失败时通常不存在。 | - -通用请求头: - -| Header | 必填 | 说明 | -| --- | --- | --- | -| `Authorization` | 是 | `Bearer `。 | -| `X-App-Code` | 否 | App 编码,默认 `lalu`;登录态接口最终以 token 内 app_code 为准。 | -| `Content-Type` | POST 必填 | `application/json`。 | - -所有 `*_ms` 都是 Unix epoch milliseconds。业务切日按 UTC,不按用户本地时区。 - -## 客户端流程 - -1. 用户成功 `JoinRoom` 后调用 `GET /api/v1/rooms/{room_id}/rocket` 初始化 7 级物料和当前进度。 -2. 用户送礼调用 `POST /api/v1/rooms/gift/send`;成功响应里的 `data.rocket` 可立即更新当前火箭状态。 -3. 房间内监听腾讯 IM 群 `TIMCustomElem.Ext=room_system_message`,处理火箭进度、倒计时、发射和发奖。 -4. App 全局或区域播报监听 `TIMCustomElem.Ext=im_broadcast` 且 `broadcast_type=room_rocket`。 -5. 任意 IM 丢失、字段解析失败、版本倒退或页面重进时,重新调用 `GET /api/v1/rooms/{room_id}/rocket` 纠正本地状态。 - -客户端必须用 `event_id` 去重,用 `room_version` 丢弃旧房间消息。 - -## 获取火箭信息 - -```http -GET /api/v1/rooms/{room_id}/rocket -Authorization: Bearer -X-App-Code: lalu -``` - -路径参数: - -| 参数 | 类型 | 必填 | 说明 | -| --- | --- | --- | --- | -| `room_id` | string | 是 | 当前语音房 ID,例如 `lalu_534d076a-7cbd-4b23-a297-0c10a3c2fa72`。 | - -成功响应: - -```json -{ - "code": "OK", - "message": "ok", - "request_id": "req_xxx", - "data": { - "enabled": true, - "levels": [ - { - "level": 1, - "energy_threshold": 1000, - "cover_url": "https://cdn.example/chest/lv1/cover.png", - "animation_url": "https://cdn.example/chest/lv1/idle.svga", - "opening_animation_url": "https://cdn.example/chest/lv1/opening.svga", - "opened_image_url": "https://cdn.example/chest/lv1/opened.png", - "in_room_rewards": [ - { - "reward_item_id": "lv1_in_room_1", - "resource_group_id": 1001, - "weight": 10000, - "display_name": "Gold Pack", - "icon_url": "https://cdn.example/reward/gold.png" - } - ], - "top1_rewards": [], - "igniter_rewards": [] - } - ], - "state": { - "current_level": 1, - "current_progress": 0, - "energy_threshold": 1000, - "status": "idle", - "reset_at_ms": 1779321600000, - "top1_user_id": "", - "igniter_user_id": "", - "box_id": "", - "config_version": 3, - "last_rewards": [] - }, - "broadcast_scope": "region", - "open_delay_ms": 30000, - "broadcast_delay_ms": 0, - "reward_stack_policy": "allow_stack", - "server_time_ms": 1779259000000 - } -} -``` - -`data` 字段: - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `enabled` | bool | 火箭总开关。`false` 时客户端隐藏或置灰火箭入口。 | -| `levels` | array | 固定 7 个等级的物料、阈值和奖励池。 | -| `state` | object | 当前房间当前 UTC 日的火箭状态。 | -| `broadcast_scope` | string | `none`、`region`、`global`。 | -| `open_delay_ms` | int64 | 燃料满后到发射的倒计时配置。 | -| `broadcast_delay_ms` | int64 | 后台配置字段;当前房间事件会立即进入播报链路。 | -| `reward_stack_policy` | string | `allow_stack` 或 `priority_only`。 | -| `server_time_ms` | int64 | 服务端当前时间,倒计时要以它校准本地时钟偏差。 | - -`levels[]` 字段: - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `level` | int32 | 等级,1 到 7。 | -| `energy_threshold` | int64 | 当前等级燃料满阈值。 | -| `cover_url` | string | 静态封面图。 | -| `animation_url` | string | 未发射/蓄能态动效。 | -| `opening_animation_url` | string | 发射动效。 | -| `opened_image_url` | string | 发射后展示图。 | -| `in_room_rewards` | array | 发射时仍在房用户的奖励候选展示。 | -| `top1_rewards` | array | 倒计时开始时贡献第一用户奖励候选展示。 | -| `igniter_rewards` | array | 点火人奖励候选展示。 | - -奖励候选字段: - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `reward_item_id` | string | 后台配置的奖励项 ID。 | -| `resource_group_id` | int64 | 钱包资源组 ID。 | -| `weight` | int64 | 抽奖权重,只用于展示或调试,不参与客户端抽奖。 | -| `display_name` | string | 奖励展示名称。 | -| `icon_url` | string | 奖励图标。 | - -`state` 字段: - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `current_level` | int32 | 当前等级,1 到 7。 | -| `current_progress` | int64 | 当前等级已累计有效燃料。 | -| `energy_threshold` | int64 | 当前等级阈值。 | -| `status` | string | 当前返回 `idle` 或 `countdown`。 | -| `countdown_started_at_ms` | int64 | 倒计时开始时间;非倒计时可为空或 0。 | -| `open_at_ms` | int64 | 预计发射时间;`status=countdown` 时有效。 | -| `opened_at_ms` | int64 | 最近一次发射时间。 | -| `reset_at_ms` | int64 | 下一个 UTC 0 点;到点后进度按新 UTC 日重置。 | -| `top1_user_id` | string | 倒计时开始时锁定的贡献第一;GET 接口中按字符串返回。 | -| `igniter_user_id` | string | 点火人;GET 接口中按字符串返回。 | -| `box_id` | string | 当前倒计时火箭或最近状态的单轮幂等 ID。 | -| `config_version` | int64 | 当前状态关联的后台配置版本。 | -| `last_rewards` | array | 最近一次发射发奖结果;客户端可用于展示发射结果兜底。 | - -`last_rewards[]` 字段: - -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `reward_role` | string | `in_room`、`top1`、`igniter`。 | -| `user_id` | string | 获奖用户 ID 字符串。 | -| `reward_item_id` | string | 奖励项 ID。 | -| `resource_group_id` | int64 | 资源组 ID。 | -| `display_name` | string | 奖励展示名称。 | -| `icon_url` | string | 奖励图标。 | -| `grant_id` | string | 钱包发放记录 ID。 | -| `status` | string | 当前成功发放为 `succeeded`。 | - -展示规则: - -- 进度条使用 `current_progress / energy_threshold`,并且最大显示 100%。 -- 倒计时使用 `open_at_ms - server_time_ms` 初始化,再用本地 timer 驱动。 -- 不要把送礼溢出燃料展示到下一等级;服务端规则是溢出作废。 -- 如果 `status=countdown`,继续送礼不会改变火箭进度、点火人、贡献第一或 `open_at_ms`。 - -## 送礼并更新火箭 - -```http -POST /api/v1/rooms/gift/send -Authorization: Bearer -X-App-Code: lalu -Content-Type: application/json -``` - -请求体: - -```json -{ - "room_id": "lalu_xxx", - "command_id": "cmd_send_gift_lalu_xxx_12345678_1779259000000", - "target_type": "user", - "target_user_ids": [12345678], - "gift_id": "rocket", - "gift_count": 1 -} -``` +地址:`GET /api/v1/rooms/{room_id}/rocket` 参数: -| 字段 | 类型 | 必填 | 说明 | +| 参数 | 位置 | 必填 | 说明 | | --- | --- | --- | --- | -| `room_id` | string | 是 | 当前房间 ID。 | -| `command_id` | string | 是 | 本次送礼动作幂等键;同一次 HTTP 重试必须复用。 | -| `target_type` | string | 否 | 当前只支持 `user`;为空按 `user` 处理。 | -| `target_user_ids` | int64[] | 是 | 收礼用户列表;`target_type=user` 时必须且只能 1 个。 | -| `target_user_id` | int64 | 否 | 旧客户端兼容字段;新客户端优先用 `target_user_ids`。 | -| `gift_id` | string | 是 | 礼物 ID。 | -| `gift_count` | int32 | 是 | 礼物数量,必须大于 0。 | +| `room_id` | path | 是 | 房间 ID | -成功响应核心字段: +返回值: -```json -{ - "code": "OK", - "message": "ok", - "request_id": "req_xxx", - "data": { - "result": { - "applied": true, - "room_version": 7, - "server_time_ms": 1779259502200 - }, - "billing_receipt_id": "bill_xxx", - "room_heat": 500, - "gift_rank": [ - { - "user_id": 12345678, - "score": 500, - "gift_value": 500, - "updated_at_ms": 1779259502104 - } - ], - "room": {}, - "rocket": { - "current_level": 2, - "current_progress": 200, - "energy_threshold": 200, - "status": "countdown", - "countdown_started_at_ms": 1779259502104, - "open_at_ms": 1779259512104, - "reset_at_ms": 1779321600000, - "top1_user_id": 12345678, - "igniter_user_id": 12345678, - "box_id": "room_rocket_box_xxx", - "config_version": 3 - } - } -} -``` +| 字段 | 说明 | +| --- | --- | +| `enabled` | 火箭是否开启 | +| `levels` | 1 到 5 级配置 | +| `levels[].fuel_threshold` | 等级燃料阈值 | +| `levels[].cover_url` | 火箭小封面图 | +| `levels[].animation_url` | 常态动效 | +| `levels[].launch_animation_url` | 发射动效 | +| `levels[].launched_image_url` | 发射后图 | +| `levels[].in_room_rewards` | 在房奖励候选 | +| `levels[].top1_rewards` | 贡献第一奖励候选 | +| `levels[].igniter_rewards` | 点火人奖励候选 | +| `state.current_level` | 当前正在累计的等级 | +| `state.current_fuel` | 当前等级已累计燃料 | +| `state.fuel_threshold` | 当前等级阈值 | +| `state.status` | `charging` 或 `completed` | +| `state.rocket_id` | 当前正在累计火箭 ID | +| `state.pending_launches` | 已点火待发射火箭列表 | +| `state.pending_launches[].rocket_id` | 待发射火箭 ID | +| `state.pending_launches[].level` | 待发射等级 | +| `state.pending_launches[].ignited_at_ms` | 点火 UTC 毫秒 | +| `state.pending_launches[].launch_at_ms` | 预计发射 UTC 毫秒 | +| `state.pending_launches[].top1_user_id` | 点火时锁定贡献第一 | +| `state.pending_launches[].igniter_user_id` | 点火人 | +| `state.pending_launches[].cover_url` | 小封面图 | +| `state.last_rewards` | 最近一次发射奖励结果 | +| `server_time_ms` | 服务端 UTC 毫秒 | -字段说明: +## 送礼 -| 字段 | 类型 | 说明 | +地址:`POST /api/v1/rooms/gift/send` + +参数: + +| 参数 | 必填 | 说明 | | --- | --- | --- | -| `data.result.applied` | bool | 命令是否产生新状态;同一 `command_id` 重试可能返回历史结果。 | -| `data.result.room_version` | int64 | 房间版本,用于和 IM 消息去重、排序。 | -| `data.billing_receipt_id` | string | 钱包扣费回执。 | -| `data.room_heat` | int64 | 送礼后的房间热度。 | -| `data.gift_rank` | array | 送礼后的本地贡献榜快照。 | -| `data.room` | object | 房间快照。 | -| `data.rocket` | object | 送礼后火箭状态;送礼人可立即用它刷新 UI。 | +| `room_id` | 是 | 房间 ID | +| `command_id` | 是 | 客户端本次送礼幂等 ID | +| `target_type` | 否 | 默认 `user` | +| `target_user_id` | 否 | 单收礼用户 ID | +| `target_user_ids` | 否 | 多收礼用户 ID | +| `gift_id` | 是 | 礼物 ID | +| `gift_count` | 是 | 礼物数量 | +| `pool_id` | 否 | 幸运礼物奖池 ID | -注意:`GET /rocket` 的用户 ID 字段按字符串返回,`POST /gift/send` 透出的 protobuf 状态里部分用户 ID 可能是数字。Flutter 侧建议用统一 helper 兼容 `String` 和 `num`。 +返回值: -```dart -String readId(dynamic value) { - if (value == null) return ''; - if (value is String) return value; - if (value is num) return value.toInt().toString(); - return ''; -} -``` - -幂等规则: - -| 场景 | 处理 | +| 字段 | 说明 | | --- | --- | -| HTTP 超时后重试同一次送礼 | 复用原 `command_id`。 | -| 用户点了第二次送礼按钮 | 生成新的 `command_id`。 | -| 同一个 `command_id` 换了礼物、数量或目标 | 服务端返回冲突错误。 | +| `result` | 命令提交结果 | +| `billing_receipt_id` | 钱包扣费回执 | +| `room_heat` | 最新房间热度 | +| `gift_rank` | 最新房间礼物榜 | +| `rocket` | 送礼后的火箭状态,字段同 `state` | +| `lucky_gift` | 本次送礼聚合幸运礼物结果 | +| `lucky_gifts` | 多目标送礼时每个目标的幸运礼物结果 | -## 错误处理 +## 后台配置 -失败响应: +地址:`GET /v1/admin/activity/room-rocket/config` -```json -{ - "code": "PERMISSION_DENIED", - "message": "permission denied", - "request_id": "req_xxx" -} -``` +参数:无 -通用错误: +返回值: -| HTTP | `code` | 场景 | Flutter 处理 | -| --- | --- | --- | --- | -| 400 | `INVALID_JSON` | 请求体不是合法 JSON。 | 修正客户端请求,不重试。 | -| 400 | `INVALID_ARGUMENT` | 缺字段、数量非法、room_id 格式非法、暂不支持的 `target_type`。 | 修正参数,不自动重试。 | -| 401 | `UNAUTHORIZED` | token 缺失、过期或无效。 | 走登录刷新或重新登录。 | -| 403 | `PROFILE_REQUIRED` | 用户资料未完成。 | 跳转资料补全流程。 | -| 403 | `PERMISSION_DENIED` | 不在房间、被 ban、无权限读取或操作。 | 退出房间页或刷新房间状态。 | -| 404 | `NOT_FOUND` | 房间不存在、送礼人不在房、收礼人不在房、礼物不可用。 | 刷新房间状态;礼物不可用时刷新礼物面板。 | -| 409 | `ROOM_CLOSED` | 房间已关闭。 | 退出房间页并刷新列表。 | -| 409 | `CONFLICT` | 状态或唯一约束冲突。 | 刷新后重试。 | -| 409 | `IDEMPOTENCY_CONFLICT` | `command_id` 复用但请求内容不同。 | 新用户动作生成新 `command_id`;重试旧动作必须保持原请求体。 | -| 409 | `INSUFFICIENT_BALANCE` | 钱包余额不足。 | 弹充值/余额不足提示。 | -| 409 | `LEDGER_CONFLICT` | 钱包并发账本冲突。 | 可短延迟重试一次,仍失败则刷新余额。 | -| 502 | `UPSTREAM_ERROR` | room/wallet 等依赖暂不可用。 | 提示稍后重试,可保留原 `command_id` 重试同一次送礼。 | -| 500 | `INTERNAL_ERROR` | 服务端内部错误。 | 提示失败并记录 `request_id`。 | - -火箭相关额外规则: - -- 获取火箭信息要求当前用户仍在房间业务 presence 内;离房后调用会返回 `PERMISSION_DENIED` 或 `NOT_FOUND`。 -- 火箭关闭时,`GET /rocket` 仍可能返回 `enabled=false` 和 7 级配置;客户端不展示进度入口即可。 -- 发奖自动完成,没有 App 领取接口;客户端不要补发或重复调用钱包接口。 - -## 房间群 IM 消息 - -房间内火箭进度、倒计时、发射和发奖通过腾讯云 IM 房间群 `TIMCustomElem` 投递。 - -| 字段 | 值 | +| 字段 | 说明 | | --- | --- | -| `MsgType` | `TIMCustomElem` | -| `MsgContent.Ext` | `room_system_message` | -| `MsgContent.Desc` | 等于 `Data.event_type` | -| `MsgContent.Data` | JSON 字符串 | -| `CloudCustomData` | 与 `MsgContent.Data` 相同的 JSON 字符串 | +| `enabled` | 是否开启 | +| `fuelSource` | 固定为 `heat_value` | +| `launchDelayMs` | 点火后延迟发射毫秒 | +| `broadcastEnabled` | 是否发区域或全局飘屏 | +| `broadcastScope` | `region`、`global`、`none` | +| `rewardStackPolicy` | `allow_stack` 或 `priority_only` | +| `levels` | 5 级配置 | +| `giftFuelRules` | 礼物燃料规则 | -`Data` 外层结构: +地址:`PUT /v1/admin/activity/room-rocket/config` -```json -{ - "event_id": "evt_xxx", - "room_id": "lalu_xxx", - "event_type": "room_rocket_progress_changed", - "actor_user_id": 12345678, - "target_user_id": 0, - "room_version": 8, - "attributes": { - "box_id": "room_rocket_box_xxx" - } -} -``` +参数: -通用字段: - -| 字段 | 类型 | 说明 | +| 参数 | 必填 | 说明 | | --- | --- | --- | -| `event_id` | string | IM 去重键。 | -| `room_id` | string | 房间 ID。 | -| `event_type` | string | 事件类型,见下表。 | -| `actor_user_id` | int64 | 触发用户;倒计时/发射通常为点火人。 | -| `target_user_id` | int64 | 目标用户;倒计时/发射通常为 top1。 | -| `room_version` | int64 | 房间版本,本地版本更高时丢弃旧消息。 | -| `attributes` | map | 业务字段;值全部按字符串传输,Flutter 侧按需转 int/bool/JSON。 | +| `enabled` | 是 | 是否开启 | +| `fuelSource` | 是 | 固定传 `heat_value` | +| `launchDelayMs` | 是 | 点火后延迟发射毫秒 | +| `broadcastEnabled` | 是 | 是否发飘屏 | +| `broadcastScope` | 是 | `region`、`global`、`none` | +| `broadcastDelayMs` | 是 | 保留字段,当前点火后立即入 outbox | +| `rewardStackPolicy` | 是 | `allow_stack` 或 `priority_only` | +| `levels` | 是 | 必须 5 级 | +| `levels[].fuelThreshold` | 是 | 等级阈值 | +| `levels[].coverUrl` | 否 | 小封面图 | +| `levels[].animationUrl` | 否 | 常态动效 | +| `levels[].launchAnimationUrl` | 否 | 发射动效 | +| `levels[].launchedImageUrl` | 否 | 发射后图 | +| `levels[].inRoomRewards` | 否 | 在房奖励池 | +| `levels[].top1Rewards` | 否 | 贡献第一奖励池 | +| `levels[].igniterRewards` | 否 | 点火人奖励池 | +| `giftFuelRules[].giftId` | 否 | 指定礼物 ID | +| `giftFuelRules[].giftTypeCode` | 否 | 指定礼物类型 | +| `giftFuelRules[].multiplierPpm` | 否 | 燃料倍率,1 倍为 `1000000` | +| `giftFuelRules[].fixedFuel` | 否 | 固定燃料 | +| `giftFuelRules[].excluded` | 否 | 是否排除该礼物 | -火箭房间群事件: +返回值:同获取后台配置。 -| `event_type` | `Desc` | 用途 | -| --- | --- | --- | -| `room_rocket_progress_changed` | `room_rocket_progress_changed` | 有效火箭燃料增加。 | -| `room_rocket_countdown_started` | `room_rocket_countdown_started` | 当前等级燃料满,进入倒计时。 | -| `room_rocket_opened` | `room_rocket_opened` | 火箭已打开,包含公开奖励摘要。 | -| `room_rocket_reward_granted` | `room_rocket_reward_granted` | 发奖结果事件,包含奖励列表。 | +## 房间 IM -### room_rocket_progress_changed +来源:腾讯云 IM 房间群。 -示例: +基础字段: -```json -{ - "event_id": "evt_progress", - "room_id": "lalu_xxx", - "event_type": "room_rocket_progress_changed", - "actor_user_id": 12345678, - "room_version": 6, - "attributes": { - "box_id": "room_rocket_box_xxx", - "level": "2", - "added_energy": "300", - "effective_added_energy": "200", - "overflow_energy": "100", - "current_progress": "200", - "energy_threshold": "200", - "status": "countdown", - "reset_at_ms": "1779321600000", - "gift_id": "rocket", - "gift_count": "3", - "command_id": "cmd_send_gift_xxx" - } -} -``` - -处理: - -- 更新当前等级进度为 `current_progress`。 -- `effective_added_energy` 是本次实际计入火箭的燃料。 -- `overflow_energy` 是作废燃料,不能加到下一等级。 -- 如果 `status=countdown`,立即切换倒计时态,等待 `room_rocket_countdown_started` 补齐 `open_at_ms`;如该消息丢失,调用 GET 兜底。 - -### room_rocket_countdown_started - -示例: - -```json -{ - "event_id": "evt_countdown", - "room_id": "lalu_xxx", - "event_type": "room_rocket_countdown_started", - "actor_user_id": 12345678, - "target_user_id": 12345678, - "room_version": 6, - "attributes": { - "box_id": "room_rocket_box_xxx", - "level": "2", - "current_progress": "200", - "energy_threshold": "200", - "countdown_start_ms": "1779259502104", - "open_at_ms": "1779259512104", - "broadcast_scope": "region", - "visible_region_id": "1001", - "top1_user_id": "12345678", - "igniter_user_id": "12345678", - "command_id": "cmd_send_gift_xxx" - } -} -``` - -处理: - -- 按 `open_at_ms` 展示发射倒计时。 -- `top1_user_id` 和 `igniter_user_id` 在此刻锁定;后续离房或下线也不影响这两类奖励发放。 -- 倒计时期间再收到普通 `room_gift_sent`,只更新礼物表现和热度,不改变火箭倒计时。 - -注意:房间群 IM 字段名是 `countdown_start_ms`,HTTP state 字段名是 `countdown_started_at_ms`。 - -### room_rocket_opened - -示例: - -```json -{ - "event_id": "evt_opened", - "room_id": "lalu_xxx", - "event_type": "room_rocket_opened", - "actor_user_id": 12345678, - "target_user_id": 12345678, - "room_version": 8, - "attributes": { - "box_id": "room_rocket_box_xxx", - "level": "2", - "next_level": "3", - "opened_at_ms": "1779259512668", - "reset_at_ms": "1779321600000", - "top1_user_id": "12345678", - "igniter_user_id": "12345678", - "rewards_json": "[{\"reward_role\":\"in_room\",\"user_id\":12345678,\"reward_item_id\":\"lv2_in_room_1\",\"resource_group_id\":1001,\"display_name\":\"Gold Pack\",\"icon_url\":\"https://cdn.example/reward/gold.png\",\"grant_id\":\"rgr_xxx\",\"status\":\"succeeded\"}]" - } -} -``` - -处理: - -- 播放当前等级 `opening_animation_url`,再展示 `opened_image_url` 或奖励弹窗。 -- 解析 `rewards_json` 得到奖励列表;如果解析失败,调用 GET 获取 `state.last_rewards`。 -- 本地状态进入 `next_level`,进度归 0,状态归 `idle`。 - -### room_rocket_reward_granted - -示例: - -```json -{ - "event_id": "evt_reward", - "room_id": "lalu_xxx", - "event_type": "room_rocket_reward_granted", - "room_version": 8, - "attributes": { - "box_id": "room_rocket_box_xxx", - "level": "2", - "rewards_json": "[{\"reward_role\":\"top1\",\"user_id\":12345678,\"reward_item_id\":\"lv2_top1_1\",\"resource_group_id\":1002,\"display_name\":\"Top Reward\",\"icon_url\":\"https://cdn.example/reward/top.png\",\"grant_id\":\"rgr_xxx\",\"status\":\"succeeded\"}]" - } -} -``` - -处理: - -- 解析 `rewards_json`。 -- 当前登录用户在奖励列表中时,展示“获得奖励”提示。 -- 同一用户可能同时命中 `in_room`、`top1`、`igniter`,是否叠加由 `reward_stack_policy` 决定;客户端按服务端发下来的列表展示。 - -当前没有单独的 C2C 火箭中奖私信,也没有 App 消息 Tab 火箭入箱消息。后续如新增私有通知,应以新的 C2C `Ext` 文档为准,不复用房间群消息假装私信。 - -## 区域/全局播报 IM - -燃料满进入倒计时后,如果后台配置允许播报,`activity-service` 会向区域或全局播报群发送 `TIMCustomElem`。 - -| 字段 | 值 | +| 字段 | 说明 | | --- | --- | -| `MsgType` | `TIMCustomElem` | -| `MsgContent.Ext` | `im_broadcast` | -| `MsgContent.Desc` | `room_rocket` | -| `MsgContent.Data` | JSON 字符串 | -| `CloudCustomData` | 与 `MsgContent.Data` 相同的 JSON 字符串 | +| `TIMCustomElem.Ext` | `room_system_message` | +| `event_type` | 事件类型 | +| `room_id` | 房间 ID | +| `event_id` | 消息 ID | +| `occurred_at_ms` | 服务端 UTC 毫秒 | -`Data` 示例: +### room_rocket_fuel_changed -```json -{ - "event_id": "room_rocket_broadcast:lalu_xxx:room_rocket_box_xxx", - "broadcast_type": "room_rocket", - "scope": "region", - "app_code": "lalu", - "region_id": 1001, - "room_id": "lalu_xxx", - "box_id": "room_rocket_box_xxx", - "level": 2, - "open_at_ms": 1779259512104, - "top1_user_id": 12345678, - "igniter_user_id": 12345678, - "sent_at_ms": 1779259502200, - "room_version": 6, - "source_event_id": "evt_countdown", - "current_progress": 200, - "energy_threshold": 200, - "countdown_started_at_ms": 1779259502104, - "action": { - "type": "enter_room", - "room_id": "lalu_xxx" - } -} -``` +用途:刷新当前等级进度。 -处理: +字段: -- `broadcast_type=room_rocket` 时展示火箭倒计时播报。 -- 点击播报按 `action.type=enter_room` 跳转或进房。 -- `scope=region` 表示区域播报群;`scope=global` 表示全局播报群。 -- 播报只负责引导和展示,不代替房间内火箭状态;进房后仍调用 GET 初始化。 - -## 普通送礼房间 IM - -送礼成功还会产生普通房间群消息: - -| 字段 | 值 | +| 字段 | 说明 | | --- | --- | -| `MsgContent.Ext` | `room_system_message` | -| `MsgContent.Desc` | `room_gift_sent` | -| `Data.event_type` | `room_gift_sent` | +| `rocket_id` | 当前累计火箭 ID | +| `level` | 当前累计等级 | +| `added_fuel` | 本次理论燃料 | +| `effective_added_fuel` | 本次实际计入燃料 | +| `overflow_fuel` | 本次作废燃料 | +| `current_fuel` | 当前等级燃料 | +| `fuel_threshold` | 当前等级阈值 | +| `status` | `charging` 或本次满阈值时的 `ignited` | +| `gift_id` | 礼物 ID | +| `gift_count` | 礼物数量 | -火箭 UI 不应该只靠 `room_gift_sent` 推算燃料;燃料以 `room_rocket_progress_changed`、`room_rocket_countdown_started`、`POST /gift/send.data.rocket` 或 GET 结果为准。 +### room_rocket_ignited -## Flutter 解析建议 +用途:当前等级满阈值,加入待发射队列;客户端立即把下一等级作为当前进度。 -IM `attributes` 的值都是字符串,建议统一封装解析: +字段: -```dart -int readInt(dynamic value, [int fallback = 0]) { - if (value == null) return fallback; - if (value is num) return value.toInt(); - if (value is String) return int.tryParse(value) ?? fallback; - return fallback; -} - -String readString(dynamic value) { - if (value == null) return ''; - if (value is String) return value; - if (value is num) return value.toInt().toString(); - return value.toString(); -} - -List> readRewardsJson(dynamic value) { - final raw = readString(value); - if (raw.isEmpty) return const []; - final decoded = jsonDecode(raw); - if (decoded is! List) return const []; - return decoded.whereType().map((item) { - return item.map((key, val) => MapEntry(key.toString(), val)); - }).toList(); -} -``` - -推荐本地状态更新策略: - -| 输入 | 处理 | +| 字段 | 说明 | | --- | --- | -| `GET /rocket` | 覆盖本地完整火箭状态和 7 级物料。 | -| `POST /gift/send.data.rocket` | 送礼人立即刷新当前状态。 | -| `room_rocket_progress_changed` | 同房间、版本不旧时更新进度。 | -| `room_rocket_countdown_started` | 切换倒计时,并锁定 top1/点火人展示。 | -| `room_rocket_opened` | 播放发射动效,状态切到下一等级 idle。 | -| `room_rocket_reward_granted` | 展示当前用户中奖提示;按 `event_id` 去重。 | -| `im_broadcast + room_rocket` | 展示区域/全局播报入口,不直接改当前房间状态。 | +| `rocket_id` | 待发射火箭 ID | +| `level` | 待发射等级 | +| `rocket_level` | 同 `level` | +| `room_short_id` | 房间短号 | +| `rocket_cover_url` | 小封面图 | +| `current_fuel` | 满阈值燃料 | +| `fuel_threshold` | 满阈值 | +| `ignited_at_ms` | 点火时间 | +| `launch_at_ms` | 预计发射时间 | +| `reset_at_ms` | UTC 日重置时间 | +| `top1_user_id` | 点火时贡献第一 | +| `igniter_user_id` | 点火人 | -如果客户端发现本地状态和服务端事件无法合并,例如当前 `box_id` 不一致、`level` 倒退、`room_version` 缺失,直接调用 `GET /api/v1/rooms/{room_id}/rocket` 重新同步。 +### room_rocket_launched + +用途:火箭已到点发射,客户端播放发射表现。 + +字段: + +| 字段 | 说明 | +| --- | --- | +| `rocket_id` | 已发射火箭 ID | +| `level` | 已发射等级 | +| `next_level` | 当前正在累计等级 | +| `launched_at_ms` | 发射时间 | +| `top1_user_id` | 贡献第一 | +| `igniter_user_id` | 点火人 | +| `rewards_json` | 奖励摘要 | + +## 区域或全局飘屏 IM + +来源:腾讯云 IM 区域群或全局群。 + +基础字段: + +| 字段 | 说明 | +| --- | --- | +| `TIMCustomElem.Ext` | `im_broadcast` | +| `TIMCustomElem.Desc` | `room_rocket` | + +消息体: + +| 字段 | 说明 | +| --- | --- | +| `broadcast_type` | `room_rocket` | +| `scope` | `region` 或 `global` | +| `room_id` | 房间 ID | +| `room_short_id` | 房间短号 | +| `rocket_id` | 待发射火箭 ID | +| `rocket_level` | 火箭等级 | +| `rocket_cover_url` | 小封面图 | +| `launch_at_ms` | 预计发射时间 | +| `igniter.user_id` | 点火人用户 ID | +| `igniter.short_id` | 点火人短 ID | +| `igniter.nickname` | 点火人昵称 | +| `igniter.avatar` | 点火人头像 | + +## 奖励房间 IM + +来源:腾讯云 IM 房间群。 + +基础字段: + +| 字段 | 说明 | +| --- | --- | +| `TIMCustomElem.Ext` | `im_broadcast` | +| `TIMCustomElem.Desc` | `room_rocket_reward_granted` | + +消息体: + +| 字段 | 说明 | +| --- | --- | +| `broadcast_type` | `room_rocket_reward_granted` | +| `scope` | `room` | +| `room_id` | 房间 ID | +| `rocket_id` | 已发射火箭 ID | +| `rocket_level` | 已发射等级 | +| `rewards` | 本次所有获得奖励的人 | +| `rewards[].reward_role` | `in_room`、`top1`、`igniter` | +| `rewards[].user.user_id` | 用户 ID | +| `rewards[].user.short_id` | 用户短 ID | +| `rewards[].user.nickname` | 用户昵称 | +| `rewards[].user.avatar` | 用户头像 | +| `rewards[].resource_group_id` | 资源组 ID | +| `rewards[].display_name` | 奖励展示名 | +| `rewards[].icon_url` | 奖励图标 | +| `rewards[].grant_id` | 发奖回执 | +| `rewards[].status` | 发奖状态 | + +## 客户端处理 + +- 进房先调 `GET /api/v1/rooms/{room_id}/rocket`。 +- 送礼成功优先用返回的 `rocket` 刷新状态。 +- 收到 `room_rocket_fuel_changed` 刷新进度。 +- 收到 `room_rocket_ignited` 把该火箭加入待发射队列,并显示下一等级当前进度。 +- 收到 `room_rocket_launched` 播放发射表现。 +- 收到 `room_rocket_reward_granted` 展示本次所有中奖人和奖励。 +- 如果 IM 丢失或乱序,用 GET 接口兜底。 +- 不要用普通 `room_gift_sent` 自行推算火箭燃料。 + +## 高频和延迟处理 + +- 送礼只同步写 Room Cell 状态和 room outbox,不同步请求腾讯 IM。 +- 房间进度 IM 走 room outbox 补偿投递,失败会重试。 +- 点火后延迟发射通过 RocketMQ delayed wakeup 唤醒;到点后 room-service 重新校验 `rocket_id`、`level`、`launch_at_ms`、UTC 重置边界。 +- 奖励 IM 一次性包含所有中奖人,避免按用户逐条发 IM。 diff --git a/docs/notice-service架构.md b/docs/notice-service架构.md index 506acedf..27051f19 100644 --- a/docs/notice-service架构.md +++ b/docs/notice-service架构.md @@ -267,7 +267,7 @@ rocketmq: 1. 钱包余额通知:已按 `walletnotice` 模块落地。 2. 房间踢人私有通知:已按 `roomnotice` 模块落地,只消费 `RoomUserKicked` 的 C2C 通知。 3. 幸运礼物中奖:仍由 `wallet-service` 写余额 outbox;notice 不需要新增特殊账务逻辑,只要客户端按 `balance_version` 更新。 -4. 语音房宝箱私有通知:如产品需要私聊提醒,可在 `roomnotice` 中消费 `RoomTreasureRewardGranted` 或由活动/系统消息模块转发;奖励入账事实仍由 room-service 调 wallet 完成,notice 只投通知。 +4. 语音房火箭私有通知:如产品需要私聊提醒,可在 `roomnotice` 中消费 `RoomRocketRewardGranted` 或由活动/系统消息模块转发;奖励入账事实仍由 room-service 调 wallet 完成,notice 只投通知。 5. 活动/系统私有通知:新增 `activitynotice` 模块,读取 activity outbox,复用 `notice_delivery_events` 或新增同语义位点表。 6. 站内 inbox:新增 inbox 模块,写 notice-service 自己的 inbox 表,再按客户端分页接口读取。 7. Push:新增 push 模块,和 IM 投递使用不同 `channel`,独立重试和死信。 diff --git a/docs/定时任务服务架构.md b/docs/定时任务服务架构.md index 076661d7..feae2e68 100644 --- a/docs/定时任务服务架构.md +++ b/docs/定时任务服务架构.md @@ -18,7 +18,7 @@ | `room-service` | stale presence cleanup | `services/room-service/internal/room/service/presence.go` | 留在 `room-service` | | `room-service` | mic publish timeout cleanup | `services/room-service/internal/room/service/mic_publish_timeout.go` | 留在 `room-service` | | `room-service` | room outbox relay to direct publishers / RocketMQ | `services/room-service/internal/room/service/outbox_worker.go` | 留在 `room-service` 或后续拆 `room-worker`,不进通用 cron | -| `room-service` | room rocket delayed open wakeup | `services/room-service/internal/room/service/room_rocket.go`、`services/room-service/internal/room/service/room_rocket_mq.go` | 留在 `room-service`;RocketMQ 只唤醒,到点后仍走 Room Cell 命令链路 | +| `room-service` | room rocket delayed launch wakeup | `services/room-service/internal/room/service/room_rocket.go`、`services/room-service/internal/room/service/room_rocket_mq.go` | 留在 `room-service`;RocketMQ 只唤醒,到点后仍走 Room Cell 命令链路 | | `user-service` | region rebuild task | `UserCronService.ProcessRegionRebuildBatch` | 已迁移到 cron-service 调度,旧内置 ticker 已删除 | | `user-service` | login IP risk job | `UserCronService.ProcessLoginIPRiskBatch` | 已迁移到 cron-service 调度,旧内置 ticker 已删除 | | `user-service` | invite recharge wallet outbox consumer | `services/user-service/internal/service/invite/service.go` | 先不迁,需补 durable cursor 或事件总线 | @@ -95,7 +95,7 @@ graph LR | Room Cell stale presence cleanup | 依赖本节点已装载 Room Cell 和 Redis lease,必须由 `room-service` owner 执行命令链路 | | Mic publish timeout cleanup | 必须对当前 Room Cell 状态做二次校验并执行 `MicDown`,不能让 cron 直接清麦 | | room outbox relay | 这是 room-service 的事件投递补偿,不是通用定时任务;需要和 direct publisher、RocketMQ 发布、outbox 状态语义绑定 | -| room rocket open timer | 火箭状态属于 Room Cell;延迟消息只负责唤醒,发射必须由 room-service 重新校验 `box_id/open_at_ms/UTC reset` 后执行命令 | +| room rocket launch timer | 火箭状态属于 Room Cell;延迟消息只负责唤醒,发射必须由 room-service 重新校验 `rocket_id/launch_at_ms/UTC reset` 后执行命令 | | wallet ledger transaction | 金币、钻石、积分、余额的加减和冻结必须在 `wallet-service` 事务内完成 | | wallet outbox publish | 属于 wallet-service outbox relay 或 MQ 投递,不应该由通用 cron 直接扫表改状态 | | invite recharge outbox consumer | 当前使用 in-memory cursor 扫 wallet outbox,迁移前必须先做 durable offset 或正式事件总线 | @@ -269,7 +269,7 @@ cron-service 可以直接开启多个实例;调度层通过 `cron_task_leases` - activity room outbox consumer。 - notice room outbox consumer。 - room IM bridge MQ consumer。 -- room rocket delayed open consumer。 +- room rocket delayed launch consumer。 这些需要的是事件消费基础设施:RocketMQ、Redis Stream、或 MySQL durable offset。cron-service 不能用“每隔几秒从头扫 outbox”的方式替代事件消费者。即使某些消费者内部有低频扫描补偿,也必须留在业务 owner service 中,因为它们需要理解 outbox 状态、消费幂等和领域状态校验。 diff --git a/docs/房间Outbox补偿开发.md b/docs/房间Outbox补偿开发.md index d76fff6c..779d4d95 100644 --- a/docs/房间Outbox补偿开发.md +++ b/docs/房间Outbox补偿开发.md @@ -37,7 +37,7 @@ gateway -> room-service command outbox worker -> claim pending/retryable rows - -> if RoomRocketCountdownStarted, schedule rocket open delayed wakeup when configured + -> if RoomRocketIgnited, schedule rocket launch delayed wakeup when configured -> publish by mode: direct: Tencent IM publisher + activity gRPC publisher mq: RocketMQ room_outbox publisher @@ -78,9 +78,9 @@ outbox worker | `hyapp_room_outbox` | `room-service` outbox worker | `hyapp-activity-room-outbox` | activity-service 消费房间事实,做活动、播报、系统消息等后续处理 | | `hyapp_room_outbox` | `room-service` outbox worker | `hyapp-notice-room-outbox` | notice-service 消费私有通知相关事实,例如 `RoomUserKicked` | | `hyapp_room_outbox` | `room-service` outbox worker | `hyapp-room-im-bridge` | room-service IM bridge 消费房间群系统消息和群成员控制事实 | -| `hyapp_room_rocket_open` | `room-service` rocket scheduler | `hyapp-room-rocket-open` | 火箭倒计时到点唤醒 Room Cell 发射命令 | +| `hyapp_room_rocket_launch` | `room-service` rocket scheduler | `hyapp-room-rocket-open` | 火箭倒计时到点唤醒 Room Cell 发射命令 | -`hyapp_room_rocket_open` 不是业务状态源。消息只携带 `app_code、room_id、box_id、level、open_at_ms、command_id` 等唤醒参数;真正能否发射仍由 room-service 加载 Room Cell 后校验当前火箭状态、等级、`box_id`、`open_at_ms` 和 UTC 重置边界。 +`hyapp_room_rocket_launch` 不是业务状态源。消息只携带 `app_code、room_id、rocket_id、level、launch_at_ms、command_id` 等唤醒参数;真正能否发射仍由 room-service 加载 Room Cell 后校验当前火箭状态、等级、`rocket_id`、`launch_at_ms` 和 UTC 重置边界。 ## 配置 @@ -103,9 +103,9 @@ rocketmq: tencent_im_consumer_enabled: false tencent_im_consumer_group: "hyapp-room-im-bridge" consumer_max_reconsume_times: 16 - rocket_open: + rocket_launch: enabled: false - topic: "hyapp_room_rocket_open" + topic: "hyapp_room_rocket_launch" producer_group: "hyapp-room-rocket-open-producer" consumer_group: "hyapp-room-rocket-open" consumer_max_reconsume_times: 16 @@ -129,7 +129,7 @@ outbox_worker: | `rocketmq.enabled` | 控制 RocketMQ client 是否启用;本地默认关闭,线上示例开启 | | `rocketmq.room_outbox.enabled` | 控制 room outbox 是否可发布到 MQ;`publish_mode=mq/dual` 时必须开启 | | `rocketmq.room_outbox.tencent_im_consumer_enabled` | 控制 room-service 是否作为 IM bridge consumer 消费 `hyapp_room_outbox` | -| `rocketmq.rocket_open.enabled` | 控制火箭倒计时是否发布和消费延迟唤醒消息 | +| `rocketmq.rocket_launch.enabled` | 控制火箭点火后是否发布和消费延迟发射唤醒消息 | | `outbox_worker.publish_mode` | 本地默认 `direct`,线上建议 `mq`,迁移期可用 `dual` | | `poll_interval` | worker 空轮询间隔,必须大于 0,避免 busy loop | | `batch_size` | 每轮最大抢占条数,必须大于 0,避免全表扫描 | @@ -143,7 +143,7 @@ outbox_worker: | RocketMQ room outbox publisher | 把 protobuf outbox 包装成统一 `room_outbox_event`,发布到 `hyapp_room_outbox` | | Tencent IM direct publisher | `RoomCreated` 对已同步创建的房间群执行幂等 EnsureGroup;房间系统事件发送腾讯云 IM 群自定义消息 | | Activity direct publisher | 本地或未接 MQ 时直接调用 activity-service room event 消费入口 | -| Rocket open scheduler | 在 `RoomRocketCountdownStarted` 后发布到点延迟消息,唤醒 room-service 发射 | +| Rocket launch scheduler | 在 `RoomRocketIgnited` 后发布到点延迟消息,唤醒 room-service 发射 | | Activity MQ consumer | 独立 consumer group 消费 `hyapp_room_outbox`,按 `event_id` 幂等推进活动/播报逻辑 | | Notice MQ consumer | 独立 consumer group 消费 `hyapp_room_outbox`,写 `notice_delivery_events` 后投 C2C 私有通知 | | Room IM bridge MQ consumer | 独立 consumer group 消费 `hyapp_room_outbox`,投递房间群系统消息和群成员控制 | @@ -193,6 +193,6 @@ RocketMQ consumer 失败时不修改 `room_outbox`。consumer 应依赖 RocketMQ | MQ 不可用,`mq/dual` | 房间写命令仍成功,outbox 进入重试;MQ 恢复后补发 | | activity consumer 不可用,`mq` | `room_outbox` 不回退;RocketMQ 和 activity 消费位点负责积压与重试 | | poison event | 达到 `max_retry_count` 后进入 `failed`,不再每秒刷日志 | -| 火箭倒计时 | `RoomRocketCountdownStarted` 发布延迟唤醒;到点后 room-service 重新校验状态再发射 | +| 火箭倒计时 | `RoomRocketIgnited` 发布延迟唤醒;到点后 room-service 重新校验状态再发射 | | 正常投递 | 同一 `event_id` 可被多个 consumer group 独立消费且重复投递不产生重复副作用 | | 真实链路 | create/join/mic/sendGift/leave 均有 `room_command_timing` | diff --git a/docs/语音房功能清单.md b/docs/语音房功能清单.md index bb56f688..0565c10f 100644 --- a/docs/语音房功能清单.md +++ b/docs/语音房功能清单.md @@ -136,7 +136,7 @@ | 礼物配置 | `PARTIAL` | `wallet_gift_prices` 提供服务端价格和 `COIN`/`DIAMOND` 收费资产,`gift_configs` 提供礼物类型、有效期和特效;SendGift 请求只传 `gift_id/gift_count`;礼物配置已绑定 `resource_type=gift` 资源 | 客户端礼物表现联调、资源版本缓存刷新未做 | | 背包/道具 | `PARTIAL` | `user_resource_entitlements`、`user_resource_equipment` 支持资源发放、我的资源和可佩戴资源装备 | 背包扣减、免费礼物、道具过期扫描未做 | | 礼物消息补偿 | `DONE` | GiftSent/Heat/Rank 事件进入 outbox,由 worker direct 投 IM/activity,或发布到 RocketMQ 后由各 consumer group 消费 | 客户端去重和展示策略未验收 | -| 语音房火箭 | `DONE` | `room_rocket_configs`、`GetRoomRocket`、`RoomRocketProgressChanged/CountdownStarted/Opened/RewardGranted`,发射支持本地扫描和 RocketMQ 延迟唤醒 | App UI、后台配置运营验收和真实 MQ 联调 | +| 语音房火箭 | `DONE` | `room_rocket_configs`、`GetRoomRocket`、`RoomRocketFuelChanged/Ignited/Launched/RewardGranted`,发射支持本地扫描和 RocketMQ 延迟唤醒 | App UI、后台配置运营验收和真实 MQ 联调 | ## Activity And Lucky Gift diff --git a/docs/语音房火箭功能架构.md b/docs/语音房火箭功能架构.md index 687e3d64..265f6b76 100644 --- a/docs/语音房火箭功能架构.md +++ b/docs/语音房火箭功能架构.md @@ -1,378 +1,125 @@ # 语音房火箭功能架构 -本文定义当前语音房火箭的产品规则、服务边界、App 接口、后台配置、MQ 唤醒、room outbox 事件和验收边界。火箭是房间内送礼驱动的互动玩法:用户在语音房送礼积攒燃料,当前等级燃料满后进入倒计时,倒计时结束时由 `room-service` 按 Room Cell 快照结算并调用 `wallet-service` 发放资源组奖励。所有周期、重置和统计口径统一使用 UTC。 - -## 当前实现结论 - -| 能力 | 当前事实 | -| --- | --- | -| App 初始化 | `GET /api/v1/rooms/{room_id}/rocket` 返回 7 级物料、当前等级、当前进度、重置时间、奖励展示配置 | -| 送礼累积 | `SendGift` 同步调用 `wallet-service.DebitGift` 成功后,Room Cell 内更新热度、礼物榜和火箭进度 | -| 燃料满 | 当前等级进度封顶为阈值,锁定点火人、贡献第一、`box_id`、`open_at_ms` 和 UTC `reset_at_ms` | -| 溢出和倒计时送礼 | 当前等级溢出燃料作废;倒计时期间送礼不累加到下一级,也不改变点火人或贡献第一 | -| 到期发射 | `room-service` 通过 RocketMQ 延迟消息到 `open_at_ms` 唤醒;本地已加载 Cell 扫描 worker 仍作为兜底 | -| 奖励结算 | `room-service` 在发射命令内按配置加权抽取奖励,并调用 `wallet-service.GrantResourceGroup` 幂等发放 | -| 离房/下线 | `top1` 和 `igniter` 在倒计时开始时锁定;发射前离房或下线仍结算对应角色奖励 | -| 在房奖励 | 只发给发射命令执行时 Room Cell `OnlineUsers` 中的用户 | -| 播报 | `activity-service` 消费 `RoomRocketCountdownStarted`,按 `broadcast_scope` 发区域或全局播报 | -| 事件分发 | `room_outbox` 先落 MySQL,再由 outbox worker 发布 RocketMQ;activity、notice、IM bridge 各自 consumer group 消费 | - -## Non-Goals - -| 不做 | 原因 | -| --- | --- | -| 客户端提交燃料、奖励或中奖结果 | 燃料和奖励必须来自服务端扣费、配置和确定性抽奖 | -| 只用 Redis 保存火箭进度 | MySQL snapshot、command log 和 `room_outbox` 才是恢复来源 | -| activity-service 判断谁在房 | 房间 presence 和发射快照必须由 Room Cell 产出 | -| activity-service 发放火箭奖励 | 当前发奖在 `room-service` 发射命令内调用 `wallet-service`,避免跨服务二次改写房间结算事实 | -| cron-service 到点发射 | 到点唤醒属于房间事件消费和 Room Cell 命令触发,不是通用 cron 任务 | -| 倒计时期间继续排队燃料 | 产品规则明确无效,不累加到下一个火箭 | - ## 服务边界 -| 模块 | 拥有 | 不拥有 | -| --- | --- | --- | -| `room-service` | 火箭进度、等级、状态机、点火人、贡献第一、发射在线用户快照、抽奖结果、房间火箭 IM outbox、RocketMQ 延迟发射唤醒消费 | 用户完整资料、全局/区域播报群成员、钱包余额账本 | -| `wallet-service` | 送礼扣费、资源组发放、钱包账务、发放幂等 | 火箭进度、中奖资格、房间 presence | -| `activity-service` | 燃料满后区域/全局播报、成长值等房间事件派生消费 | Room Cell 状态、火箭奖励结算 | -| `notice-service` | 通过 room outbox/MQ 消费需要私信的房间事实,写自己的投递位点和死信 | 房间群系统消息、群成员控制、房间状态 | -| `gateway-service` | App HTTP envelope、鉴权、协议转换、调用 room gRPC | 燃料、抽奖和发奖事实 | -| `server/admin` | 后台菜单、配置表单、审计、调用 owner service 管理接口 | 直接写 room/wallet/activity 业务表 | +- `room-service` 是火箭进度、点火、待发射队列和奖励结算 owner。 +- `gateway-service` 只提供 App HTTP 入口和鉴权后的协议转换。 +- `server/admin` 只读写 `room-service` 内部配置接口,不直连 room 数据库。 +- `wallet-service` 负责资源组发放。 +- `activity-service` 负责区域/全局飘屏和带用户资料的奖励房间 IM。 +- 腾讯云 IM 负责房间群、区域群和全局群投递。 -火箭进度和发射必须经过 Room Cell 命令链路。MQ 只承担“到点唤醒”和“已提交事实分发”,不能成为火箭状态 owner。 +## 状态模型 -## 状态机 +`RoomRocketState` 保存当前正在累计的火箭和已点火待发射队列。 -```mermaid -stateDiagram-v2 - [*] --> idle - idle --> idle: SendGift progress countdown: SendGift progress>=threshold - countdown --> opened: open_at_ms reached - opened --> idle: next level - opened --> exhausted: level 7 opened - idle --> idle: UTC day reset - countdown --> idle: UTC day reset before open - exhausted --> idle: next UTC day -``` - -| 状态 | 语义 | +| 字段 | 说明 | | --- | --- | -| `idle` | 当前等级可累积燃料 | -| `countdown` | 当前等级已满,`open_at_ms` 已确定,本轮进度、点火人、贡献第一和配置版本锁定 | -| `opened` | 本轮已发射,奖励已在命令内完成抽取和资源组发放请求 | -| `exhausted` | 当天 7 级都已开启,等下一个 UTC 自然日 | +| `current_level` | 当前正在累计的等级,最高 5 | +| `current_fuel` | 当前等级燃料 | +| `fuel_threshold` | 当前等级阈值 | +| `status` | `charging` 或 `completed` | +| `rocket_id` | 当前正在累计的火箭 ID | +| `pending_launches` | 已点火、等待延迟发射的火箭 | +| `last_rewards` | 最近一次发射奖励结果 | -当前实现没有单独持久化 `round_id` 表。`box_id` 是单轮火箭幂等和奖励选择的核心 ID,发射命令 ID 固定为: +`pending_launches` 中每一项锁定: -```text -cmd_room_rocket_open_ -``` - -奖励发放命令 ID 固定由 `box_id + role + user_id + reward_item_id` 派生,重复发射或 MQ 重投不会重复发放同一条奖励。 - -## 燃料规则 - -| 规则 | 决策 | +| 字段 | 说明 | | --- | --- | -| 燃料来源 | 默认使用 `DebitGiftResponse.gift_point_added`;后台可配置 `gift_id` 或 `gift_type_code` 的倍率、覆盖值和排除规则 | -| 生效条件 | wallet 扣费成功、Room Cell 提交 `SendGift`、礼物燃料值大于 0 | -| 幂等 | 同一 `SendGift.command_id` 只能增加一次火箭燃料 | -| 点火人 | 第一笔让当前等级 `progress >= threshold` 的送礼用户 | -| 贡献第一 | 当前等级倒计时前累计有效燃料最高用户 | -| 溢出燃料 | 单笔礼物超过当前等级剩余阈值时,只计入补满当前等级所需燃料,多余燃料作废 | -| 倒计时送礼 | 当前等级已经燃料满,继续送礼不再增加火箭燃料,也不会排队到下一等级 | -| 7 级后送礼 | 当天不再累计火箭燃料,礼物仍正常扣费、热度和榜单照常更新 | +| `rocket_id` | 本枚火箭幂等 ID | +| `level` | 点火等级 | +| `fuel_threshold` | 点火阈值 | +| `ignited_at_ms` | 点火时间 | +| `launch_at_ms` | 预计发射时间 | +| `reset_at_ms` | UTC 日重置时间 | +| `top1_user_id` | 点火时贡献第一 | +| `igniter_user_id` | 点火人 | +| `config_version` | 点火配置版本 | +| `cover_url` | 小封面图 | -无效燃料只进入 `RoomRocketProgressChanged` 的审计字段,不计入当前等级贡献、不计入下一等级进度、不改变点火人或贡献第一。 +## 送礼加燃料 -## 发射与 MQ 唤醒 +1. `SendGift` 先调用 wallet 扣费。 +2. 扣费成功后,Room Cell 串行更新热度、礼物榜和火箭。 +3. 燃料来自 wallet 返回的 `heat_value`。 +4. 当前等级未满时,增加 `min(本次燃料, 剩余阈值)`。 +5. 超过阈值的燃料直接写入 `overflow_fuel`,不进入下一等级。 +6. 当前等级满后: + - 写入 `RoomRocketFuelChanged`。 + - 写入 `RoomRocketIgnited`。 + - 将当前火箭追加到 `pending_launches`。 + - 如果未到 5 级,立刻把当前状态切到下一等级,`current_fuel=0`。 + - 如果 5 级已满,状态切到 `completed`,UTC 重置前不再累计新火箭。 -```mermaid -sequenceDiagram - participant C as App - participant R as room-service - participant W as wallet-service - participant DB as hyapp_room - participant MQ as RocketMQ - participant A as activity-service - participant IM as Tencent IM +## 发射结算 - C->>R: SendGift(command_id) - R->>W: DebitGift(command_id) - W-->>R: gift_point_added, heat_value - R->>R: Room Cell updates rocket - R->>DB: command_log + snapshot + room_outbox - R-->>C: SendGiftResponse.rocket - R->>DB: outbox worker claims RoomRocketCountdownStarted - R->>MQ: delayed RoomRocketOpenDue(open_at_ms) - R->>MQ: room_outbox event - MQ-->>A: RoomRocketCountdownStarted - A-->>IM: region/global broadcast - MQ-->>R: RoomRocketOpenDue at open_at_ms - R->>R: OpenRoomRocket command - R->>W: GrantResourceGroup per reward - R->>DB: command_log + snapshot + opened/reward outbox -``` +1. `RoomRocketIgnited` 被 outbox worker 处理后,安排 RocketMQ delayed wakeup。 +2. 本地扫描 worker 也会扫描已加载且仍持有 lease 的 Room Cell,作为兜底。 +3. 到点后 `LaunchRoomRocket` 命令重新加载 Room Cell。 +4. 命令必须校验: + - `rocket_id` 存在于 `pending_launches`。 + - `level` 匹配。 + - `launch_at_ms <= now_ms`。 + - `now_ms < reset_at_ms`。 +5. 结算时按发射瞬间 `OnlineUsers` 抽在房奖励。 +6. `top1` 和 `igniter` 使用点火时锁定的用户,离房后仍发对应奖励。 +7. 发奖完成后移除该 `pending_launches` 项,当前正在累计的火箭不被发射命令推进。 -关键规则: +## 奖励 -- 延迟消息只唤醒,不直接修改状态。 -- MQ 提前投递时,`room-service` 先检查 `open_at_ms`,未到点返回可重试错误,不写 no-op command log,避免污染发射 `command_id`。 -- MQ 重复投递、outbox 重试或扫描 worker 同时触发时,`command_id` 和 wallet grant command 保证幂等。 -- 如果 `reset_at_ms` 已过,UTC 日界优先,昨天倒计时火箭不再结算。 -- 本地 `room_rocket_open_scan_interval` 只扫描本节点已加载且仍持有 lease 的 Cell,是兜底,不解决未加载房间;未加载房间依赖 MQ delayed wakeup。 - -## 奖励规则 - -| 奖励角色 | 资格 | +| 奖励 | 用户来源 | | --- | --- | -| `in_room` | 发射命令执行时 Room Cell `OnlineUsers` 内的每个用户 | -| `top1` | 倒计时开始时锁定的贡献第一用户 | -| `igniter` | 倒计时开始时锁定的点火人 | - -奖励配置按等级拆分,每个角色是一个加权奖励池。奖励项引用 `resource_group_id`,由 `wallet-service` 按资源组发放金币、钻石或资源权益。 - -```json -{ - "level": 3, - "reward_role": "top1", - "items": [ - { - "reward_item_id": "lv3_top1_a", - "resource_group_id": 12003, - "weight": 9000, - "display_name": "Top1 Reward", - "icon_url": "https://cdn.example/reward.png" - } - ] -} -``` +| 点火人奖励 | `pending_launches[].igniter_user_id` | +| 贡献第一奖励 | `pending_launches[].top1_user_id` | +| 在房奖励 | 发射命令执行时 Room Cell `OnlineUsers` | `reward_stack_policy`: -| 策略 | 语义 | +| 值 | 说明 | | --- | --- | -| `allow_stack` | 同一用户可同时获得在房、top1、igniter 多个角色奖励 | -| `priority_only` | 同一用户只获得优先级最高的一份,优先级为 `igniter -> top1 -> in_room` | +| `allow_stack` | 同一用户可同时拿在房、top1、点火人奖励 | +| `priority_only` | 先点火人,再 top1,再在房;同一用户只拿一份 | -## App 接口 +## IM -### 获取火箭信息 - -```http -GET /api/v1/rooms/{room_id}/rocket -Authorization: Bearer -``` - -Response `data` 核心字段: - -```json -{ - "enabled": true, - "server_time_ms": 1779120000000, - "reset_at_ms": 1779148800000, - "broadcast_scope": "region", - "open_delay_ms": 30000, - "broadcast_delay_ms": 0, - "reward_stack_policy": "allow_stack", - "state": { - "current_level": 3, - "current_progress": 3600, - "energy_threshold": 10000, - "status": "idle", - "open_at_ms": 0, - "top1_user_id": "10001", - "igniter_user_id": "0", - "box_id": "box_xxx", - "last_rewards": [] - }, - "levels": [ - { - "level": 1, - "energy_threshold": 3000, - "cover_url": "https://cdn.example/chest/lv1/cover.png", - "animation_url": "https://cdn.example/chest/lv1/idle.svga", - "opening_animation_url": "https://cdn.example/chest/lv1/opening.svga", - "opened_image_url": "https://cdn.example/chest/lv1/opened.png", - "in_room_rewards": [], - "top1_rewards": [], - "igniter_rewards": [] - } - ] -} -``` - -规则: - -- `levels` 固定返回 7 个等级。 -- `reset_at_ms` 是下一个 UTC 零点,不是用户本地零点。 -- 客户端只展示服务端返回的当前等级进度;不要自行把溢出燃料展示到下一等级。 -- 当前没有单独的 App 领奖接口,奖励自动发放,最近一次发射奖励通过 `state.last_rewards` 展示。 - -### SendGift 响应 - -`SendGiftResponse` 已携带 `rocket` 状态,送礼人成功送礼后可立即更新火箭 UI,不必等待 IM。 - -## 房间 IM 事件 - -房间内火箭 IM 走腾讯云房间群 `TIMCustomElem`,由 room outbox 事件转换。当前事件类型: - -| Event | 客户端 `event_type` | 用途 | +| 事实 | IM | 投递方 | | --- | --- | --- | -| `RoomRocketProgressChanged` | `room_rocket_progress_changed` | 火箭进度增加 | -| `RoomRocketCountdownStarted` | `room_rocket_countdown_started` | 燃料满进入倒计时 | -| `RoomRocketOpened` | `room_rocket_opened` | 火箭打开并给出公开奖励摘要 | -| `RoomRocketRewardGranted` | `room_rocket_reward_granted` | 发奖结果事件,包含奖励列表 | +| `RoomRocketFuelChanged` | `room_rocket_fuel_changed` | room-service IM bridge | +| `RoomRocketIgnited` | `room_rocket_ignited` | room-service IM bridge | +| `RoomRocketIgnited` | `room_rocket` 区域/全局飘屏 | activity-service | +| `RoomRocketLaunched` | `room_rocket_launched` | room-service IM bridge | +| `RoomRocketRewardGranted` | `room_rocket_reward_granted` 房间奖励 IM | activity-service | -### Progress Changed +奖励 IM 由 activity-service 查询用户公开资料后组装,包含头像、昵称、短 ID 和奖励信息。room-service 不反查 user-service。 -```json -{ - "event_type": "room_rocket_progress_changed", - "box_id": "box_xxx", - "level": 3, - "added_energy": 500, - "effective_added_energy": 500, - "overflow_energy": 0, - "current_progress": 3600, - "energy_threshold": 10000, - "status": "idle", - "reset_at_ms": 1779148800000, - "gift_id": "rose", - "gift_count": 10 -} -``` +## 高频处理 -### Countdown Started +- Room Cell 串行处理送礼命令,所以同房高频送礼不会并发写乱进度。 +- 满阈值时只把火箭追加到 `pending_launches`,不会阻塞下一等级继续累计。 +- 房间状态 IM 通过 room outbox 补偿投递,不在送礼主链路同步调用腾讯 IM。 +- 奖励 IM 合并为一条消息,`rewards` 内包含所有中奖人,避免按用户逐条堆积。 +- RocketMQ delayed wakeup 只负责唤醒,最终发射仍由 Room Cell 命令校验。 -```json -{ - "event_type": "room_rocket_countdown_started", - "box_id": "box_xxx", - "level": 3, - "current_progress": 10000, - "energy_threshold": 10000, - "open_at_ms": 1779120030000, - "broadcast_scope": "region", - "visible_region_id": 1001, - "top1_user_id": "10001", - "igniter_user_id": "10002" -} -``` +## UTC 重置 -### Opened / Reward Granted - -```json -{ - "event_type": "room_rocket_opened", - "box_id": "box_xxx", - "level": 3, - "next_level": 4, - "opened_at_ms": 1779120030000, - "top1_user_id": "10001", - "igniter_user_id": "10002", - "rewards_json": "[...]" -} -``` - -群 IM 承载公开摘要。后续如要做个人中奖私信或消息 Tab,应由 `notice-service` 或 inbox consumer 消费 `RoomRocketRewardGranted`,不要让 `room-service` 同步投递私信。 +- 火箭周期按 UTC 自然日。 +- 到达 `reset_at_ms` 后,查询或下一次送礼会投影为 1 级新进度。 +- 跨过 UTC 日界仍未发射的待发射火箭不结算。 ## 后台配置 -后台配置带 `config_version`。倒计时开始后,当前火箭的 `box_id`、等级、进度、`open_at_ms`、点火人和贡献第一不再因配置修改而变化;当前实现发射时读取最新启用配置的奖励池,因此运营在倒计时窗口内修改奖励配置要按发布窗口处理。 +配置表:`room_rocket_configs` -| 字段 | 含义 | +| 字段 | 说明 | | --- | --- | -| `enabled` | 总开关 | -| `energy_source` | 默认 `gift_point_added` | -| `open_delay_ms` | 燃料满到发射的倒计时 | -| `broadcast_enabled` | 是否燃料满后播报 | -| `broadcast_scope` | `none/region/global` | -| `broadcast_delay_ms` | 燃料满后多久发播报,当前代码写入配置和读模型,实际倒计时事件立即进入 room outbox | -| `reward_stack_policy` | `allow_stack` 或 `priority_only` | -| `levels` | 7 个等级配置,包含阈值、物料 URL 和三类奖励池 | -| `gift_energy_rules` | gift_id 或 gift_type_code 的燃料倍率、覆盖值和排除规则 | -| `updated_by_admin_id` | 审计字段 | +| `enabled` | 是否开启 | +| `fuel_source` | 固定 `heat_value` | +| `launch_delay_ms` | 点火到发射延迟 | +| `broadcast_enabled` | 是否发飘屏 | +| `broadcast_scope` | `region`、`global`、`none` | +| `reward_stack_policy` | 奖励叠加策略 | +| `levels_json` | 5 级阈值、物料和奖励池 | +| `gift_fuel_rules_json` | 礼物燃料规则 | -奖励池保存时应校验: - -- `resource_group_id` 必须存在且 active。 -- 同一奖励池权重总和必须大于 0。 -- 展示字段可以保存快照,但发放仍以 wallet-service 资源组事实为准。 -- 配置不能直接填写金币数量绕过资源组,除非新增明确的钱包奖励配置类型。 - -## room_outbox 与 RocketMQ - -`room_outbox` 是 MySQL 内的可靠事实源,RocketMQ 是发布后的事件总线。不能用 MQ 替代 outbox,因为房间状态提交成功但 MQ 发布失败时必须能从 MySQL 补偿。 - -当前 topic: - -| Topic | Producer | Consumer | -| --- | --- | --- | -| `hyapp_room_outbox` | `room-service` outbox worker | `activity-service`、`notice-service`、`room-service` IM bridge、后续 push/inbox | -| `hyapp_room_rocket_open` | `room-service` rocket open scheduler | `room-service` rocket open consumer | - -推荐线上配置: - -```yaml -outbox_worker: - enabled: true - publish_mode: "mq" - -rocketmq: - enabled: true - room_outbox: - enabled: true - topic: "hyapp_room_outbox" - rocket_open: - enabled: true - topic: "hyapp_room_rocket_open" -``` - -`publish_mode`: - -| 模式 | 行为 | -| --- | --- | -| `direct` | room outbox worker 直接投 activity gRPC 和 Tencent IM,适合本地默认 | -| `mq` | room outbox worker 只投 RocketMQ,activity/notice/IM bridge 各自消费,推荐线上 | -| `dual` | direct + MQ 双写,只用于迁移验证,所有消费者必须幂等 | - -## Daily Reset - -| 规则 | 决策 | -| --- | --- | -| 日期来源 | `time.Now().UTC()` | -| 重置时间 | 每天 UTC `00:00:00.000` | -| 时间区间 | `[day_start_ms, next_day_start_ms)` | -| idle | 到达新 UTC 日时回到 level 1,清空进度和贡献 | -| countdown | 如果 `open_at_ms` 跨过 `reset_at_ms`,UTC 日界优先,旧火箭不再结算 | -| exhausted | 下一个 UTC 日恢复 level 1 | - -必须覆盖 UTC 边界测试:`day_start_ms`、`next_day_start_ms - 1`、`next_day_start_ms`。不要重新引入 `time.Local`、`task_timezone` 或客户端时区。 - -## 验收清单 - -| 场景 | 验收 | -| --- | --- | -| 送礼成功 | wallet 扣费成功,Room Cell 进度增加,`SendGiftResponse.rocket` 返回最新状态 | -| 单笔溢出 | 进度封顶当前阈值,溢出燃料作废,不进入下一等级 | -| 倒计时送礼 | 礼物正常扣费和加热度,但火箭进度、点火人、贡献第一不变化 | -| 燃料满 | 写 `RoomRocketCountdownStarted`,outbox worker 安排 RocketMQ delayed open | -| 提前延迟消息 | 不写发射 no-op command log,MQ 后续可重试 | -| 到点发射 | 即使房间未加载,MQ 唤醒后 room-service 恢复 Room Cell 并发射 | -| top1/igniter 离房 | 仍发角色奖励 | -| 在房奖励 | 只发发射瞬间在线用户 | -| UTC 重置 | 到达 UTC 零点后进度回到 level 1,跨日倒计时不结算 | -| MQ/outbox 重投 | `event_id`、`box_id`、wallet grant command 保证幂等 | - -## 代码位置 - -| 模块 | 路径 | -| --- | --- | -| 火箭状态机和结算 | `services/room-service/internal/room/service/room_rocket.go` | -| MQ delayed open | `services/room-service/internal/room/service/room_rocket_mq.go` | -| outbox worker | `services/room-service/internal/room/service/outbox_worker.go` | -| RocketMQ 适配 | `pkg/rocketmqx`、`pkg/roommq`、`services/room-service/internal/integration/rocketmq_outbox.go` | -| App HTTP | `services/gateway-service/internal/transport/http/roomapi` | -| 后台配置 | `server/admin/internal/modules/roomrocket` | +`levels_json` 字段使用 `fuelThreshold`、`coverUrl`、`animationUrl`、`launchAnimationUrl`、`launchedImageUrl`、`inRoomRewards`、`top1Rewards`、`igniterRewards`。 diff --git a/pkg/rocketmqx/rocketmqx.go b/pkg/rocketmqx/rocketmqx.go index f7099254..5d947106 100644 --- a/pkg/rocketmqx/rocketmqx.go +++ b/pkg/rocketmqx/rocketmqx.go @@ -20,8 +20,8 @@ import ( const ( // Tencent Cloud RocketMQ accepts this property as an absolute Unix epoch // millisecond delivery timestamp. Open-source RocketMQ delay levels remain - // available through the native client, but the treasure open timer needs an - // exact backend-configured open_at_ms. + // available through the native client, but the rocket launch timer needs an + // exact backend-configured launch_at_ms. PropertyStartDeliverTime = "__STARTDELIVERTIME" ) diff --git a/server/admin/internal/integration/roomclient/client.go b/server/admin/internal/integration/roomclient/client.go index a4b1a4a7..9bd3574f 100644 --- a/server/admin/internal/integration/roomclient/client.go +++ b/server/admin/internal/integration/roomclient/client.go @@ -135,7 +135,7 @@ type GiftFuelRuleConfig struct { GiftID string GiftTypeCode string MultiplierPPM int64 - FixedFuel int64 + FixedFuel int64 Excluded bool } @@ -642,7 +642,7 @@ func roomRocketFuelRulesFromProto(input []*roomv1.RoomRocketGiftFuelRule) []Gift GiftID: rule.GetGiftId(), GiftTypeCode: rule.GetGiftTypeCode(), MultiplierPPM: rule.GetMultiplierPpm(), - FixedFuel: rule.GetFixedFuel(), + FixedFuel: rule.GetFixedFuel(), Excluded: rule.GetExcluded(), }) } @@ -657,7 +657,7 @@ func roomRocketFuelRulesToProto(input []GiftFuelRuleConfig) []*roomv1.RoomRocket GiftId: rule.GiftID, GiftTypeCode: rule.GiftTypeCode, MultiplierPpm: rule.MultiplierPPM, - FixedFuel: rule.FixedFuel, + FixedFuel: rule.FixedFuel, Excluded: rule.Excluded, }) } diff --git a/server/admin/internal/modules/roomrocket/service.go b/server/admin/internal/modules/roomrocket/service.go index 910d093b..b9fdd1ac 100644 --- a/server/admin/internal/modules/roomrocket/service.go +++ b/server/admin/internal/modules/roomrocket/service.go @@ -71,7 +71,7 @@ type GiftFuelRuleConfig struct { GiftID string `json:"giftId"` GiftTypeCode string `json:"giftTypeCode"` MultiplierPPM int64 `json:"multiplierPpm"` - FixedFuel int64 `json:"fixedFuel"` + FixedFuel int64 `json:"fixedFuel"` Excluded bool `json:"excluded"` } @@ -161,10 +161,6 @@ func normalizeConfig(config RoomRocketConfig) (RoomRocketConfig, error) { return RoomRocketConfig{}, fmt.Errorf("%w: 配置版本不能小于 0", ErrInvalidArgument) } config.FuelSource = defaultString(strings.TrimSpace(config.FuelSource), defaultFuelSource) - if config.FuelSource == "gift_point_added" { - // gift_point_added 是历史配置值;GIFT_POINT 已下线,后台读取旧配置时统一切到真实礼物贡献口径。 - config.FuelSource = defaultFuelSource - } if config.FuelSource != defaultFuelSource { return RoomRocketConfig{}, fmt.Errorf("%w: 燃料来源不支持", ErrInvalidArgument) } @@ -408,7 +404,7 @@ func fuelRulesFromClient(input []roomclient.GiftFuelRuleConfig) []GiftFuelRuleCo GiftID: rule.GiftID, GiftTypeCode: rule.GiftTypeCode, MultiplierPPM: rule.MultiplierPPM, - FixedFuel: rule.FixedFuel, + FixedFuel: rule.FixedFuel, Excluded: rule.Excluded, }) } @@ -423,7 +419,7 @@ func fuelRulesToClient(input []GiftFuelRuleConfig) []roomclient.GiftFuelRuleConf GiftID: rule.GiftID, GiftTypeCode: rule.GiftTypeCode, MultiplierPPM: rule.MultiplierPPM, - FixedFuel: rule.FixedFuel, + FixedFuel: rule.FixedFuel, Excluded: rule.Excluded, }) } diff --git a/server/admin/internal/modules/roomrocket/service_test.go b/server/admin/internal/modules/roomrocket/service_test.go index 888dd4e6..18af318c 100644 --- a/server/admin/internal/modules/roomrocket/service_test.go +++ b/server/admin/internal/modules/roomrocket/service_test.go @@ -15,8 +15,6 @@ func TestNormalizeConfigDefaultsAndSortsLevels(t *testing.T) { {Level: 3, FuelThreshold: 600}, {Level: 4, FuelThreshold: 1_000}, {Level: 5, FuelThreshold: 1_500}, - {Level: 6, FuelThreshold: 2_100}, - {Level: 7, FuelThreshold: 2_800}, }, }) if err != nil { @@ -25,7 +23,7 @@ func TestNormalizeConfigDefaultsAndSortsLevels(t *testing.T) { if config.FuelSource != defaultFuelSource || config.LaunchDelayMS != defaultLaunchDelayMS || config.BroadcastScope != defaultBroadcastScope { t.Fatalf("default fields mismatch: %+v", config) } - if config.Levels[0].Level != 1 || config.Levels[6].Level != 7 { + if config.Levels[0].Level != 1 || config.Levels[4].Level != 5 { t.Fatalf("levels should be sorted by level: %+v", config.Levels) } } @@ -47,10 +45,10 @@ func TestNormalizeConfigRejectsInvalidLevels(t *testing.T) { }{ {name: "missing_level", config: RoomRocketConfig{AppCode: "lalu", Levels: []RoomRocketLevelConfig{{Level: 1, FuelThreshold: 1}}}}, {name: "duplicate", config: RoomRocketConfig{AppCode: "lalu", Levels: []RoomRocketLevelConfig{ - {Level: 1, FuelThreshold: 1}, {Level: 1, FuelThreshold: 2}, {Level: 3, FuelThreshold: 3}, {Level: 4, FuelThreshold: 4}, {Level: 5, FuelThreshold: 5}, {Level: 6, FuelThreshold: 6}, {Level: 7, FuelThreshold: 7}, + {Level: 1, FuelThreshold: 1}, {Level: 1, FuelThreshold: 2}, {Level: 3, FuelThreshold: 3}, {Level: 4, FuelThreshold: 4}, {Level: 5, FuelThreshold: 5}, }}}, {name: "zero_threshold", config: RoomRocketConfig{AppCode: "lalu", Levels: []RoomRocketLevelConfig{ - {Level: 1, FuelThreshold: 0}, {Level: 2, FuelThreshold: 2}, {Level: 3, FuelThreshold: 3}, {Level: 4, FuelThreshold: 4}, {Level: 5, FuelThreshold: 5}, {Level: 6, FuelThreshold: 6}, {Level: 7, FuelThreshold: 7}, + {Level: 1, FuelThreshold: 0}, {Level: 2, FuelThreshold: 2}, {Level: 3, FuelThreshold: 3}, {Level: 4, FuelThreshold: 4}, {Level: 5, FuelThreshold: 5}, }}}, } @@ -74,6 +72,6 @@ func TestNormalizeConfigRejectsIncompleteRewardAndFuelRule(t *testing.T) { config = defaultConfig("lalu") config.GiftFuelRules = []GiftFuelRuleConfig{{GiftID: "gift-1"}} if _, err := normalizeConfig(config); !errors.Is(err, ErrInvalidArgument) { - t.Fatalf("energy rule without multiplier or fixed energy should be invalid, got %v", err) + t.Fatalf("fuel rule without multiplier or fixed fuel should be invalid, got %v", err) } } diff --git a/server/admin/migrations/018_room_treasure_navigation.sql b/server/admin/migrations/018_room_rocket_navigation.sql similarity index 100% rename from server/admin/migrations/018_room_treasure_navigation.sql rename to server/admin/migrations/018_room_rocket_navigation.sql diff --git a/services/activity-service/internal/service/broadcast/service.go b/services/activity-service/internal/service/broadcast/service.go index 6596d8e7..9ccfe21b 100644 --- a/services/activity-service/internal/service/broadcast/service.go +++ b/services/activity-service/internal/service/broadcast/service.go @@ -649,19 +649,19 @@ func roomPasswordChangedPayload(envelope *roomeventsv1.EventEnvelope, event *roo func roomRocketPayload(envelope *roomeventsv1.EventEnvelope, rocket *roomeventsv1.RoomRocketIgnited, eventID string, sentAtMS int64, igniter SenderProfile) (string, error) { payload := map[string]any{ - "event_id": eventID, - "broadcast_type": broadcastdomain.TypeRoomRocket, - "scope": rocket.GetBroadcastScope(), - "app_code": envelope.GetAppCode(), - "region_id": rocket.GetVisibleRegionId(), - "room_id": envelope.GetRoomId(), - "room_short_id": rocket.GetRoomShortId(), - "rocket_id": rocket.GetRocketId(), - "rocket_level": rocket.GetLevel(), + "event_id": eventID, + "broadcast_type": broadcastdomain.TypeRoomRocket, + "scope": rocket.GetBroadcastScope(), + "app_code": envelope.GetAppCode(), + "region_id": rocket.GetVisibleRegionId(), + "room_id": envelope.GetRoomId(), + "room_short_id": rocket.GetRoomShortId(), + "rocket_id": rocket.GetRocketId(), + "rocket_level": rocket.GetLevel(), "rocket_cover_url": rocket.GetRocketCoverUrl(), - "launch_at_ms": rocket.GetLaunchAtMs(), - "top1_user_id": rocket.GetTop1UserId(), - "igniter_user_id": rocket.GetIgniterUserId(), + "launch_at_ms": rocket.GetLaunchAtMs(), + "top1_user_id": rocket.GetTop1UserId(), + "igniter_user_id": rocket.GetIgniterUserId(), "igniter": map[string]any{ "user_id": fmt.Sprintf("%d", rocket.GetIgniterUserId()), "short_id": strings.TrimSpace(igniter.Account), @@ -689,8 +689,8 @@ func roomRocketRewardPayload(envelope *roomeventsv1.EventEnvelope, event *roomev userID := reward.GetUserId() profile := profiles[userID] rewards = append(rewards, map[string]any{ - "reward_role": reward.GetRewardRole(), - "user_id": fmt.Sprintf("%d", userID), + "reward_role": reward.GetRewardRole(), + "user_id": fmt.Sprintf("%d", userID), "user": map[string]any{ "user_id": fmt.Sprintf("%d", userID), "short_id": strings.TrimSpace(profile.Account), diff --git a/services/gateway-service/internal/transport/http/roomapi/room_view.go b/services/gateway-service/internal/transport/http/roomapi/room_view.go index 094097d6..c36b1486 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_view.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_view.go @@ -199,19 +199,19 @@ type roomRocketRewardData struct { } type roomRocketStateData struct { - CurrentLevel int32 `json:"current_level"` - CurrentFuel int64 `json:"current_fuel"` - FuelThreshold int64 `json:"fuel_threshold"` - Status string `json:"status"` - IgnitedAtMS int64 `json:"ignited_at_ms,omitempty"` - LaunchAtMS int64 `json:"launch_at_ms,omitempty"` - LaunchedAtMS int64 `json:"launched_at_ms,omitempty"` - ResetAtMS int64 `json:"reset_at_ms"` - Top1UserID string `json:"top1_user_id,omitempty"` - IgniterUserID string `json:"igniter_user_id,omitempty"` - RocketID string `json:"rocket_id,omitempty"` - ConfigVersion int64 `json:"config_version,omitempty"` - LastRewards []roomRocketRewardGrantData `json:"last_rewards,omitempty"` + CurrentLevel int32 `json:"current_level"` + CurrentFuel int64 `json:"current_fuel"` + FuelThreshold int64 `json:"fuel_threshold"` + Status string `json:"status"` + IgnitedAtMS int64 `json:"ignited_at_ms,omitempty"` + LaunchAtMS int64 `json:"launch_at_ms,omitempty"` + LaunchedAtMS int64 `json:"launched_at_ms,omitempty"` + ResetAtMS int64 `json:"reset_at_ms"` + Top1UserID string `json:"top1_user_id,omitempty"` + IgniterUserID string `json:"igniter_user_id,omitempty"` + RocketID string `json:"rocket_id,omitempty"` + ConfigVersion int64 `json:"config_version,omitempty"` + LastRewards []roomRocketRewardGrantData `json:"last_rewards,omitempty"` PendingLaunches []roomRocketPendingLaunchData `json:"pending_launches,omitempty"` } @@ -225,7 +225,7 @@ type roomRocketPendingLaunchData struct { Top1UserID string `json:"top1_user_id,omitempty"` IgniterUserID string `json:"igniter_user_id,omitempty"` ConfigVersion int64 `json:"config_version,omitempty"` - CoverURL string `json:"cover_url,omitempty"` + CoverURL string `json:"cover_url,omitempty"` } type roomRocketRewardGrantData struct { @@ -460,19 +460,19 @@ func roomRocketStateFromProto(state *roomv1.RoomRocketState) roomRocketStateData return roomRocketStateData{} } return roomRocketStateData{ - CurrentLevel: state.GetCurrentLevel(), - CurrentFuel: state.GetCurrentFuel(), - FuelThreshold: state.GetFuelThreshold(), - Status: state.GetStatus(), - IgnitedAtMS: state.GetIgnitedAtMs(), - LaunchAtMS: state.GetLaunchAtMs(), - LaunchedAtMS: state.GetLaunchedAtMs(), - ResetAtMS: state.GetResetAtMs(), - Top1UserID: formatOptionalUserID(state.GetTop1UserId()), - IgniterUserID: formatOptionalUserID(state.GetIgniterUserId()), - RocketID: state.GetRocketId(), - ConfigVersion: state.GetConfigVersion(), - LastRewards: roomRocketRewardGrantsFromProto(state.GetLastRewards()), + CurrentLevel: state.GetCurrentLevel(), + CurrentFuel: state.GetCurrentFuel(), + FuelThreshold: state.GetFuelThreshold(), + Status: state.GetStatus(), + IgnitedAtMS: state.GetIgnitedAtMs(), + LaunchAtMS: state.GetLaunchAtMs(), + LaunchedAtMS: state.GetLaunchedAtMs(), + ResetAtMS: state.GetResetAtMs(), + Top1UserID: formatOptionalUserID(state.GetTop1UserId()), + IgniterUserID: formatOptionalUserID(state.GetIgniterUserId()), + RocketID: state.GetRocketId(), + ConfigVersion: state.GetConfigVersion(), + LastRewards: roomRocketRewardGrantsFromProto(state.GetLastRewards()), PendingLaunches: roomRocketPendingLaunchesFromProto(state.GetPendingLaunches()), } } diff --git a/services/room-service/configs/config.docker.yaml b/services/room-service/configs/config.docker.yaml index 799a8dbc..b7651a41 100644 --- a/services/room-service/configs/config.docker.yaml +++ b/services/room-service/configs/config.docker.yaml @@ -67,11 +67,11 @@ rocketmq: tencent_im_consumer_enabled: true tencent_im_consumer_group: "hyapp-room-im-bridge" consumer_max_reconsume_times: 16 - rocket_open: + rocket_launch: enabled: false topic: "hyapp_room_rocket_launch" - producer_group: "hyapp-room-rocket-open-producer" - consumer_group: "hyapp-room-rocket-open" + producer_group: "hyapp-room-rocket-launch-producer" + consumer_group: "hyapp-room-rocket-launch" consumer_max_reconsume_times: 16 outbox_worker: # Docker 和 testbox 走 MQ;room-service 同进程启动 IM bridge consumer 补偿房间群消息。 diff --git a/services/room-service/configs/config.tencent.example.yaml b/services/room-service/configs/config.tencent.example.yaml index 0a136145..55b3cb9e 100644 --- a/services/room-service/configs/config.tencent.example.yaml +++ b/services/room-service/configs/config.tencent.example.yaml @@ -77,11 +77,11 @@ rocketmq: tencent_im_consumer_enabled: true tencent_im_consumer_group: "hyapp-room-im-bridge" consumer_max_reconsume_times: 16 - rocket_open: + rocket_launch: enabled: true topic: "hyapp_room_rocket_launch" - producer_group: "hyapp-room-rocket-open-producer" - consumer_group: "hyapp-room-rocket-open" + producer_group: "hyapp-room-rocket-launch-producer" + consumer_group: "hyapp-room-rocket-launch" consumer_max_reconsume_times: 16 outbox_worker: # mq 模式下 outbox worker 只把事实投 MQ,并顺带安排火箭延迟发射唤醒。 diff --git a/services/room-service/configs/config.yaml b/services/room-service/configs/config.yaml index 6789596d..a6b42dff 100644 --- a/services/room-service/configs/config.yaml +++ b/services/room-service/configs/config.yaml @@ -70,11 +70,11 @@ rocketmq: tencent_im_consumer_enabled: false tencent_im_consumer_group: "hyapp-room-im-bridge" consumer_max_reconsume_times: 16 - rocket_open: + rocket_launch: enabled: false topic: "hyapp_room_rocket_launch" - producer_group: "hyapp-room-rocket-open-producer" - consumer_group: "hyapp-room-rocket-open" + producer_group: "hyapp-room-rocket-launch-producer" + consumer_group: "hyapp-room-rocket-launch" consumer_max_reconsume_times: 16 outbox_worker: # room_outbox 是腾讯云 IM 和 activity-service 的异步投递通道;失败退避重试,超过上限转死信。 diff --git a/services/room-service/internal/config/config.go b/services/room-service/internal/config/config.go index d69dd2dd..b495b8b3 100644 --- a/services/room-service/internal/config/config.go +++ b/services/room-service/internal/config/config.go @@ -123,7 +123,7 @@ type RocketMQConfig struct { SendTimeout time.Duration `yaml:"send_timeout"` Retry int `yaml:"retry"` RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"` - RocketLaunch RocketLaunchMQConfig `yaml:"rocket_open"` + RocketLaunch RocketLaunchMQConfig `yaml:"rocket_launch"` } // RoomOutboxMQConfig 控制 room_outbox fanout topic。 @@ -302,8 +302,8 @@ func defaultRocketMQConfig() RocketMQConfig { RocketLaunch: RocketLaunchMQConfig{ Enabled: false, Topic: "hyapp_room_rocket_launch", - ProducerGroup: "hyapp-room-rocket-open-producer", - ConsumerGroup: "hyapp-room-rocket-open", + ProducerGroup: "hyapp-room-rocket-launch-producer", + ConsumerGroup: "hyapp-room-rocket-launch", ConsumerMaxReconsumeTimes: 16, }, } diff --git a/services/room-service/internal/config/config_test.go b/services/room-service/internal/config/config_test.go index e71868c2..72a806ec 100644 --- a/services/room-service/internal/config/config_test.go +++ b/services/room-service/internal/config/config_test.go @@ -71,7 +71,7 @@ func TestLoadTencentExample(t *testing.T) { t.Fatalf("tencent example must configure outbox worker: %+v", cfg.OutboxWorker) } if cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || !cfg.RocketMQ.RoomOutbox.Enabled || !cfg.RocketMQ.RocketLaunch.Enabled { - t.Fatalf("tencent example must route room outbox and rocket open through RocketMQ: mode=%s mq=%+v", cfg.OutboxWorker.PublishMode, cfg.RocketMQ) + t.Fatalf("tencent example must route room outbox and rocket launch through RocketMQ: mode=%s mq=%+v", cfg.OutboxWorker.PublishMode, cfg.RocketMQ) } } diff --git a/services/room-service/internal/integration/clients_grpc.go b/services/room-service/internal/integration/clients_grpc.go index 3aadd92e..a2600081 100644 --- a/services/room-service/internal/integration/clients_grpc.go +++ b/services/room-service/internal/integration/clients_grpc.go @@ -36,7 +36,7 @@ func (c *grpcWalletClient) BatchDebitGift(ctx context.Context, req *walletv1.Bat // GrantResourceGroup 直接转调 wallet-service 资源组发放接口。 func (c *grpcWalletClient) GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) { - // 宝箱奖励以 resource group 为后台配置单位,wallet-service 负责展开资产和权益。 + // 火箭奖励以 resource group 为后台配置单位,wallet-service 负责展开资产和权益。 return c.client.GrantResourceGroup(ctx, req) } diff --git a/services/room-service/internal/integration/rocketmq_outbox.go b/services/room-service/internal/integration/rocketmq_outbox.go index 963d3ee3..2b84dc72 100644 --- a/services/room-service/internal/integration/rocketmq_outbox.go +++ b/services/room-service/internal/integration/rocketmq_outbox.go @@ -52,7 +52,7 @@ type RocketMQRocketLaunchScheduler struct { topic string } -// NewRocketMQRocketLaunchScheduler creates the delayed open scheduler. +// NewRocketMQRocketLaunchScheduler creates the delayed launch scheduler. func NewRocketMQRocketLaunchScheduler(producer *rocketmqx.Producer, topic string) *RocketMQRocketLaunchScheduler { return &RocketMQRocketLaunchScheduler{producer: producer, topic: topic} } diff --git a/services/room-service/internal/integration/tencent_im.go b/services/room-service/internal/integration/tencent_im.go index ead2452d..746b0c6e 100644 --- a/services/room-service/internal/integration/tencent_im.go +++ b/services/room-service/internal/integration/tencent_im.go @@ -302,10 +302,14 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room base.Attributes = map[string]string{ "rocket_id": body.GetRocketId(), "level": fmt.Sprintf("%d", body.GetLevel()), + "rocket_level": fmt.Sprintf("%d", body.GetLevel()), + "room_short_id": body.GetRoomShortId(), + "rocket_cover_url": body.GetRocketCoverUrl(), "current_fuel": fmt.Sprintf("%d", body.GetCurrentFuel()), "fuel_threshold": fmt.Sprintf("%d", body.GetFuelThreshold()), "ignited_at_ms": fmt.Sprintf("%d", body.GetIgnitedAtMs()), "launch_at_ms": fmt.Sprintf("%d", body.GetLaunchAtMs()), + "reset_at_ms": fmt.Sprintf("%d", body.GetResetAtMs()), "broadcast_scope": body.GetBroadcastScope(), "visible_region_id": fmt.Sprintf("%d", body.GetVisibleRegionId()), "top1_user_id": fmt.Sprintf("%d", body.GetTop1UserId()), @@ -336,20 +340,9 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room } return base, true, nil case "RoomRocketRewardGranted": - var body roomeventsv1.RoomRocketRewardGranted - if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { - return tencentim.RoomEvent{}, false, err - } - rewardsJSON, err := json.Marshal(body.GetRewards()) - if err != nil { - return tencentim.RoomEvent{}, false, err - } - base.Attributes = map[string]string{ - "rocket_id": body.GetRocketId(), - "level": fmt.Sprintf("%d", body.GetLevel()), - "rewards_json": string(rewardsJSON), - } - return base, true, nil + // 奖励 IM 需要头像、昵称和短 ID,room-service 不反查 user-service; + // activity-service 消费同一事实后做资料 enrichment 并投递房间播报。 + return tencentim.RoomEvent{}, false, nil default: // RoomCreated/HeatChanged/RankChanged 不单独推客户端,避免一条命令产生多条系统消息。 return tencentim.RoomEvent{}, false, nil diff --git a/services/room-service/internal/room/service/gift.go b/services/room-service/internal/room/service/gift.go index 81048079..5a2e9eb8 100644 --- a/services/room-service/internal/room/service/gift.go +++ b/services/room-service/internal/room/service/gift.go @@ -211,11 +211,11 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r records = append(records, progressEvent) } if rocketApply.ignited != nil { - countdownEvent, err := outbox.Build(current.RoomID, "RoomRocketIgnited", current.Version, now, rocketApply.ignited) + ignitedEvent, err := outbox.Build(current.RoomID, "RoomRocketIgnited", current.Version, now, rocketApply.ignited) if err != nil { return mutationResult{}, nil, err } - records = append(records, countdownEvent) + records = append(records, ignitedEvent) } return mutationResult{ diff --git a/services/room-service/internal/room/service/recovery.go b/services/room-service/internal/room/service/recovery.go index e2b06552..b129065f 100644 --- a/services/room-service/internal/room/service/recovery.go +++ b/services/room-service/internal/room/service/recovery.go @@ -419,17 +419,17 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64) func rocketStateFromSettledGift(cmd *command.SendGift) *state.RocketState { return &state.RocketState{ - CurrentLevel: cmd.RocketLevel, - CurrentFuel: cmd.RocketFuel, - FuelThreshold: cmd.RocketFuelThreshold, - Status: cmd.RocketStatus, - IgnitedAtMS: cmd.RocketIgnitedAtMS, - LaunchAtMS: cmd.RocketLaunchAtMS, - ResetAtMS: cmd.RocketResetAtMS, - Top1UserID: cmd.RocketTop1UserID, - IgniterUserID: cmd.RocketIgniterUserID, - RocketID: cmd.RocketID, - ConfigVersion: cmd.RocketConfigVersion, + CurrentLevel: cmd.RocketLevel, + CurrentFuel: cmd.RocketFuel, + FuelThreshold: cmd.RocketFuelThreshold, + Status: cmd.RocketStatus, + IgnitedAtMS: cmd.RocketIgnitedAtMS, + LaunchAtMS: cmd.RocketLaunchAtMS, + ResetAtMS: cmd.RocketResetAtMS, + Top1UserID: cmd.RocketTop1UserID, + IgniterUserID: cmd.RocketIgniterUserID, + RocketID: cmd.RocketID, + ConfigVersion: cmd.RocketConfigVersion, PendingLaunches: rocketPendingLaunchesFromCommand(cmd.RocketPendingLaunches), } } diff --git a/services/room-service/internal/room/service/repository.go b/services/room-service/internal/room/service/repository.go index 37b28a5b..16cbde3b 100644 --- a/services/room-service/internal/room/service/repository.go +++ b/services/room-service/internal/room/service/repository.go @@ -293,7 +293,7 @@ type GiftFuelRuleConfig struct { GiftID string `json:"giftId"` GiftTypeCode string `json:"giftTypeCode"` MultiplierPPM int64 `json:"multiplierPpm"` - FixedFuel int64 `json:"fixedFuel"` + FixedFuel int64 `json:"fixedFuel"` Excluded bool `json:"excluded"` } diff --git a/services/room-service/internal/room/service/room_rocket.go b/services/room-service/internal/room/service/room_rocket.go index 470552d7..c7373ca1 100644 --- a/services/room-service/internal/room/service/room_rocket.go +++ b/services/room-service/internal/room/service/room_rocket.go @@ -126,10 +126,6 @@ func normalizeRoomRocketConfig(config RoomRocketConfig) (RoomRocketConfig, error return RoomRocketConfig{}, xerr.New(xerr.InvalidArgument, "room rocket config_version is invalid") } config.FuelSource = defaultRoomRocketString(config.FuelSource, roomRocketDefaultFuelSource) - if config.FuelSource == "gift_point_added" { - // 旧配置可能还存着 gift_point_added;GIFT_POINT 已下线,运行时统一按真实送礼贡献 heat_value 计算火箭燃料。 - config.FuelSource = roomRocketFuelHeatValue - } if config.FuelSource != roomRocketFuelHeatValue { return RoomRocketConfig{}, xerr.New(xerr.InvalidArgument, "room rocket fuel_source is invalid") } @@ -247,20 +243,20 @@ func roomRocketProtoStateForView(current *roomv1.RoomRocketState, config RoomRoc internal := state.RocketState{} if current != nil { internal = state.RocketState{ - CurrentLevel: current.GetCurrentLevel(), - CurrentFuel: current.GetCurrentFuel(), - FuelThreshold: current.GetFuelThreshold(), - Status: current.GetStatus(), - IgnitedAtMS: current.GetIgnitedAtMs(), - LaunchAtMS: current.GetLaunchAtMs(), - LaunchedAtMS: current.GetLaunchedAtMs(), - ResetAtMS: current.GetResetAtMs(), - Top1UserID: current.GetTop1UserId(), - IgniterUserID: current.GetIgniterUserId(), - RocketID: current.GetRocketId(), - ConfigVersion: current.GetConfigVersion(), + CurrentLevel: current.GetCurrentLevel(), + CurrentFuel: current.GetCurrentFuel(), + FuelThreshold: current.GetFuelThreshold(), + Status: current.GetStatus(), + IgnitedAtMS: current.GetIgnitedAtMs(), + LaunchAtMS: current.GetLaunchAtMs(), + LaunchedAtMS: current.GetLaunchedAtMs(), + ResetAtMS: current.GetResetAtMs(), + Top1UserID: current.GetTop1UserId(), + IgniterUserID: current.GetIgniterUserId(), + RocketID: current.GetRocketId(), + ConfigVersion: current.GetConfigVersion(), PendingLaunches: statePendingLaunchesFromProto(current.GetPendingLaunches()), - LastRewards: stateRewardsFromProto(current.GetLastRewards()), + LastRewards: stateRewardsFromProto(current.GetLastRewards()), } } normalized := rocketStateForNow(&internal, config, now, false) @@ -693,7 +689,7 @@ func rocketStateToEventRewards(input []state.RocketRewardGrant) []*roomeventsv1. return out } -// RunRoomRocketLaunchWorker 周期性扫描已装载房间,把到点的倒计时火箭走命令链路打开。 +// RunRoomRocketLaunchWorker 周期性扫描已装载房间,把到点的待发射火箭走命令链路发射。 func (s *Service) RunRoomRocketLaunchWorker(ctx context.Context, interval time.Duration) { if interval <= 0 { return @@ -849,7 +845,7 @@ func (s *Service) launchRoomRocket(ctx context.Context, cmd command.LaunchRoomRo current.Version++ - openedEvent, err := outbox.Build(current.RoomID, "RoomRocketLaunched", current.Version, now, &roomeventsv1.RoomRocketLaunched{ + launchedEvent, err := outbox.Build(current.RoomID, "RoomRocketLaunched", current.Version, now, &roomeventsv1.RoomRocketLaunched{ RocketId: pending.RocketID, Level: pending.Level, NextLevel: current.Rocket.CurrentLevel, @@ -863,7 +859,7 @@ func (s *Service) launchRoomRocket(ctx context.Context, cmd command.LaunchRoomRo if err != nil { return mutationResult{}, nil, err } - records := []outbox.Record{openedEvent} + records := []outbox.Record{launchedEvent} if len(rewards) > 0 { rewardEvent, err := outbox.Build(current.RoomID, "RoomRocketRewardGranted", current.Version, now, &roomeventsv1.RoomRocketRewardGranted{ RocketId: pending.RocketID, diff --git a/services/room-service/internal/room/service/room_rocket_test.go b/services/room-service/internal/room/service/room_rocket_test.go index 6ed40ebc..661f8e2f 100644 --- a/services/room-service/internal/room/service/room_rocket_test.go +++ b/services/room-service/internal/room/service/room_rocket_test.go @@ -175,13 +175,13 @@ func (r *rocketLaunchScheduleRecorder) ScheduleRoomRocketLaunch(_ context.Contex return nil } -func TestRoomRocketGiftOverflowAndIgnitedInvalidEnergy(t *testing.T) { +func TestRoomRocketGiftOverflowAndImmediateNextLevel(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)} wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{ - {BillingReceiptId: "receipt-fill", GiftPointAdded: 150, HeatValue: 150, GiftTypeCode: "normal"}, - {BillingReceiptId: "receipt-ignited", GiftPointAdded: 200, HeatValue: 200, GiftTypeCode: "normal"}, + {BillingReceiptId: "receipt-fill", GiftPointAdded: 250, HeatValue: 250, GiftTypeCode: "normal"}, + {BillingReceiptId: "receipt-next", GiftPointAdded: 50, HeatValue: 50, GiftTypeCode: "normal"}, }} svc := newRocketTestService(t, repository, wallet, now) writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000)) @@ -203,46 +203,60 @@ func TestRoomRocketGiftOverflowAndIgnitedInvalidEnergy(t *testing.T) { t.Fatalf("send fill gift failed: %v", err) } rocket := fillResp.GetRocket() - if rocket.GetCurrentLevel() != 1 || rocket.GetCurrentFuel() != 100 || rocket.GetStatus() != state.RocketStatusIgnited { - t.Fatalf("fill gift must cap level 1 progress and enter ignited: %+v", rocket) + if rocket.GetCurrentLevel() != 2 || rocket.GetCurrentFuel() != 0 || rocket.GetStatus() != state.RocketStatusCharging { + t.Fatalf("fill gift must ignite level 1 and immediately start level 2: %+v", rocket) } - if rocket.GetLaunchAtMs() != now.Now().Add(time.Second).UnixMilli() || rocket.GetResetAtMs() != time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC).UnixMilli() { + if len(rocket.GetPendingLaunches()) != 1 { + t.Fatalf("filled level must create exactly one pending launch: %+v", rocket) + } + pending := rocket.GetPendingLaunches()[0] + if pending.GetLevel() != 1 || pending.GetFuelThreshold() != 100 || pending.GetLaunchAtMs() != now.Now().Add(time.Second).UnixMilli() { + t.Fatalf("pending launch must lock the filled level snapshot: %+v", pending) + } + if rocket.GetResetAtMs() != time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC).UnixMilli() { t.Fatalf("rocket timers must use UTC business time: %+v", rocket) } - if rocket.GetTop1UserId() != ownerID || rocket.GetIgniterUserId() != ownerID { - t.Fatalf("top1 and igniter must lock at ignited start: %+v", rocket) + if pending.GetTop1UserId() != ownerID || pending.GetIgniterUserId() != ownerID { + t.Fatalf("top1 and igniter must lock at ignited start: %+v", pending) } progressEvents := rocketProgressEvents(t, ctx, repository) - if len(progressEvents) != 1 || progressEvents[0].GetAddedFuel() != 150 || progressEvents[0].GetEffectiveAddedFuel() != 100 || progressEvents[0].GetOverflowFuel() != 50 { - t.Fatalf("progress event must expose effective energy and discarded overflow: %+v", progressEvents) + if len(progressEvents) != 1 || progressEvents[0].GetAddedFuel() != 250 || progressEvents[0].GetEffectiveAddedFuel() != 100 || progressEvents[0].GetOverflowFuel() != 150 { + t.Fatalf("progress event must expose effective fuel and discarded overflow: %+v", progressEvents) + } + if progressEvents[0].GetLevel() != 1 || progressEvents[0].GetStatus() != state.RocketStatusIgnited { + t.Fatalf("progress event must describe the filled level, got %+v", progressEvents[0]) } if got := countRocketIgnitedEvents(t, ctx, repository); got != 1 { t.Fatalf("fill gift must emit one ignited event, got %d", got) } - countdownResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ - Meta: rocketMeta(roomID, viewerID, "ignited"), + nextResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ + Meta: rocketMeta(roomID, viewerID, "next-level"), TargetType: "user", TargetUserId: ownerID, - GiftId: "gift-during-ignited", + GiftId: "gift-next-level", GiftCount: 1, }) if err != nil { - t.Fatalf("send ignited gift failed: %v", err) + t.Fatalf("send next-level gift failed: %v", err) } - if countdownResp.GetRocket().GetCurrentFuel() != 100 || countdownResp.GetRocket().GetStatus() != state.RocketStatusIgnited { - t.Fatalf("ignited gift must not change rocket progress: %+v", countdownResp.GetRocket()) + if nextResp.GetRocket().GetCurrentLevel() != 2 || nextResp.GetRocket().GetCurrentFuel() != 50 || nextResp.GetRocket().GetStatus() != state.RocketStatusCharging { + t.Fatalf("gift after ignition must add fuel to the next level immediately: %+v", nextResp.GetRocket()) } - if got := len(rocketProgressEvents(t, ctx, repository)); got != 1 { - t.Fatalf("ignited invalid energy must not emit a second progress event, got %d", got) + if len(nextResp.GetRocket().GetPendingLaunches()) != 1 { + t.Fatalf("new progress must keep the previous pending launch only: %+v", nextResp.GetRocket()) + } + progressEvents = rocketProgressEvents(t, ctx, repository) + if len(progressEvents) != 2 || progressEvents[1].GetLevel() != 2 || progressEvents[1].GetEffectiveAddedFuel() != 50 || progressEvents[1].GetOverflowFuel() != 0 { + t.Fatalf("second progress event must target level 2: %+v", progressEvents) } if got := countRocketIgnitedEvents(t, ctx, repository); got != 1 { - t.Fatalf("ignited invalid energy must not emit another ignited event, got %d", got) + t.Fatalf("next-level partial gift must not emit another ignited event, got %d", got) } } -func TestRoomRocketIgnitedOutboxSchedulesDelayedOpen(t *testing.T) { +func TestRoomRocketIgnitedOutboxSchedulesDelayedLaunch(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)} @@ -270,11 +284,11 @@ func TestRoomRocketIgnitedOutboxSchedulesDelayedOpen(t *testing.T) { t.Fatalf("process outbox failed: %v", err) } if len(scheduler.schedules) != 1 { - t.Fatalf("scheduled opens=%d, want 1: %+v", len(scheduler.schedules), scheduler.schedules) + t.Fatalf("scheduled launches=%d, want 1: %+v", len(scheduler.schedules), scheduler.schedules) } scheduled := scheduler.schedules[0] - if scheduled.RoomID != roomID || scheduled.Level != 1 || scheduled.LaunchAtMS != now.Now().Add(time.Second).UnixMilli() || scheduled.CommandID == "" { - t.Fatalf("unexpected delayed open schedule: %+v", scheduled) + if scheduled.RoomID != roomID || scheduled.Level != 1 || scheduled.LaunchAtMS != now.Now().Add(time.Second).UnixMilli() || scheduled.ResetAtMS == 0 || scheduled.CommandID == "" { + t.Fatalf("unexpected delayed launch schedule: %+v", scheduled) } } @@ -288,7 +302,7 @@ func TestRoomRocketLaunchDueEarlyMessageDoesNotPoisonCommandID(t *testing.T) { svc := newRocketTestService(t, repository, wallet, now) writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000)) - roomID := "room-rocket-mq-open" + roomID := "room-rocket-mq-launch" ownerID := int64(901) createRocketRoom(t, ctx, svc, roomID, ownerID, 9009) resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ @@ -302,14 +316,19 @@ func TestRoomRocketLaunchDueEarlyMessageDoesNotPoisonCommandID(t *testing.T) { t.Fatalf("ignite rocket failed: %v", err) } rocket := resp.GetRocket() + if len(rocket.GetPendingLaunches()) != 1 { + t.Fatalf("ignite should create one pending launch: %+v", rocket) + } + pending := rocket.GetPendingLaunches()[0] due := roommq.RoomRocketLaunchDueMessage{ MessageType: roommq.MessageTypeRoomRocketLaunchDue, AppCode: appcode.Default, RoomID: roomID, - RocketID: rocket.GetRocketId(), - Level: rocket.GetCurrentLevel(), - LaunchAtMS: rocket.GetLaunchAtMs(), - CommandID: "cmd_room_rocket_launch_" + rocket.GetRocketId(), + RocketID: pending.GetRocketId(), + Level: pending.GetLevel(), + LaunchAtMS: pending.GetLaunchAtMs(), + ResetAtMS: pending.GetResetAtMs(), + CommandID: "cmd_room_rocket_launch_" + pending.GetRocketId(), } if err := svc.HandleRoomRocketLaunchDue(ctx, due); err == nil { @@ -317,7 +336,7 @@ func TestRoomRocketLaunchDueEarlyMessageDoesNotPoisonCommandID(t *testing.T) { } now.now = now.now.Add(time.Second) if err := svc.HandleRoomRocketLaunchDue(ctx, due); err != nil { - t.Fatalf("due delayed message should open rocket after retry: %v", err) + t.Fatalf("due delayed message should launch rocket after retry: %v", err) } info, err := svc.GetRoomRocket(ctx, &roomv1.GetRoomRocketRequest{ RoomId: roomID, @@ -330,11 +349,11 @@ func TestRoomRocketLaunchDueEarlyMessageDoesNotPoisonCommandID(t *testing.T) { if err != nil { t.Fatalf("get rocket failed: %v", err) } - if info.GetRocket().GetState().GetStatus() != state.RocketStatusCharging || info.GetRocket().GetState().GetCurrentLevel() != 2 { - t.Fatalf("rocket should open once after early retry: %+v", info.GetRocket().GetState()) + if info.GetRocket().GetState().GetStatus() != state.RocketStatusCharging || info.GetRocket().GetState().GetCurrentLevel() != 2 || len(info.GetRocket().GetState().GetPendingLaunches()) != 0 { + t.Fatalf("rocket should launch once after early retry: %+v", info.GetRocket().GetState()) } if len(wallet.grants) == 0 { - t.Fatal("open should grant configured rewards after retry") + t.Fatal("launch should grant configured rewards after retry") } } @@ -348,7 +367,7 @@ func TestRoomRocketLaunchGrantsLockedTop1AndIgniterAfterLeave(t *testing.T) { svc := newRocketTestService(t, repository, wallet, now) writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000)) - roomID := "room-rocket-open" + roomID := "room-rocket-launch" ownerID := int64(301) igniterID := int64(302) createRocketRoom(t, ctx, svc, roomID, ownerID, 9002) @@ -369,7 +388,7 @@ func TestRoomRocketLaunchGrantsLockedTop1AndIgniterAfterLeave(t *testing.T) { now.now = now.now.Add(1100 * time.Millisecond) if err := svc.SweepRoomRocketLaunchings(ctx); err != nil { - t.Fatalf("sweep room rocket openings failed: %v", err) + t.Fatalf("sweep room rocket launches failed: %v", err) } if !hasRocketGrant(wallet.grants, igniterID, 2001) || !hasRocketGrant(wallet.grants, igniterID, 3001) { @@ -388,14 +407,14 @@ func TestRoomRocketLaunchGrantsLockedTop1AndIgniterAfterLeave(t *testing.T) { ViewerUserId: ownerID, }) if err != nil { - t.Fatalf("get rocket after open failed: %v", err) + t.Fatalf("get rocket after launch failed: %v", err) } stateView := info.GetRocket().GetState() - if stateView.GetCurrentLevel() != 2 || stateView.GetStatus() != state.RocketStatusCharging { - t.Fatalf("opened rocket must advance to next idle level: %+v", stateView) + if stateView.GetCurrentLevel() != 2 || stateView.GetStatus() != state.RocketStatusCharging || len(stateView.GetPendingLaunches()) != 0 { + t.Fatalf("launch rocket must keep current next-level progress and clear pending launch: %+v", stateView) } if len(stateView.GetLastRewards()) != 3 { - t.Fatalf("opened rocket must retain in-room, top1 and igniter grants for client display: %+v", stateView.GetLastRewards()) + t.Fatalf("launch rocket must retain in-room, top1 and igniter grants for client display: %+v", stateView.GetLastRewards()) } if got := rocketRewardUserIDs(stateView.GetLastRewards()); !slices.Equal(got, []int64{301, 302, 302}) { t.Fatalf("reward users mismatch: got %+v rewards=%+v", got, stateView.GetLastRewards()) @@ -429,8 +448,8 @@ func TestRoomRocketUTCResetCancelsCrossDayIgnited(t *testing.T) { if err != nil { t.Fatalf("send cross-day gift failed: %v", err) } - if fillResp.GetRocket().GetStatus() != state.RocketStatusIgnited || fillResp.GetRocket().GetLaunchAtMs() <= fillResp.GetRocket().GetResetAtMs() { - t.Fatalf("fixture must create a ignited whose open time crosses UTC reset: %+v", fillResp.GetRocket()) + if len(fillResp.GetRocket().GetPendingLaunches()) != 1 || fillResp.GetRocket().GetPendingLaunches()[0].GetLaunchAtMs() <= fillResp.GetRocket().GetResetAtMs() { + t.Fatalf("fixture must create a pending launch whose launch time crosses UTC reset: %+v", fillResp.GetRocket()) } now.now = time.Date(2026, 5, 21, 0, 0, 2, 0, time.UTC) @@ -486,16 +505,16 @@ func newRocketTestServiceWithScheduler(t *testing.T, repository *mysqltest.Repos }, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) } -func rocketConfigForTest(threshold int64, openDelayMS int64) roomservice.RoomRocketConfig { - levels := make([]roomservice.RoomRocketLevelConfig, 0, 7) - for level := int32(1); level <= 7; level++ { +func rocketConfigForTest(threshold int64, launchDelayMS int64) roomservice.RoomRocketConfig { + levels := make([]roomservice.RoomRocketLevelConfig, 0, 5) + for level := int32(1); level <= 5; level++ { levels = append(levels, roomservice.RoomRocketLevelConfig{ Level: level, FuelThreshold: threshold + int64(level-1)*100, CoverURL: fmt.Sprintf("https://example.test/rocket/%d-cover.png", level), AnimationURL: fmt.Sprintf("https://example.test/rocket/%d-idle.webp", level), - LaunchAnimationURL: fmt.Sprintf("https://example.test/rocket/%d-opening.webp", level), - LaunchedImageURL: fmt.Sprintf("https://example.test/rocket/%d-open.png", level), + LaunchAnimationURL: fmt.Sprintf("https://example.test/rocket/%d-launch.webp", level), + LaunchedImageURL: fmt.Sprintf("https://example.test/rocket/%d-launched.png", level), InRoomRewards: []roomservice.RoomRocketRewardItem{{ RewardItemID: fmt.Sprintf("level-%d-in-room", level), ResourceGroupID: 1000 + int64(level), @@ -523,8 +542,8 @@ func rocketConfigForTest(threshold int64, openDelayMS int64) roomservice.RoomRoc AppCode: appcode.Default, Enabled: true, ConfigVersion: 1, - FuelSource: "gift_point_added", - LaunchDelayMS: openDelayMS, + FuelSource: "heat_value", + LaunchDelayMS: launchDelayMS, BroadcastEnabled: true, BroadcastScope: "region", RewardStackPolicy: "allow_stack", diff --git a/services/room-service/internal/room/state/state.go b/services/room-service/internal/room/state/state.go index bfe84bfb..526aa5a7 100644 --- a/services/room-service/internal/room/state/state.go +++ b/services/room-service/internal/room/state/state.go @@ -166,7 +166,7 @@ type RocketPendingLaunch struct { IgniterUserID int64 // ConfigVersion 锁定本枚火箭的配置版本,CoverURL 给区域飘屏和客户端展示使用。 ConfigVersion int64 - CoverURL string + CoverURL string } // RocketState 保存 Room Cell 内当前火箭进度和最近一次发射结果。