Configure room rocket launch messaging

This commit is contained in:
zhx 2026-06-05 19:56:13 +08:00
parent 3417ee44ef
commit 9001fce4b7
34 changed files with 556 additions and 1125 deletions

View File

@ -7,7 +7,7 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
核心边界: 核心边界:
- `gateway-service` 是客户端 HTTP JSON 入口,负责鉴权、协议转换、签发腾讯云 IM UserSig以及调用内部 gRPC 服务。 - `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` 同步调用它。 - `wallet-service` 当前提供 `DebitGift` 送礼扣费语义,余额、流水和幂等记录落 MySQL`room-service``SendGift` 同步调用它。
- `user-service` 当前承接登录、三方注册、密码、refresh session、用户主数据、默认短号和临时靓号基础能力。 - `user-service` 当前承接登录、三方注册、密码、refresh session、用户主数据、默认短号和临时靓号基础能力。
- `activity-service` 当前承接每日任务、活动/系统消息基础能力和房间事件 consumer可通过 room outbox direct gRPC 或 RocketMQ `hyapp_room_outbox` 独立 consumer group 消费房间事实。 - `activity-service` 当前承接每日任务、活动/系统消息基础能力和房间事件 consumer可通过 room outbox direct gRPC 或 RocketMQ `hyapp_room_outbox` 独立 consumer group 消费房间事实。
@ -143,7 +143,7 @@ Authorization: Bearer <access_token>
- `gateway-service` 创建房间时从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id``room-service` 由该 actor 收敛 owner外部 CreateRoom 请求体不接收 `owner_user_id` - `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` 由前端按用户动作生成,用于服务端幂等。 - `X-Request-ID`/`request_id` 由后端生成,只做链路追踪;写操作的 `command_id` 由前端按用户动作生成,用于服务端幂等。
- `room-service``CreateRoom` 成功后写 `room_outbox`,由 outbox worker 异步创建或补偿同名腾讯云 IM 群组。 - `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 单聊。 - `notice-service` 消费钱包余额变更 outbox 和房间踢人 outbox向单个用户发送 `wallet_notice``room_notice` 私有自定义消息owner service 不直接调用腾讯云 IM 单聊。
- `gateway-service` 已提供 `/api/v1/tencent-im/callback`,支持腾讯云 IM 入群前和发言前回调,并分别回查 `VerifyRoomPresence``CheckSpeakPermission` - `gateway-service` 已提供 `/api/v1/tencent-im/callback`,支持腾讯云 IM 入群前和发言前回调,并分别回查 `VerifyRoomPresence``CheckSpeakPermission`
@ -183,7 +183,7 @@ docs/flutter对接/Flutter App 通知与钱包余额对接.md
| Topic | Producer | Consumer Group | 用途 | | 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-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-room-im-bridge` | room-service IM bridge 消费房间群系统消息和群成员控制事实 |
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-user-mictime-room-outbox` | user-service 消费 `RoomMicChanged` 并更新用户麦时读模型 | | `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-notice-wallet-outbox` | notice-service 消费余额、账务等私有通知事实 |
| `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-statistics-wallet-outbox` | statistics-service 消费 `WalletRechargeRecorded` 并更新充值聚合 | | `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_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 ## Storage Model

View File

@ -1651,6 +1651,7 @@ type RoomRocketIgnited struct {
CommandId string `protobuf:"bytes,11,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` 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"` 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"` 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -1776,6 +1777,13 @@ func (x *RoomRocketIgnited) GetRocketCoverUrl() string {
return "" return ""
} }
func (x *RoomRocketIgnited) GetResetAtMs() int64 {
if x != nil {
return x.ResetAtMs
}
return 0
}
// RoomRocketLaunched 表达火箭已经发射,在线用户名单按发射瞬间 Room Cell presence 结算。 // RoomRocketLaunched 表达火箭已经发射,在线用户名单按发射瞬间 Room Cell presence 结算。
type RoomRocketLaunched struct { type RoomRocketLaunched struct {
state protoimpl.MessageState `protogen:"open.v1"` 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" + "gift_count\x18\f \x01(\x05R\tgiftCount\x12\x1d\n" +
"\n" + "\n" +
"command_id\x18\r \x01(\tR\tcommandId\x12*\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" + "\x11RoomRocketIgnited\x12\x1b\n" +
"\trocket_id\x18\x01 \x01(\tR\brocketId\x12\x14\n" + "\trocket_id\x18\x01 \x01(\tR\brocketId\x12\x14\n" +
"\x05level\x18\x02 \x01(\x05R\x05level\x12!\n" + "\x05level\x18\x02 \x01(\x05R\x05level\x12!\n" +
@ -2112,7 +2120,8 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"\n" + "\n" +
"command_id\x18\v \x01(\tR\tcommandId\x12\"\n" + "command_id\x18\v \x01(\tR\tcommandId\x12\"\n" +
"\rroom_short_id\x18\f \x01(\tR\vroomShortId\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" + "\x12RoomRocketLaunched\x12\x1b\n" +
"\trocket_id\x18\x01 \x01(\tR\brocketId\x12\x14\n" + "\trocket_id\x18\x01 \x01(\tR\brocketId\x12\x14\n" +
"\x05level\x18\x02 \x01(\x05R\x05level\x12\x1d\n" + "\x05level\x18\x02 \x01(\x05R\x05level\x12\x1d\n" +

View File

@ -935,7 +935,7 @@ func (x *RoomRocketLevel) GetIgniterRewards() []*RoomRocketRewardItem {
return nil return nil
} }
// RoomRocketRewardGrant 是一次火箭打开后给某个用户结算出的具体奖励。 // RoomRocketRewardGrant 是一次火箭发射后给某个用户结算出的具体奖励。
type RoomRocketRewardGrant struct { type RoomRocketRewardGrant struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
RewardRole string `protobuf:"bytes,1,opt,name=reward_role,json=rewardRole,proto3" json:"reward_role,omitempty"` RewardRole string `protobuf:"bytes,1,opt,name=reward_role,json=rewardRole,proto3" json:"reward_role,omitempty"`

View File

@ -124,7 +124,7 @@ message RoomRocketLevel {
repeated RoomRocketRewardItem igniter_rewards = 9; repeated RoomRocketRewardItem igniter_rewards = 9;
} }
// RoomRocketRewardGrant // RoomRocketRewardGrant
message RoomRocketRewardGrant { message RoomRocketRewardGrant {
string reward_role = 1; string reward_role = 1;
int64 user_id = 2; int64 user_id = 2;

View File

@ -38,7 +38,7 @@ docs/房间Outbox补偿开发.md
| 播报发送能力 | `activity-service` | 服务端向全局/区域播报群发送自定义消息 | | 播报发送能力 | `activity-service` | 服务端向全局/区域播报群发送自定义消息 |
| 旧区域群成员移除 | `activity-service` | 用户区域变化后,对旧区域群执行 `delete_group_member(user_id)` | | 旧区域群成员移除 | `activity-service` | 用户区域变化后,对旧区域群执行 `delete_group_member(user_id)` |
| 贵重礼物播报触发 | `activity-service` consuming room outbox | 基于 `RoomGiftSent` 事实判断阈值并发区域播报 | | 贵重礼物播报触发 | `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 只携带入口和展示字段 | | 红包资金事实 | `wallet-service` 或独立 red-packet domain | IM 只携带入口和展示字段 |
| 客户端 IM 长连接 | Tencent IM SDK | 本仓库不自建 WebSocket | | 客户端 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 ## Client Flow
@ -406,7 +406,7 @@ sequenceDiagram
## Room Rocket Broadcast ## Room Rocket Broadcast
火箭燃料满进入倒计时时Room Cell 写 `RoomRocketCountdownStarted` outbox。activity-service 消费该事实后按后台配置决定发区域播报还是全局播报;发射到点、奖励结算和火箭状态推进仍由 room-service 负责。 火箭燃料满进入倒计时时Room Cell 写 `RoomRocketIgnited` outbox。activity-service 消费该事实后按后台配置决定发区域播报还是全局播报;发射到点、奖励结算和火箭状态推进仍由 room-service 负责。
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
@ -421,11 +421,12 @@ sequenceDiagram
C->>G: POST /api/v1/rooms/gift/send C->>G: POST /api/v1/rooms/gift/send
G->>R: SendGift G->>R: SendGift
R->>W: DebitGift R->>W: DebitGift
W-->>R: receipt + heat/gift point W-->>R: receipt + heat_value
R->>R: add effective rocket energy R->>R: add effective rocket fuel
R->>R: status=countdown when threshold reached R->>R: append pending_launch when threshold reached
R->>R: start next level immediately
R-->>G: SendGiftResponse R-->>G: SendGiftResponse
R->>MQ: RoomRocketCountdownStarted R->>MQ: RoomRocketIgnited
MQ->>A: consume by hyapp-activity-room-outbox MQ->>A: consume by hyapp-activity-room-outbox
A->>IM: send_group_msg(region/global broadcast group) A->>IM: send_group_msg(region/global broadcast group)
``` ```
@ -434,15 +435,22 @@ sequenceDiagram
```json ```json
{ {
"event_id": "room_rocket:lalu_room_xxx:box_1", "event_id": "room_rocket:lalu_room_xxx:rocket_1",
"broadcast_type": "room_rocket", "broadcast_type": "room_rocket",
"scope": "region", "scope": "region",
"app_code": "lalu", "app_code": "lalu",
"region_id": 1001, "region_id": 1001,
"room_id": "lalu_room_xxx", "room_id": "lalu_room_xxx",
"box_id": "box_1", "rocket_id": "rocket_1",
"level": 3, "rocket_level": 3,
"open_at_ms": 1770000000000, "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": { "action": {
"type": "enter_room", "type": "enter_room",
"room_id": "lalu_room_xxx" "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 ## Red Packet Broadcast
@ -464,7 +472,7 @@ sequenceDiagram
| 过期退款 | wallet/red-packet domain + cron | | 过期退款 | wallet/red-packet domain + cron |
| IM 播报 | activity-service broadcast | | IM 播报 | activity-service broadcast |
红包创建成功后,红包领域写 `RedPacketCreated` 事件activity-service 转成区域/全局播报。客户端点击播报只打开红包入口,领取时必须调用后端接口,不能信任 IM payload 中的金额。 红包创建成功后,红包领域写 `RedPacketCreated` 事件activity-service 转成区域/全局播报。客户端点击播报只发射红包入口,领取时必须调用后端接口,不能信任 IM payload 中的金额。
## App Code And Region Safety ## App Code And Region Safety
@ -496,7 +504,7 @@ sequenceDiagram
5. `activity-service` 增加 broadcast group reconciler启动时确保全局和 active 区域群。 5. `activity-service` 增加 broadcast group reconciler启动时确保全局和 active 区域群。
6. `activity-service` 增加 `PublishRegionBroadcast/PublishGlobalBroadcast` 和 broadcast outbox。 6. `activity-service` 增加 `PublishRegionBroadcast/PublishGlobalBroadcast` 和 broadcast outbox。
7. 扩展 `RoomGiftSent` 事件字段activity-service 消费后按阈值发 `super_gift` 区域播报。 7. 扩展 `RoomGiftSent` 事件字段activity-service 消费后按阈值发 `super_gift` 区域播报。
8. 消费 `RoomRocketCountdownStarted`,按后台配置发 `room_rocket` 区域或全局播报。 8. 消费 `RoomRocketIgnited`,按后台配置发 `room_rocket` 区域或全局播报。
9. 红包领域落账务事实后,复用同一播报能力发送 `red_packet` 消息。 9. 红包领域落账务事实后,复用同一播报能力发送 `red_packet` 消息。
## Test Expectations ## Test Expectations
@ -513,5 +521,5 @@ sequenceDiagram
| group reconciler | 已存在群视为成功,失败可重试 | | group reconciler | 已存在群视为成功,失败可重试 |
| broadcast outbox | 同一 `event_id` 幂等,失败后 retry | | broadcast outbox | 同一 `event_id` 幂等,失败后 retry |
| super gift | 未达阈值不播报,达阈值发送到用户房间所在区域群 | | super gift | 未达阈值不播报,达阈值发送到用户房间所在区域群 |
| room rocket | 火箭燃料满后只按 `RoomRocketCountdownStarted` 播报一次;区域/全局 scope 来自后台配置 | | room rocket | 火箭燃料满后只按 `RoomRocketIgnited` 播报一次;区域/全局 scope 来自后台配置 |
| red packet | IM payload 不影响领取接口的后端金额校验 | | red packet | IM payload 不影响领取接口的后端金额校验 |

View File

@ -115,7 +115,7 @@ Content-Type: application/json
| `room_heat` | int64 | 送礼后的房间热度。 | | `room_heat` | int64 | 送礼后的房间热度。 |
| `gift_rank` | array | 当前房间礼物榜。 | | `gift_rank` | array | 当前房间礼物榜。 |
| `room` | object | 房间快照。 | | `room` | object | 房间快照。 |
| `treasure` | object | 房间宝箱状态。 | | `rocket` | object | 房间火箭状态。 |
| `lucky_gift` | object | 幸运礼物抽奖结果;普通礼物可为空。 | | `lucky_gift` | object | 幸运礼物抽奖结果;普通礼物可为空。 |
`lucky_gift` 返回字段: `lucky_gift` 返回字段:
@ -159,7 +159,7 @@ Content-Type: application/json
"room_heat": 123456, "room_heat": 123456,
"gift_rank": [], "gift_rank": [],
"room": {}, "room": {},
"treasure": {}, "rocket": {},
"lucky_gift": { "lucky_gift": {
"enabled": true, "enabled": true,
"draw_id": "lucky_draw_test", "draw_id": "lucky_draw_test",
@ -328,4 +328,3 @@ Content-Type: application/json
| `reward_status=pending` / `granting` | 到账处理中;等待钱包通知、刷新余额或后端补偿。 | | `reward_status=pending` / `granting` | 到账处理中;等待钱包通知、刷新余额或后端补偿。 |
| `reward_status=failed` | 展示异常状态,记录 `request_id/draw_id/command_id` 方便排查。 | | `reward_status=failed` | 展示异常状态,记录 `request_id/draw_id/command_id` 方便排查。 |
| HTTP 超时 | 保留原 `command_id` 重试,不生成新命令。 | | HTTP 超时 | 保留原 `command_id` 重试,不生成新命令。 |

View File

@ -20,11 +20,11 @@
1. 用户进入房间并完成 `JoinRoom` 1. 用户进入房间并完成 `JoinRoom`
2. App 可在启动或进入房间前调用 `GET /api/v1/gift-tabs` 预加载 Tab 配置。 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` 渲染收礼人。 4. 用 `tabs` 渲染当前房间分类,用 `gifts` 渲染礼物网格,用 `recipients` 渲染收礼人。
5. 用户选择收礼人、礼物和数量。 5. 用户选择收礼人、礼物和数量。
6. 点击发送时生成稳定 `command_id`,调用 `POST /api/v1/rooms/gift/send` 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`,同步播放礼物动效。 8. 监听房间 IM `room_gift_sent`,同步播放礼物动效。
9. 普通礼物和未中奖余额仍监听私有钱包通知或主动刷新余额,不本地扣减余额作为最终值。 9. 普通礼物和未中奖余额仍监听私有钱包通知或主动刷新余额,不本地扣减余额作为最终值。
@ -202,7 +202,7 @@ X-App-Code: lalu
| 字段 | 说明 | | 字段 | 说明 |
| --- | --- | | --- | --- |
| `coin_balance` | 当前登录用户 COIN 可用余额快照。只用于打开面板时展示,最终余额仍以后续钱包事实为准。 | | `coin_balance` | 当前登录用户 COIN 可用余额快照。只用于发射面板时展示,最终余额仍以后续钱包事实为准。 |
| `recipients` | 当前房间可收礼人。当前主流程只启用 `target_type=user` 的单个用户。 | | `recipients` | 当前房间可收礼人。当前主流程只启用 `target_type=user` 的单个用户。 |
| `tabs` | 礼物分类。`key=all` 是全量分类;其他 tab 通常对应 `gift_type_code`。 | | `tabs` | 礼物分类。`key=all` 是全量分类;其他 tab 通常对应 `gift_type_code`。 |
| `gifts` | 当前房间国家维度可用的 active 礼物列表。 | | `gifts` | 当前房间国家维度可用的 active 礼物列表。 |
@ -295,7 +295,7 @@ Content-Type: application/json
} }
], ],
"room": {}, "room": {},
"treasure": {}, "rocket": {},
"lucky_gift": { "lucky_gift": {
"enabled": true, "enabled": true,
"draw_id": "lucky_draw_test", "draw_id": "lucky_draw_test",
@ -329,7 +329,7 @@ Content-Type: application/json
| `room_heat` | 最新房间热度。 | | `room_heat` | 最新房间热度。 |
| `gift_rank` | 最新房间礼物榜。 | | `gift_rank` | 最新房间礼物榜。 |
| `room` | 房间快照字段较大App 可按已有房间快照解析逻辑局部更新。 | | `room` | 房间快照字段较大App 可按已有房间快照解析逻辑局部更新。 |
| `treasure` | 房间宝箱状态;宝箱 UI 以该字段或宝箱 IM 事件更新。 | | `rocket` | 房间火箭状态;火箭 UI 以该字段或火箭 IM 事件更新。 |
| `lucky_gift` | 幸运礼物抽奖结果;普通礼物可为空。`reward_status=granted` 且中奖时,`coin_balance_after` 是当前用户返奖入账后的余额。 | | `lucky_gift` | 幸运礼物抽奖结果;普通礼物可为空。`reward_status=granted` 且中奖时,`coin_balance_after` 是当前用户返奖入账后的余额。 |
发送后处理: 发送后处理:

View File

@ -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 地址:`GET /api/v1/rooms/{room_id}/rocket`
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 <access_token>`。 |
| `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 <access_token>
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 <access_token>
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
}
```
参数: 参数:
| 字段 | 类型 | 必填 | 说明 | | 参数 | 位置 | 必填 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `room_id` | string | 是 | 当前房间 ID。 | | `room_id` | path | 是 | 房间 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。 |
成功响应核心字段 返回值:
```json | 字段 | 说明 |
{ | --- | --- |
"code": "OK", | `enabled` | 火箭是否开启 |
"message": "ok", | `levels` | 1 到 5 级配置 |
"request_id": "req_xxx", | `levels[].fuel_threshold` | 等级燃料阈值 |
"data": { | `levels[].cover_url` | 火箭小封面图 |
"result": { | `levels[].animation_url` | 常态动效 |
"applied": true, | `levels[].launch_animation_url` | 发射动效 |
"room_version": 7, | `levels[].launched_image_url` | 发射后图 |
"server_time_ms": 1779259502200 | `levels[].in_room_rewards` | 在房奖励候选 |
}, | `levels[].top1_rewards` | 贡献第一奖励候选 |
"billing_receipt_id": "bill_xxx", | `levels[].igniter_rewards` | 点火人奖励候选 |
"room_heat": 500, | `state.current_level` | 当前正在累计的等级 |
"gift_rank": [ | `state.current_fuel` | 当前等级已累计燃料 |
{ | `state.fuel_threshold` | 当前等级阈值 |
"user_id": 12345678, | `state.status` | `charging``completed` |
"score": 500, | `state.rocket_id` | 当前正在累计火箭 ID |
"gift_value": 500, | `state.pending_launches` | 已点火待发射火箭列表 |
"updated_at_ms": 1779259502104 | `state.pending_launches[].rocket_id` | 待发射火箭 ID |
} | `state.pending_launches[].level` | 待发射等级 |
], | `state.pending_launches[].ignited_at_ms` | 点火 UTC 毫秒 |
"room": {}, | `state.pending_launches[].launch_at_ms` | 预计发射 UTC 毫秒 |
"rocket": { | `state.pending_launches[].top1_user_id` | 点火时锁定贡献第一 |
"current_level": 2, | `state.pending_launches[].igniter_user_id` | 点火人 |
"current_progress": 200, | `state.pending_launches[].cover_url` | 小封面图 |
"energy_threshold": 200, | `state.last_rewards` | 最近一次发射奖励结果 |
"status": "countdown", | `server_time_ms` | 服务端 UTC 毫秒 |
"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
}
}
}
```
字段说明: ## 送礼
| 字段 | 类型 | 说明 | 地址:`POST /api/v1/rooms/gift/send`
参数:
| 参数 | 必填 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `data.result.applied` | bool | 命令是否产生新状态;同一 `command_id` 重试可能返回历史结果。 | | `room_id` | 是 | 房间 ID |
| `data.result.room_version` | int64 | 房间版本,用于和 IM 消息去重、排序。 | | `command_id` | 是 | 客户端本次送礼幂等 ID |
| `data.billing_receipt_id` | string | 钱包扣费回执。 | | `target_type` | 否 | 默认 `user` |
| `data.room_heat` | int64 | 送礼后的房间热度。 | | `target_user_id` | 否 | 单收礼用户 ID |
| `data.gift_rank` | array | 送礼后的本地贡献榜快照。 | | `target_user_ids` | 否 | 多收礼用户 ID |
| `data.room` | object | 房间快照。 | | `gift_id` | 是 | 礼物 ID |
| `data.rocket` | object | 送礼后火箭状态;送礼人可立即用它刷新 UI。 | | `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`。 | | `result` | 命令提交结果 |
| 用户点了第二次送礼按钮 | 生成新的 `command_id`。 | | `billing_receipt_id` | 钱包扣费回执 |
| 同一个 `command_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` | | `enabled` | 是否开启 |
| `MsgContent.Ext` | `room_system_message` | | `fuelSource` | 固定为 `heat_value` |
| `MsgContent.Desc` | 等于 `Data.event_type` | | `launchDelayMs` | 点火后延迟发射毫秒 |
| `MsgContent.Data` | JSON 字符串 | | `broadcastEnabled` | 是否发区域或全局飘屏 |
| `CloudCustomData` | 与 `MsgContent.Data` 相同的 JSON 字符串 | | `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 去重键。 | | `enabled` | 是 | 是否开启 |
| `room_id` | string | 房间 ID。 | | `fuelSource` | 是 | 固定传 `heat_value` |
| `event_type` | string | 事件类型,见下表。 | | `launchDelayMs` | 是 | 点火后延迟发射毫秒 |
| `actor_user_id` | int64 | 触发用户;倒计时/发射通常为点火人。 | | `broadcastEnabled` | 是 | 是否发飘屏 |
| `target_user_id` | int64 | 目标用户;倒计时/发射通常为 top1。 | | `broadcastScope` | 是 | `region``global``none` |
| `room_version` | int64 | 房间版本,本地版本更高时丢弃旧消息。 | | `broadcastDelayMs` | 是 | 保留字段,当前点火后立即入 outbox |
| `attributes` | map<string,string> | 业务字段值全部按字符串传输Flutter 侧按需转 int/bool/JSON。 | | `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` | 用途 | ## 房间 IM
| --- | --- | --- |
| `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` | 发奖结果事件,包含奖励列表。 |
### 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` | | `TIMCustomElem.Ext` | `room_system_message` |
| `MsgContent.Ext` | `im_broadcast` | | `event_type` | 事件类型 |
| `MsgContent.Desc` | `room_rocket` | | `room_id` | 房间 ID |
| `MsgContent.Data` | JSON 字符串 | | `event_id` | 消息 ID |
| `CloudCustomData` | 与 `MsgContent.Data` 相同的 JSON 字符串 | | `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` | | `rocket_id` | 当前累计火箭 ID |
| `MsgContent.Desc` | `room_gift_sent` | | `level` | 当前累计等级 |
| `Data.event_type` | `room_gift_sent` | | `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<Map<String, dynamic>> 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>().map((item) {
return item.map((key, val) => MapEntry(key.toString(), val));
}).toList();
}
```
推荐本地状态更新策略:
| 输入 | 处理 |
| --- | --- | | --- | --- |
| `GET /rocket` | 覆盖本地完整火箭状态和 7 级物料。 | | `rocket_id` | 待发射火箭 ID |
| `POST /gift/send.data.rocket` | 送礼人立即刷新当前状态。 | | `level` | 待发射等级 |
| `room_rocket_progress_changed` | 同房间、版本不旧时更新进度。 | | `rocket_level` | 同 `level` |
| `room_rocket_countdown_started` | 切换倒计时,并锁定 top1/点火人展示。 | | `room_short_id` | 房间短号 |
| `room_rocket_opened` | 播放发射动效,状态切到下一等级 idle。 | | `rocket_cover_url` | 小封面图 |
| `room_rocket_reward_granted` | 展示当前用户中奖提示;按 `event_id` 去重。 | | `current_fuel` | 满阈值燃料 |
| `im_broadcast + room_rocket` | 展示区域/全局播报入口,不直接改当前房间状态。 | | `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。

View File

@ -267,7 +267,7 @@ rocketmq:
1. 钱包余额通知:已按 `walletnotice` 模块落地。 1. 钱包余额通知:已按 `walletnotice` 模块落地。
2. 房间踢人私有通知:已按 `roomnotice` 模块落地,只消费 `RoomUserKicked` 的 C2C 通知。 2. 房间踢人私有通知:已按 `roomnotice` 模块落地,只消费 `RoomUserKicked` 的 C2C 通知。
3. 幸运礼物中奖:仍由 `wallet-service` 写余额 outboxnotice 不需要新增特殊账务逻辑,只要客户端按 `balance_version` 更新。 3. 幸运礼物中奖:仍由 `wallet-service` 写余额 outboxnotice 不需要新增特殊账务逻辑,只要客户端按 `balance_version` 更新。
4. 语音房宝箱私有通知:如产品需要私聊提醒,可在 `roomnotice` 中消费 `RoomTreasureRewardGranted` 或由活动/系统消息模块转发;奖励入账事实仍由 room-service 调 wallet 完成notice 只投通知。 4. 语音房火箭私有通知:如产品需要私聊提醒,可在 `roomnotice` 中消费 `RoomRocketRewardGranted` 或由活动/系统消息模块转发;奖励入账事实仍由 room-service 调 wallet 完成notice 只投通知。
5. 活动/系统私有通知:新增 `activitynotice` 模块,读取 activity outbox复用 `notice_delivery_events` 或新增同语义位点表。 5. 活动/系统私有通知:新增 `activitynotice` 模块,读取 activity outbox复用 `notice_delivery_events` 或新增同语义位点表。
6. 站内 inbox新增 inbox 模块,写 notice-service 自己的 inbox 表,再按客户端分页接口读取。 6. 站内 inbox新增 inbox 模块,写 notice-service 自己的 inbox 表,再按客户端分页接口读取。
7. Push新增 push 模块,和 IM 投递使用不同 `channel`,独立重试和死信。 7. Push新增 push 模块,和 IM 投递使用不同 `channel`,独立重试和死信。

View File

@ -18,7 +18,7 @@
| `room-service` | stale presence cleanup | `services/room-service/internal/room/service/presence.go` | 留在 `room-service` | | `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` | 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 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` | region rebuild task | `UserCronService.ProcessRegionRebuildBatch` | 已迁移到 cron-service 调度,旧内置 ticker 已删除 |
| `user-service` | login IP risk job | `UserCronService.ProcessLoginIPRiskBatch` | 已迁移到 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 或事件总线 | | `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 执行命令链路 | | Room Cell stale presence cleanup | 依赖本节点已装载 Room Cell 和 Redis lease必须由 `room-service` owner 执行命令链路 |
| Mic publish timeout cleanup | 必须对当前 Room Cell 状态做二次校验并执行 `MicDown`,不能让 cron 直接清麦 | | Mic publish timeout cleanup | 必须对当前 Room Cell 状态做二次校验并执行 `MicDown`,不能让 cron 直接清麦 |
| room outbox relay | 这是 room-service 的事件投递补偿,不是通用定时任务;需要和 direct publisher、RocketMQ 发布、outbox 状态语义绑定 | | 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 ledger transaction | 金币、钻石、积分、余额的加减和冻结必须在 `wallet-service` 事务内完成 |
| wallet outbox publish | 属于 wallet-service outbox relay 或 MQ 投递,不应该由通用 cron 直接扫表改状态 | | wallet outbox publish | 属于 wallet-service outbox relay 或 MQ 投递,不应该由通用 cron 直接扫表改状态 |
| invite recharge outbox consumer | 当前使用 in-memory cursor 扫 wallet outbox迁移前必须先做 durable offset 或正式事件总线 | | invite recharge outbox consumer | 当前使用 in-memory cursor 扫 wallet outbox迁移前必须先做 durable offset 或正式事件总线 |
@ -269,7 +269,7 @@ cron-service 可以直接开启多个实例;调度层通过 `cron_task_leases`
- activity room outbox consumer。 - activity room outbox consumer。
- notice room outbox consumer。 - notice room outbox consumer。
- room IM bridge MQ 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 状态、消费幂等和领域状态校验。 这些需要的是事件消费基础设施RocketMQ、Redis Stream、或 MySQL durable offset。cron-service 不能用“每隔几秒从头扫 outbox”的方式替代事件消费者。即使某些消费者内部有低频扫描补偿也必须留在业务 owner service 中,因为它们需要理解 outbox 状态、消费幂等和领域状态校验。

View File

@ -37,7 +37,7 @@ gateway -> room-service command
outbox worker outbox worker
-> claim pending/retryable rows -> 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: -> publish by mode:
direct: Tencent IM publisher + activity gRPC publisher direct: Tencent IM publisher + activity gRPC publisher
mq: RocketMQ room_outbox 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-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-notice-room-outbox` | notice-service 消费私有通知相关事实,例如 `RoomUserKicked` |
| `hyapp_room_outbox` | `room-service` outbox worker | `hyapp-room-im-bridge` | room-service IM bridge 消费房间群系统消息和群成员控制事实 | | `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_enabled: false
tencent_im_consumer_group: "hyapp-room-im-bridge" tencent_im_consumer_group: "hyapp-room-im-bridge"
consumer_max_reconsume_times: 16 consumer_max_reconsume_times: 16
rocket_open: rocket_launch:
enabled: false enabled: false
topic: "hyapp_room_rocket_open" topic: "hyapp_room_rocket_launch"
producer_group: "hyapp-room-rocket-open-producer" producer_group: "hyapp-room-rocket-open-producer"
consumer_group: "hyapp-room-rocket-open" consumer_group: "hyapp-room-rocket-open"
consumer_max_reconsume_times: 16 consumer_max_reconsume_times: 16
@ -129,7 +129,7 @@ outbox_worker:
| `rocketmq.enabled` | 控制 RocketMQ client 是否启用;本地默认关闭,线上示例开启 | | `rocketmq.enabled` | 控制 RocketMQ client 是否启用;本地默认关闭,线上示例开启 |
| `rocketmq.room_outbox.enabled` | 控制 room outbox 是否可发布到 MQ`publish_mode=mq/dual` 时必须开启 | | `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.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` | | `outbox_worker.publish_mode` | 本地默认 `direct`,线上建议 `mq`,迁移期可用 `dual` |
| `poll_interval` | worker 空轮询间隔,必须大于 0避免 busy loop | | `poll_interval` | worker 空轮询间隔,必须大于 0避免 busy loop |
| `batch_size` | 每轮最大抢占条数,必须大于 0避免全表扫描 | | `batch_size` | 每轮最大抢占条数,必须大于 0避免全表扫描 |
@ -143,7 +143,7 @@ outbox_worker:
| RocketMQ room outbox publisher | 把 protobuf outbox 包装成统一 `room_outbox_event`,发布到 `hyapp_room_outbox` | | RocketMQ room outbox publisher | 把 protobuf outbox 包装成统一 `room_outbox_event`,发布到 `hyapp_room_outbox` |
| Tencent IM direct publisher | `RoomCreated` 对已同步创建的房间群执行幂等 EnsureGroup房间系统事件发送腾讯云 IM 群自定义消息 | | Tencent IM direct publisher | `RoomCreated` 对已同步创建的房间群执行幂等 EnsureGroup房间系统事件发送腾讯云 IM 群自定义消息 |
| Activity direct publisher | 本地或未接 MQ 时直接调用 activity-service room event 消费入口 | | 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` 幂等推进活动/播报逻辑 | | Activity MQ consumer | 独立 consumer group 消费 `hyapp_room_outbox`,按 `event_id` 幂等推进活动/播报逻辑 |
| Notice MQ consumer | 独立 consumer group 消费 `hyapp_room_outbox`,写 `notice_delivery_events` 后投 C2C 私有通知 | | Notice MQ consumer | 独立 consumer group 消费 `hyapp_room_outbox`,写 `notice_delivery_events` 后投 C2C 私有通知 |
| Room IM bridge MQ consumer | 独立 consumer group 消费 `hyapp_room_outbox`,投递房间群系统消息和群成员控制 | | 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 恢复后补发 | | MQ 不可用,`mq/dual` | 房间写命令仍成功outbox 进入重试MQ 恢复后补发 |
| activity consumer 不可用,`mq` | `room_outbox` 不回退RocketMQ 和 activity 消费位点负责积压与重试 | | activity consumer 不可用,`mq` | `room_outbox` 不回退RocketMQ 和 activity 消费位点负责积压与重试 |
| poison event | 达到 `max_retry_count` 后进入 `failed`,不再每秒刷日志 | | poison event | 达到 `max_retry_count` 后进入 `failed`,不再每秒刷日志 |
| 火箭倒计时 | `RoomRocketCountdownStarted` 发布延迟唤醒;到点后 room-service 重新校验状态再发射 | | 火箭倒计时 | `RoomRocketIgnited` 发布延迟唤醒;到点后 room-service 重新校验状态再发射 |
| 正常投递 | 同一 `event_id` 可被多个 consumer group 独立消费且重复投递不产生重复副作用 | | 正常投递 | 同一 `event_id` 可被多个 consumer group 独立消费且重复投递不产生重复副作用 |
| 真实链路 | create/join/mic/sendGift/leave 均有 `room_command_timing` | | 真实链路 | create/join/mic/sendGift/leave 均有 `room_command_timing` |

View File

@ -136,7 +136,7 @@
| 礼物配置 | `PARTIAL` | `wallet_gift_prices` 提供服务端价格和 `COIN`/`DIAMOND` 收费资产,`gift_configs` 提供礼物类型、有效期和特效SendGift 请求只传 `gift_id/gift_count`;礼物配置已绑定 `resource_type=gift` 资源 | 客户端礼物表现联调、资源版本缓存刷新未做 | | 礼物配置 | `PARTIAL` | `wallet_gift_prices` 提供服务端价格和 `COIN`/`DIAMOND` 收费资产,`gift_configs` 提供礼物类型、有效期和特效SendGift 请求只传 `gift_id/gift_count`;礼物配置已绑定 `resource_type=gift` 资源 | 客户端礼物表现联调、资源版本缓存刷新未做 |
| 背包/道具 | `PARTIAL` | `user_resource_entitlements``user_resource_equipment` 支持资源发放、我的资源和可佩戴资源装备 | 背包扣减、免费礼物、道具过期扫描未做 | | 背包/道具 | `PARTIAL` | `user_resource_entitlements``user_resource_equipment` 支持资源发放、我的资源和可佩戴资源装备 | 背包扣减、免费礼物、道具过期扫描未做 |
| 礼物消息补偿 | `DONE` | GiftSent/Heat/Rank 事件进入 outbox由 worker direct 投 IM/activity或发布到 RocketMQ 后由各 consumer group 消费 | 客户端去重和展示策略未验收 | | 礼物消息补偿 | `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 ## Activity And Lucky Gift

View File

@ -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 发布 RocketMQactivity、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` 是火箭进度、点火、待发射队列和奖励结算 owner。
| --- | --- | --- | - `gateway-service` 只提供 App HTTP 入口和鉴权后的协议转换。
| `room-service` | 火箭进度、等级、状态机、点火人、贡献第一、发射在线用户快照、抽奖结果、房间火箭 IM outbox、RocketMQ 延迟发射唤醒消费 | 用户完整资料、全局/区域播报群成员、钱包余额账本 | - `server/admin` 只读写 `room-service` 内部配置接口,不直连 room 数据库。
| `wallet-service` | 送礼扣费、资源组发放、钱包账务、发放幂等 | 火箭进度、中奖资格、房间 presence | - `wallet-service` 负责资源组发放。
| `activity-service` | 燃料满后区域/全局播报、成长值等房间事件派生消费 | Room Cell 状态、火箭奖励结算 | - `activity-service` 负责区域/全局飘屏和带用户资料的奖励房间 IM。
| `notice-service` | 通过 room outbox/MQ 消费需要私信的房间事实,写自己的投递位点和死信 | 房间群系统消息、群成员控制、房间状态 | - 腾讯云 IM 负责房间群、区域群和全局群投递。
| `gateway-service` | App HTTP envelope、鉴权、协议转换、调用 room gRPC | 燃料、抽奖和发奖事实 |
| `server/admin` | 后台菜单、配置表单、审计、调用 owner service 管理接口 | 直接写 room/wallet/activity 业务表 |
火箭进度和发射必须经过 Room Cell 命令链路。MQ 只承担“到点唤醒”和“已提交事实分发”,不能成为火箭状态 owner。 ## 状态模型
## 状态机 `RoomRocketState` 保存当前正在累计的火箭和已点火待发射队列。
```mermaid | 字段 | 说明 |
stateDiagram-v2
[*] --> idle
idle --> idle: SendGift progress<threshold
idle --> 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` | 当前等级可累积燃料 | | `current_level` | 当前正在累计的等级,最高 5 |
| `countdown` | 当前等级已满,`open_at_ms` 已确定,本轮进度、点火人、贡献第一和配置版本锁定 | | `current_fuel` | 当前等级燃料 |
| `opened` | 本轮已发射,奖励已在命令内完成抽取和资源组发放请求 | | `fuel_threshold` | 当前等级阈值 |
| `exhausted` | 当天 7 级都已开启,等下一个 UTC 自然日 | | `status` | `charging``completed` |
| `rocket_id` | 当前正在累计的火箭 ID |
| `pending_launches` | 已点火、等待延迟发射的火箭 |
| `last_rewards` | 最近一次发射奖励结果 |
当前实现没有单独持久化 `round_id` 表。`box_id` 是单轮火箭幂等和奖励选择的核心 ID发射命令 ID 固定为: `pending_launches` 中每一项锁定:
```text | 字段 | 说明 |
cmd_room_rocket_open_<box_id>
```
奖励发放命令 ID 固定由 `box_id + role + user_id + reward_item_id` 派生,重复发射或 MQ 重投不会重复发放同一条奖励。
## 燃料规则
| 规则 | 决策 |
| --- | --- | | --- | --- |
| 燃料来源 | 默认使用 `DebitGiftResponse.gift_point_added`;后台可配置 `gift_id``gift_type_code` 的倍率、覆盖值和排除规则 | | `rocket_id` | 本枚火箭幂等 ID |
| 生效条件 | wallet 扣费成功、Room Cell 提交 `SendGift`、礼物燃料值大于 0 | | `level` | 点火等级 |
| 幂等 | 同一 `SendGift.command_id` 只能增加一次火箭燃料 | | `fuel_threshold` | 点火阈值 |
| 点火人 | 第一笔让当前等级 `progress >= threshold` 的送礼用户 | | `ignited_at_ms` | 点火时间 |
| 贡献第一 | 当前等级倒计时前累计有效燃料最高用户 | | `launch_at_ms` | 预计发射时间 |
| 溢出燃料 | 单笔礼物超过当前等级剩余阈值时,只计入补满当前等级所需燃料,多余燃料作废 | | `reset_at_ms` | UTC 日重置时间 |
| 倒计时送礼 | 当前等级已经燃料满,继续送礼不再增加火箭燃料,也不会排队到下一等级 | | `top1_user_id` | 点火时贡献第一 |
| 7 级后送礼 | 当天不再累计火箭燃料,礼物仍正常扣费、热度和榜单照常更新 | | `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) 1. `RoomRocketIgnited` 被 outbox worker 处理后,安排 RocketMQ delayed wakeup。
R->>W: DebitGift(command_id) 2. 本地扫描 worker 也会扫描已加载且仍持有 lease 的 Room Cell作为兜底。
W-->>R: gift_point_added, heat_value 3. 到点后 `LaunchRoomRocket` 命令重新加载 Room Cell。
R->>R: Room Cell updates rocket 4. 命令必须校验:
R->>DB: command_log + snapshot + room_outbox - `rocket_id` 存在于 `pending_launches`
R-->>C: SendGiftResponse.rocket - `level` 匹配。
R->>DB: outbox worker claims RoomRocketCountdownStarted - `launch_at_ms <= now_ms`
R->>MQ: delayed RoomRocketOpenDue(open_at_ms) - `now_ms < reset_at_ms`
R->>MQ: room_outbox event 5. 结算时按发射瞬间 `OnlineUsers` 抽在房奖励。
MQ-->>A: RoomRocketCountdownStarted 6. `top1``igniter` 使用点火时锁定的用户,离房后仍发对应奖励。
A-->>IM: region/global broadcast 7. 发奖完成后移除该 `pending_launches` 项,当前正在累计的火箭不被发射命令推进。
MQ-->>R: RoomRocketOpenDue at open_at_ms
R->>R: OpenRoomRocket command
R->>W: GrantResourceGroup per reward
R->>DB: command_log + snapshot + opened/reward outbox
```
关键规则: ## 奖励
- 延迟消息只唤醒,不直接修改状态。 | 奖励 | 用户来源 |
- 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` 内的每个用户 | | 点火人奖励 | `pending_launches[].igniter_user_id` |
| `top1` | 倒计时开始时锁定的贡献第一用户 | | 贡献第一奖励 | `pending_launches[].top1_user_id` |
| `igniter` | 倒计时开始时锁定的点火人 | | 在房奖励 | 发射命令执行时 Room Cell `OnlineUsers` |
奖励配置按等级拆分,每个角色是一个加权奖励池。奖励项引用 `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"
}
]
}
```
`reward_stack_policy` `reward_stack_policy`
| 策略 | 语义 | | 值 | 说明 |
| --- | --- | | --- | --- |
| `allow_stack` | 同一用户可同时获得在房、top1、igniter 多个角色奖励 | | `allow_stack` | 同一用户可同时拿在房、top1、点火人奖励 |
| `priority_only` | 同一用户只获得优先级最高的一份,优先级为 `igniter -> top1 -> in_room` | | `priority_only` | 先点火人,再 top1再在房同一用户只拿一份 |
## App 接口 ## IM
### 获取火箭信息 | 事实 | IM | 投递方 |
```http
GET /api/v1/rooms/{room_id}/rocket
Authorization: Bearer <access_token>
```
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` | 用途 |
| --- | --- | --- | | --- | --- | --- |
| `RoomRocketProgressChanged` | `room_rocket_progress_changed` | 火箭进度增加 | | `RoomRocketFuelChanged` | `room_rocket_fuel_changed` | room-service IM bridge |
| `RoomRocketCountdownStarted` | `room_rocket_countdown_started` | 燃料满进入倒计时 | | `RoomRocketIgnited` | `room_rocket_ignited` | room-service IM bridge |
| `RoomRocketOpened` | `room_rocket_opened` | 火箭打开并给出公开奖励摘要 | | `RoomRocketIgnited` | `room_rocket` 区域/全局飘屏 | activity-service |
| `RoomRocketRewardGranted` | `room_rocket_reward_granted` | 发奖结果事件,包含奖励列表 | | `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 ## UTC 重置
{
"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"
}
```
### Opened / Reward Granted - 火箭周期按 UTC 自然日。
- 到达 `reset_at_ms` 后,查询或下一次送礼会投影为 1 级新进度。
```json - 跨过 UTC 日界仍未发射的待发射火箭不结算。
{
"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` 同步投递私信。
## 后台配置 ## 后台配置
后台配置带 `config_version`。倒计时开始后,当前火箭的 `box_id`、等级、进度、`open_at_ms`、点火人和贡献第一不再因配置修改而变化;当前实现发射时读取最新启用配置的奖励池,因此运营在倒计时窗口内修改奖励配置要按发布窗口处理。 配置表:`room_rocket_configs`
| 字段 | 含义 | | 字段 | 说明 |
| --- | --- | | --- | --- |
| `enabled` | 总开关 | | `enabled` | 是否开启 |
| `energy_source` | 默认 `gift_point_added` | | `fuel_source` | 固定 `heat_value` |
| `open_delay_ms` | 燃料满到发射的倒计时 | | `launch_delay_ms` | 点火到发射延迟 |
| `broadcast_enabled` | 是否燃料满后播报 | | `broadcast_enabled` | 是否发飘屏 |
| `broadcast_scope` | `none/region/global` | | `broadcast_scope` | `region``global``none` |
| `broadcast_delay_ms` | 燃料满后多久发播报,当前代码写入配置和读模型,实际倒计时事件立即进入 room outbox | | `reward_stack_policy` | 奖励叠加策略 |
| `reward_stack_policy` | `allow_stack``priority_only` | | `levels_json` | 5 级阈值、物料和奖励池 |
| `levels` | 7 个等级配置,包含阈值、物料 URL 和三类奖励池 | | `gift_fuel_rules_json` | 礼物燃料规则 |
| `gift_energy_rules` | gift_id 或 gift_type_code 的燃料倍率、覆盖值和排除规则 |
| `updated_by_admin_id` | 审计字段 |
奖励池保存时应校验: `levels_json` 字段使用 `fuelThreshold``coverUrl``animationUrl``launchAnimationUrl``launchedImageUrl``inRoomRewards``top1Rewards``igniterRewards`
- `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 只投 RocketMQactivity/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 logMQ 后续可重试 |
| 到点发射 | 即使房间未加载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` |

View File

@ -20,8 +20,8 @@ import (
const ( const (
// Tencent Cloud RocketMQ accepts this property as an absolute Unix epoch // Tencent Cloud RocketMQ accepts this property as an absolute Unix epoch
// millisecond delivery timestamp. Open-source RocketMQ delay levels remain // millisecond delivery timestamp. Open-source RocketMQ delay levels remain
// available through the native client, but the treasure open timer needs an // available through the native client, but the rocket launch timer needs an
// exact backend-configured open_at_ms. // exact backend-configured launch_at_ms.
PropertyStartDeliverTime = "__STARTDELIVERTIME" PropertyStartDeliverTime = "__STARTDELIVERTIME"
) )

View File

@ -135,7 +135,7 @@ type GiftFuelRuleConfig struct {
GiftID string GiftID string
GiftTypeCode string GiftTypeCode string
MultiplierPPM int64 MultiplierPPM int64
FixedFuel int64 FixedFuel int64
Excluded bool Excluded bool
} }
@ -642,7 +642,7 @@ func roomRocketFuelRulesFromProto(input []*roomv1.RoomRocketGiftFuelRule) []Gift
GiftID: rule.GetGiftId(), GiftID: rule.GetGiftId(),
GiftTypeCode: rule.GetGiftTypeCode(), GiftTypeCode: rule.GetGiftTypeCode(),
MultiplierPPM: rule.GetMultiplierPpm(), MultiplierPPM: rule.GetMultiplierPpm(),
FixedFuel: rule.GetFixedFuel(), FixedFuel: rule.GetFixedFuel(),
Excluded: rule.GetExcluded(), Excluded: rule.GetExcluded(),
}) })
} }
@ -657,7 +657,7 @@ func roomRocketFuelRulesToProto(input []GiftFuelRuleConfig) []*roomv1.RoomRocket
GiftId: rule.GiftID, GiftId: rule.GiftID,
GiftTypeCode: rule.GiftTypeCode, GiftTypeCode: rule.GiftTypeCode,
MultiplierPpm: rule.MultiplierPPM, MultiplierPpm: rule.MultiplierPPM,
FixedFuel: rule.FixedFuel, FixedFuel: rule.FixedFuel,
Excluded: rule.Excluded, Excluded: rule.Excluded,
}) })
} }

View File

@ -71,7 +71,7 @@ type GiftFuelRuleConfig struct {
GiftID string `json:"giftId"` GiftID string `json:"giftId"`
GiftTypeCode string `json:"giftTypeCode"` GiftTypeCode string `json:"giftTypeCode"`
MultiplierPPM int64 `json:"multiplierPpm"` MultiplierPPM int64 `json:"multiplierPpm"`
FixedFuel int64 `json:"fixedFuel"` FixedFuel int64 `json:"fixedFuel"`
Excluded bool `json:"excluded"` Excluded bool `json:"excluded"`
} }
@ -161,10 +161,6 @@ func normalizeConfig(config RoomRocketConfig) (RoomRocketConfig, error) {
return RoomRocketConfig{}, fmt.Errorf("%w: 配置版本不能小于 0", ErrInvalidArgument) return RoomRocketConfig{}, fmt.Errorf("%w: 配置版本不能小于 0", ErrInvalidArgument)
} }
config.FuelSource = defaultString(strings.TrimSpace(config.FuelSource), defaultFuelSource) 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 { if config.FuelSource != defaultFuelSource {
return RoomRocketConfig{}, fmt.Errorf("%w: 燃料来源不支持", ErrInvalidArgument) return RoomRocketConfig{}, fmt.Errorf("%w: 燃料来源不支持", ErrInvalidArgument)
} }
@ -408,7 +404,7 @@ func fuelRulesFromClient(input []roomclient.GiftFuelRuleConfig) []GiftFuelRuleCo
GiftID: rule.GiftID, GiftID: rule.GiftID,
GiftTypeCode: rule.GiftTypeCode, GiftTypeCode: rule.GiftTypeCode,
MultiplierPPM: rule.MultiplierPPM, MultiplierPPM: rule.MultiplierPPM,
FixedFuel: rule.FixedFuel, FixedFuel: rule.FixedFuel,
Excluded: rule.Excluded, Excluded: rule.Excluded,
}) })
} }
@ -423,7 +419,7 @@ func fuelRulesToClient(input []GiftFuelRuleConfig) []roomclient.GiftFuelRuleConf
GiftID: rule.GiftID, GiftID: rule.GiftID,
GiftTypeCode: rule.GiftTypeCode, GiftTypeCode: rule.GiftTypeCode,
MultiplierPPM: rule.MultiplierPPM, MultiplierPPM: rule.MultiplierPPM,
FixedFuel: rule.FixedFuel, FixedFuel: rule.FixedFuel,
Excluded: rule.Excluded, Excluded: rule.Excluded,
}) })
} }

View File

@ -15,8 +15,6 @@ func TestNormalizeConfigDefaultsAndSortsLevels(t *testing.T) {
{Level: 3, FuelThreshold: 600}, {Level: 3, FuelThreshold: 600},
{Level: 4, FuelThreshold: 1_000}, {Level: 4, FuelThreshold: 1_000},
{Level: 5, FuelThreshold: 1_500}, {Level: 5, FuelThreshold: 1_500},
{Level: 6, FuelThreshold: 2_100},
{Level: 7, FuelThreshold: 2_800},
}, },
}) })
if err != nil { if err != nil {
@ -25,7 +23,7 @@ func TestNormalizeConfigDefaultsAndSortsLevels(t *testing.T) {
if config.FuelSource != defaultFuelSource || config.LaunchDelayMS != defaultLaunchDelayMS || config.BroadcastScope != defaultBroadcastScope { if config.FuelSource != defaultFuelSource || config.LaunchDelayMS != defaultLaunchDelayMS || config.BroadcastScope != defaultBroadcastScope {
t.Fatalf("default fields mismatch: %+v", config) 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) 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: "missing_level", config: RoomRocketConfig{AppCode: "lalu", Levels: []RoomRocketLevelConfig{{Level: 1, FuelThreshold: 1}}}},
{name: "duplicate", config: RoomRocketConfig{AppCode: "lalu", Levels: []RoomRocketLevelConfig{ {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{ {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 = defaultConfig("lalu")
config.GiftFuelRules = []GiftFuelRuleConfig{{GiftID: "gift-1"}} config.GiftFuelRules = []GiftFuelRuleConfig{{GiftID: "gift-1"}}
if _, err := normalizeConfig(config); !errors.Is(err, ErrInvalidArgument) { 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)
} }
} }

View File

@ -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) { func roomRocketPayload(envelope *roomeventsv1.EventEnvelope, rocket *roomeventsv1.RoomRocketIgnited, eventID string, sentAtMS int64, igniter SenderProfile) (string, error) {
payload := map[string]any{ payload := map[string]any{
"event_id": eventID, "event_id": eventID,
"broadcast_type": broadcastdomain.TypeRoomRocket, "broadcast_type": broadcastdomain.TypeRoomRocket,
"scope": rocket.GetBroadcastScope(), "scope": rocket.GetBroadcastScope(),
"app_code": envelope.GetAppCode(), "app_code": envelope.GetAppCode(),
"region_id": rocket.GetVisibleRegionId(), "region_id": rocket.GetVisibleRegionId(),
"room_id": envelope.GetRoomId(), "room_id": envelope.GetRoomId(),
"room_short_id": rocket.GetRoomShortId(), "room_short_id": rocket.GetRoomShortId(),
"rocket_id": rocket.GetRocketId(), "rocket_id": rocket.GetRocketId(),
"rocket_level": rocket.GetLevel(), "rocket_level": rocket.GetLevel(),
"rocket_cover_url": rocket.GetRocketCoverUrl(), "rocket_cover_url": rocket.GetRocketCoverUrl(),
"launch_at_ms": rocket.GetLaunchAtMs(), "launch_at_ms": rocket.GetLaunchAtMs(),
"top1_user_id": rocket.GetTop1UserId(), "top1_user_id": rocket.GetTop1UserId(),
"igniter_user_id": rocket.GetIgniterUserId(), "igniter_user_id": rocket.GetIgniterUserId(),
"igniter": map[string]any{ "igniter": map[string]any{
"user_id": fmt.Sprintf("%d", rocket.GetIgniterUserId()), "user_id": fmt.Sprintf("%d", rocket.GetIgniterUserId()),
"short_id": strings.TrimSpace(igniter.Account), "short_id": strings.TrimSpace(igniter.Account),
@ -689,8 +689,8 @@ func roomRocketRewardPayload(envelope *roomeventsv1.EventEnvelope, event *roomev
userID := reward.GetUserId() userID := reward.GetUserId()
profile := profiles[userID] profile := profiles[userID]
rewards = append(rewards, map[string]any{ rewards = append(rewards, map[string]any{
"reward_role": reward.GetRewardRole(), "reward_role": reward.GetRewardRole(),
"user_id": fmt.Sprintf("%d", userID), "user_id": fmt.Sprintf("%d", userID),
"user": map[string]any{ "user": map[string]any{
"user_id": fmt.Sprintf("%d", userID), "user_id": fmt.Sprintf("%d", userID),
"short_id": strings.TrimSpace(profile.Account), "short_id": strings.TrimSpace(profile.Account),

View File

@ -199,19 +199,19 @@ type roomRocketRewardData struct {
} }
type roomRocketStateData struct { type roomRocketStateData struct {
CurrentLevel int32 `json:"current_level"` CurrentLevel int32 `json:"current_level"`
CurrentFuel int64 `json:"current_fuel"` CurrentFuel int64 `json:"current_fuel"`
FuelThreshold int64 `json:"fuel_threshold"` FuelThreshold int64 `json:"fuel_threshold"`
Status string `json:"status"` Status string `json:"status"`
IgnitedAtMS int64 `json:"ignited_at_ms,omitempty"` IgnitedAtMS int64 `json:"ignited_at_ms,omitempty"`
LaunchAtMS int64 `json:"launch_at_ms,omitempty"` LaunchAtMS int64 `json:"launch_at_ms,omitempty"`
LaunchedAtMS int64 `json:"launched_at_ms,omitempty"` LaunchedAtMS int64 `json:"launched_at_ms,omitempty"`
ResetAtMS int64 `json:"reset_at_ms"` ResetAtMS int64 `json:"reset_at_ms"`
Top1UserID string `json:"top1_user_id,omitempty"` Top1UserID string `json:"top1_user_id,omitempty"`
IgniterUserID string `json:"igniter_user_id,omitempty"` IgniterUserID string `json:"igniter_user_id,omitempty"`
RocketID string `json:"rocket_id,omitempty"` RocketID string `json:"rocket_id,omitempty"`
ConfigVersion int64 `json:"config_version,omitempty"` ConfigVersion int64 `json:"config_version,omitempty"`
LastRewards []roomRocketRewardGrantData `json:"last_rewards,omitempty"` LastRewards []roomRocketRewardGrantData `json:"last_rewards,omitempty"`
PendingLaunches []roomRocketPendingLaunchData `json:"pending_launches,omitempty"` PendingLaunches []roomRocketPendingLaunchData `json:"pending_launches,omitempty"`
} }
@ -225,7 +225,7 @@ type roomRocketPendingLaunchData struct {
Top1UserID string `json:"top1_user_id,omitempty"` Top1UserID string `json:"top1_user_id,omitempty"`
IgniterUserID string `json:"igniter_user_id,omitempty"` IgniterUserID string `json:"igniter_user_id,omitempty"`
ConfigVersion int64 `json:"config_version,omitempty"` ConfigVersion int64 `json:"config_version,omitempty"`
CoverURL string `json:"cover_url,omitempty"` CoverURL string `json:"cover_url,omitempty"`
} }
type roomRocketRewardGrantData struct { type roomRocketRewardGrantData struct {
@ -460,19 +460,19 @@ func roomRocketStateFromProto(state *roomv1.RoomRocketState) roomRocketStateData
return roomRocketStateData{} return roomRocketStateData{}
} }
return roomRocketStateData{ return roomRocketStateData{
CurrentLevel: state.GetCurrentLevel(), CurrentLevel: state.GetCurrentLevel(),
CurrentFuel: state.GetCurrentFuel(), CurrentFuel: state.GetCurrentFuel(),
FuelThreshold: state.GetFuelThreshold(), FuelThreshold: state.GetFuelThreshold(),
Status: state.GetStatus(), Status: state.GetStatus(),
IgnitedAtMS: state.GetIgnitedAtMs(), IgnitedAtMS: state.GetIgnitedAtMs(),
LaunchAtMS: state.GetLaunchAtMs(), LaunchAtMS: state.GetLaunchAtMs(),
LaunchedAtMS: state.GetLaunchedAtMs(), LaunchedAtMS: state.GetLaunchedAtMs(),
ResetAtMS: state.GetResetAtMs(), ResetAtMS: state.GetResetAtMs(),
Top1UserID: formatOptionalUserID(state.GetTop1UserId()), Top1UserID: formatOptionalUserID(state.GetTop1UserId()),
IgniterUserID: formatOptionalUserID(state.GetIgniterUserId()), IgniterUserID: formatOptionalUserID(state.GetIgniterUserId()),
RocketID: state.GetRocketId(), RocketID: state.GetRocketId(),
ConfigVersion: state.GetConfigVersion(), ConfigVersion: state.GetConfigVersion(),
LastRewards: roomRocketRewardGrantsFromProto(state.GetLastRewards()), LastRewards: roomRocketRewardGrantsFromProto(state.GetLastRewards()),
PendingLaunches: roomRocketPendingLaunchesFromProto(state.GetPendingLaunches()), PendingLaunches: roomRocketPendingLaunchesFromProto(state.GetPendingLaunches()),
} }
} }

View File

@ -67,11 +67,11 @@ rocketmq:
tencent_im_consumer_enabled: true tencent_im_consumer_enabled: true
tencent_im_consumer_group: "hyapp-room-im-bridge" tencent_im_consumer_group: "hyapp-room-im-bridge"
consumer_max_reconsume_times: 16 consumer_max_reconsume_times: 16
rocket_open: rocket_launch:
enabled: false enabled: false
topic: "hyapp_room_rocket_launch" topic: "hyapp_room_rocket_launch"
producer_group: "hyapp-room-rocket-open-producer" producer_group: "hyapp-room-rocket-launch-producer"
consumer_group: "hyapp-room-rocket-open" consumer_group: "hyapp-room-rocket-launch"
consumer_max_reconsume_times: 16 consumer_max_reconsume_times: 16
outbox_worker: outbox_worker:
# Docker 和 testbox 走 MQroom-service 同进程启动 IM bridge consumer 补偿房间群消息。 # Docker 和 testbox 走 MQroom-service 同进程启动 IM bridge consumer 补偿房间群消息。

View File

@ -77,11 +77,11 @@ rocketmq:
tencent_im_consumer_enabled: true tencent_im_consumer_enabled: true
tencent_im_consumer_group: "hyapp-room-im-bridge" tencent_im_consumer_group: "hyapp-room-im-bridge"
consumer_max_reconsume_times: 16 consumer_max_reconsume_times: 16
rocket_open: rocket_launch:
enabled: true enabled: true
topic: "hyapp_room_rocket_launch" topic: "hyapp_room_rocket_launch"
producer_group: "hyapp-room-rocket-open-producer" producer_group: "hyapp-room-rocket-launch-producer"
consumer_group: "hyapp-room-rocket-open" consumer_group: "hyapp-room-rocket-launch"
consumer_max_reconsume_times: 16 consumer_max_reconsume_times: 16
outbox_worker: outbox_worker:
# mq 模式下 outbox worker 只把事实投 MQ并顺带安排火箭延迟发射唤醒。 # mq 模式下 outbox worker 只把事实投 MQ并顺带安排火箭延迟发射唤醒。

View File

@ -70,11 +70,11 @@ rocketmq:
tencent_im_consumer_enabled: false tencent_im_consumer_enabled: false
tencent_im_consumer_group: "hyapp-room-im-bridge" tencent_im_consumer_group: "hyapp-room-im-bridge"
consumer_max_reconsume_times: 16 consumer_max_reconsume_times: 16
rocket_open: rocket_launch:
enabled: false enabled: false
topic: "hyapp_room_rocket_launch" topic: "hyapp_room_rocket_launch"
producer_group: "hyapp-room-rocket-open-producer" producer_group: "hyapp-room-rocket-launch-producer"
consumer_group: "hyapp-room-rocket-open" consumer_group: "hyapp-room-rocket-launch"
consumer_max_reconsume_times: 16 consumer_max_reconsume_times: 16
outbox_worker: outbox_worker:
# room_outbox 是腾讯云 IM 和 activity-service 的异步投递通道;失败退避重试,超过上限转死信。 # room_outbox 是腾讯云 IM 和 activity-service 的异步投递通道;失败退避重试,超过上限转死信。

View File

@ -123,7 +123,7 @@ type RocketMQConfig struct {
SendTimeout time.Duration `yaml:"send_timeout"` SendTimeout time.Duration `yaml:"send_timeout"`
Retry int `yaml:"retry"` Retry int `yaml:"retry"`
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"` RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
RocketLaunch RocketLaunchMQConfig `yaml:"rocket_open"` RocketLaunch RocketLaunchMQConfig `yaml:"rocket_launch"`
} }
// RoomOutboxMQConfig 控制 room_outbox fanout topic。 // RoomOutboxMQConfig 控制 room_outbox fanout topic。
@ -302,8 +302,8 @@ func defaultRocketMQConfig() RocketMQConfig {
RocketLaunch: RocketLaunchMQConfig{ RocketLaunch: RocketLaunchMQConfig{
Enabled: false, Enabled: false,
Topic: "hyapp_room_rocket_launch", Topic: "hyapp_room_rocket_launch",
ProducerGroup: "hyapp-room-rocket-open-producer", ProducerGroup: "hyapp-room-rocket-launch-producer",
ConsumerGroup: "hyapp-room-rocket-open", ConsumerGroup: "hyapp-room-rocket-launch",
ConsumerMaxReconsumeTimes: 16, ConsumerMaxReconsumeTimes: 16,
}, },
} }

View File

@ -71,7 +71,7 @@ func TestLoadTencentExample(t *testing.T) {
t.Fatalf("tencent example must configure outbox worker: %+v", cfg.OutboxWorker) t.Fatalf("tencent example must configure outbox worker: %+v", cfg.OutboxWorker)
} }
if cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || !cfg.RocketMQ.RoomOutbox.Enabled || !cfg.RocketMQ.RocketLaunch.Enabled { 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)
} }
} }

View File

@ -36,7 +36,7 @@ func (c *grpcWalletClient) BatchDebitGift(ctx context.Context, req *walletv1.Bat
// GrantResourceGroup 直接转调 wallet-service 资源组发放接口。 // GrantResourceGroup 直接转调 wallet-service 资源组发放接口。
func (c *grpcWalletClient) GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) { 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) return c.client.GrantResourceGroup(ctx, req)
} }

View File

@ -52,7 +52,7 @@ type RocketMQRocketLaunchScheduler struct {
topic string topic string
} }
// NewRocketMQRocketLaunchScheduler creates the delayed open scheduler. // NewRocketMQRocketLaunchScheduler creates the delayed launch scheduler.
func NewRocketMQRocketLaunchScheduler(producer *rocketmqx.Producer, topic string) *RocketMQRocketLaunchScheduler { func NewRocketMQRocketLaunchScheduler(producer *rocketmqx.Producer, topic string) *RocketMQRocketLaunchScheduler {
return &RocketMQRocketLaunchScheduler{producer: producer, topic: topic} return &RocketMQRocketLaunchScheduler{producer: producer, topic: topic}
} }

View File

@ -302,10 +302,14 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
base.Attributes = map[string]string{ base.Attributes = map[string]string{
"rocket_id": body.GetRocketId(), "rocket_id": body.GetRocketId(),
"level": fmt.Sprintf("%d", body.GetLevel()), "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()), "current_fuel": fmt.Sprintf("%d", body.GetCurrentFuel()),
"fuel_threshold": fmt.Sprintf("%d", body.GetFuelThreshold()), "fuel_threshold": fmt.Sprintf("%d", body.GetFuelThreshold()),
"ignited_at_ms": fmt.Sprintf("%d", body.GetIgnitedAtMs()), "ignited_at_ms": fmt.Sprintf("%d", body.GetIgnitedAtMs()),
"launch_at_ms": fmt.Sprintf("%d", body.GetLaunchAtMs()), "launch_at_ms": fmt.Sprintf("%d", body.GetLaunchAtMs()),
"reset_at_ms": fmt.Sprintf("%d", body.GetResetAtMs()),
"broadcast_scope": body.GetBroadcastScope(), "broadcast_scope": body.GetBroadcastScope(),
"visible_region_id": fmt.Sprintf("%d", body.GetVisibleRegionId()), "visible_region_id": fmt.Sprintf("%d", body.GetVisibleRegionId()),
"top1_user_id": fmt.Sprintf("%d", body.GetTop1UserId()), "top1_user_id": fmt.Sprintf("%d", body.GetTop1UserId()),
@ -336,20 +340,9 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
} }
return base, true, nil return base, true, nil
case "RoomRocketRewardGranted": case "RoomRocketRewardGranted":
var body roomeventsv1.RoomRocketRewardGranted // 奖励 IM 需要头像、昵称和短 IDroom-service 不反查 user-service
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { // activity-service 消费同一事实后做资料 enrichment 并投递房间播报。
return tencentim.RoomEvent{}, false, err return tencentim.RoomEvent{}, false, nil
}
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
default: default:
// RoomCreated/HeatChanged/RankChanged 不单独推客户端,避免一条命令产生多条系统消息。 // RoomCreated/HeatChanged/RankChanged 不单独推客户端,避免一条命令产生多条系统消息。
return tencentim.RoomEvent{}, false, nil return tencentim.RoomEvent{}, false, nil

View File

@ -211,11 +211,11 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
records = append(records, progressEvent) records = append(records, progressEvent)
} }
if rocketApply.ignited != nil { 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 { if err != nil {
return mutationResult{}, nil, err return mutationResult{}, nil, err
} }
records = append(records, countdownEvent) records = append(records, ignitedEvent)
} }
return mutationResult{ return mutationResult{

View File

@ -419,17 +419,17 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64)
func rocketStateFromSettledGift(cmd *command.SendGift) *state.RocketState { func rocketStateFromSettledGift(cmd *command.SendGift) *state.RocketState {
return &state.RocketState{ return &state.RocketState{
CurrentLevel: cmd.RocketLevel, CurrentLevel: cmd.RocketLevel,
CurrentFuel: cmd.RocketFuel, CurrentFuel: cmd.RocketFuel,
FuelThreshold: cmd.RocketFuelThreshold, FuelThreshold: cmd.RocketFuelThreshold,
Status: cmd.RocketStatus, Status: cmd.RocketStatus,
IgnitedAtMS: cmd.RocketIgnitedAtMS, IgnitedAtMS: cmd.RocketIgnitedAtMS,
LaunchAtMS: cmd.RocketLaunchAtMS, LaunchAtMS: cmd.RocketLaunchAtMS,
ResetAtMS: cmd.RocketResetAtMS, ResetAtMS: cmd.RocketResetAtMS,
Top1UserID: cmd.RocketTop1UserID, Top1UserID: cmd.RocketTop1UserID,
IgniterUserID: cmd.RocketIgniterUserID, IgniterUserID: cmd.RocketIgniterUserID,
RocketID: cmd.RocketID, RocketID: cmd.RocketID,
ConfigVersion: cmd.RocketConfigVersion, ConfigVersion: cmd.RocketConfigVersion,
PendingLaunches: rocketPendingLaunchesFromCommand(cmd.RocketPendingLaunches), PendingLaunches: rocketPendingLaunchesFromCommand(cmd.RocketPendingLaunches),
} }
} }

View File

@ -293,7 +293,7 @@ type GiftFuelRuleConfig struct {
GiftID string `json:"giftId"` GiftID string `json:"giftId"`
GiftTypeCode string `json:"giftTypeCode"` GiftTypeCode string `json:"giftTypeCode"`
MultiplierPPM int64 `json:"multiplierPpm"` MultiplierPPM int64 `json:"multiplierPpm"`
FixedFuel int64 `json:"fixedFuel"` FixedFuel int64 `json:"fixedFuel"`
Excluded bool `json:"excluded"` Excluded bool `json:"excluded"`
} }

View File

@ -126,10 +126,6 @@ func normalizeRoomRocketConfig(config RoomRocketConfig) (RoomRocketConfig, error
return RoomRocketConfig{}, xerr.New(xerr.InvalidArgument, "room rocket config_version is invalid") return RoomRocketConfig{}, xerr.New(xerr.InvalidArgument, "room rocket config_version is invalid")
} }
config.FuelSource = defaultRoomRocketString(config.FuelSource, roomRocketDefaultFuelSource) config.FuelSource = defaultRoomRocketString(config.FuelSource, roomRocketDefaultFuelSource)
if config.FuelSource == "gift_point_added" {
// 旧配置可能还存着 gift_point_addedGIFT_POINT 已下线,运行时统一按真实送礼贡献 heat_value 计算火箭燃料。
config.FuelSource = roomRocketFuelHeatValue
}
if config.FuelSource != roomRocketFuelHeatValue { if config.FuelSource != roomRocketFuelHeatValue {
return RoomRocketConfig{}, xerr.New(xerr.InvalidArgument, "room rocket fuel_source is invalid") 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{} internal := state.RocketState{}
if current != nil { if current != nil {
internal = state.RocketState{ internal = state.RocketState{
CurrentLevel: current.GetCurrentLevel(), CurrentLevel: current.GetCurrentLevel(),
CurrentFuel: current.GetCurrentFuel(), CurrentFuel: current.GetCurrentFuel(),
FuelThreshold: current.GetFuelThreshold(), FuelThreshold: current.GetFuelThreshold(),
Status: current.GetStatus(), Status: current.GetStatus(),
IgnitedAtMS: current.GetIgnitedAtMs(), IgnitedAtMS: current.GetIgnitedAtMs(),
LaunchAtMS: current.GetLaunchAtMs(), LaunchAtMS: current.GetLaunchAtMs(),
LaunchedAtMS: current.GetLaunchedAtMs(), LaunchedAtMS: current.GetLaunchedAtMs(),
ResetAtMS: current.GetResetAtMs(), ResetAtMS: current.GetResetAtMs(),
Top1UserID: current.GetTop1UserId(), Top1UserID: current.GetTop1UserId(),
IgniterUserID: current.GetIgniterUserId(), IgniterUserID: current.GetIgniterUserId(),
RocketID: current.GetRocketId(), RocketID: current.GetRocketId(),
ConfigVersion: current.GetConfigVersion(), ConfigVersion: current.GetConfigVersion(),
PendingLaunches: statePendingLaunchesFromProto(current.GetPendingLaunches()), PendingLaunches: statePendingLaunchesFromProto(current.GetPendingLaunches()),
LastRewards: stateRewardsFromProto(current.GetLastRewards()), LastRewards: stateRewardsFromProto(current.GetLastRewards()),
} }
} }
normalized := rocketStateForNow(&internal, config, now, false) normalized := rocketStateForNow(&internal, config, now, false)
@ -693,7 +689,7 @@ func rocketStateToEventRewards(input []state.RocketRewardGrant) []*roomeventsv1.
return out return out
} }
// RunRoomRocketLaunchWorker 周期性扫描已装载房间,把到点的倒计时火箭走命令链路打开 // RunRoomRocketLaunchWorker 周期性扫描已装载房间,把到点的待发射火箭走命令链路发射
func (s *Service) RunRoomRocketLaunchWorker(ctx context.Context, interval time.Duration) { func (s *Service) RunRoomRocketLaunchWorker(ctx context.Context, interval time.Duration) {
if interval <= 0 { if interval <= 0 {
return return
@ -849,7 +845,7 @@ func (s *Service) launchRoomRocket(ctx context.Context, cmd command.LaunchRoomRo
current.Version++ 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, RocketId: pending.RocketID,
Level: pending.Level, Level: pending.Level,
NextLevel: current.Rocket.CurrentLevel, NextLevel: current.Rocket.CurrentLevel,
@ -863,7 +859,7 @@ func (s *Service) launchRoomRocket(ctx context.Context, cmd command.LaunchRoomRo
if err != nil { if err != nil {
return mutationResult{}, nil, err return mutationResult{}, nil, err
} }
records := []outbox.Record{openedEvent} records := []outbox.Record{launchedEvent}
if len(rewards) > 0 { if len(rewards) > 0 {
rewardEvent, err := outbox.Build(current.RoomID, "RoomRocketRewardGranted", current.Version, now, &roomeventsv1.RoomRocketRewardGranted{ rewardEvent, err := outbox.Build(current.RoomID, "RoomRocketRewardGranted", current.Version, now, &roomeventsv1.RoomRocketRewardGranted{
RocketId: pending.RocketID, RocketId: pending.RocketID,

View File

@ -175,13 +175,13 @@ func (r *rocketLaunchScheduleRecorder) ScheduleRoomRocketLaunch(_ context.Contex
return nil return nil
} }
func TestRoomRocketGiftOverflowAndIgnitedInvalidEnergy(t *testing.T) { func TestRoomRocketGiftOverflowAndImmediateNextLevel(t *testing.T) {
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)} now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{ wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{BillingReceiptId: "receipt-fill", GiftPointAdded: 150, HeatValue: 150, GiftTypeCode: "normal"}, {BillingReceiptId: "receipt-fill", GiftPointAdded: 250, HeatValue: 250, GiftTypeCode: "normal"},
{BillingReceiptId: "receipt-ignited", GiftPointAdded: 200, HeatValue: 200, GiftTypeCode: "normal"}, {BillingReceiptId: "receipt-next", GiftPointAdded: 50, HeatValue: 50, GiftTypeCode: "normal"},
}} }}
svc := newRocketTestService(t, repository, wallet, now) svc := newRocketTestService(t, repository, wallet, now)
writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000)) 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) t.Fatalf("send fill gift failed: %v", err)
} }
rocket := fillResp.GetRocket() rocket := fillResp.GetRocket()
if rocket.GetCurrentLevel() != 1 || rocket.GetCurrentFuel() != 100 || rocket.GetStatus() != state.RocketStatusIgnited { if rocket.GetCurrentLevel() != 2 || rocket.GetCurrentFuel() != 0 || rocket.GetStatus() != state.RocketStatusCharging {
t.Fatalf("fill gift must cap level 1 progress and enter ignited: %+v", rocket) 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) t.Fatalf("rocket timers must use UTC business time: %+v", rocket)
} }
if rocket.GetTop1UserId() != ownerID || rocket.GetIgniterUserId() != ownerID { if pending.GetTop1UserId() != ownerID || pending.GetIgniterUserId() != ownerID {
t.Fatalf("top1 and igniter must lock at ignited start: %+v", rocket) t.Fatalf("top1 and igniter must lock at ignited start: %+v", pending)
} }
progressEvents := rocketProgressEvents(t, ctx, repository) progressEvents := rocketProgressEvents(t, ctx, repository)
if len(progressEvents) != 1 || progressEvents[0].GetAddedFuel() != 150 || progressEvents[0].GetEffectiveAddedFuel() != 100 || progressEvents[0].GetOverflowFuel() != 50 { if len(progressEvents) != 1 || progressEvents[0].GetAddedFuel() != 250 || progressEvents[0].GetEffectiveAddedFuel() != 100 || progressEvents[0].GetOverflowFuel() != 150 {
t.Fatalf("progress event must expose effective energy and discarded overflow: %+v", progressEvents) 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 { if got := countRocketIgnitedEvents(t, ctx, repository); got != 1 {
t.Fatalf("fill gift must emit one ignited event, got %d", got) t.Fatalf("fill gift must emit one ignited event, got %d", got)
} }
countdownResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ nextResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, viewerID, "ignited"), Meta: rocketMeta(roomID, viewerID, "next-level"),
TargetType: "user", TargetType: "user",
TargetUserId: ownerID, TargetUserId: ownerID,
GiftId: "gift-during-ignited", GiftId: "gift-next-level",
GiftCount: 1, GiftCount: 1,
}) })
if err != nil { 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 { if nextResp.GetRocket().GetCurrentLevel() != 2 || nextResp.GetRocket().GetCurrentFuel() != 50 || nextResp.GetRocket().GetStatus() != state.RocketStatusCharging {
t.Fatalf("ignited gift must not change rocket progress: %+v", countdownResp.GetRocket()) 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 { if len(nextResp.GetRocket().GetPendingLaunches()) != 1 {
t.Fatalf("ignited invalid energy must not emit a second progress event, got %d", got) 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 { 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() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)} 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) t.Fatalf("process outbox failed: %v", err)
} }
if len(scheduler.schedules) != 1 { 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] scheduled := scheduler.schedules[0]
if scheduled.RoomID != roomID || scheduled.Level != 1 || scheduled.LaunchAtMS != now.Now().Add(time.Second).UnixMilli() || scheduled.CommandID == "" { if scheduled.RoomID != roomID || scheduled.Level != 1 || scheduled.LaunchAtMS != now.Now().Add(time.Second).UnixMilli() || scheduled.ResetAtMS == 0 || scheduled.CommandID == "" {
t.Fatalf("unexpected delayed open schedule: %+v", scheduled) t.Fatalf("unexpected delayed launch schedule: %+v", scheduled)
} }
} }
@ -288,7 +302,7 @@ func TestRoomRocketLaunchDueEarlyMessageDoesNotPoisonCommandID(t *testing.T) {
svc := newRocketTestService(t, repository, wallet, now) svc := newRocketTestService(t, repository, wallet, now)
writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000)) writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000))
roomID := "room-rocket-mq-open" roomID := "room-rocket-mq-launch"
ownerID := int64(901) ownerID := int64(901)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9009) createRocketRoom(t, ctx, svc, roomID, ownerID, 9009)
resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
@ -302,14 +316,19 @@ func TestRoomRocketLaunchDueEarlyMessageDoesNotPoisonCommandID(t *testing.T) {
t.Fatalf("ignite rocket failed: %v", err) t.Fatalf("ignite rocket failed: %v", err)
} }
rocket := resp.GetRocket() 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{ due := roommq.RoomRocketLaunchDueMessage{
MessageType: roommq.MessageTypeRoomRocketLaunchDue, MessageType: roommq.MessageTypeRoomRocketLaunchDue,
AppCode: appcode.Default, AppCode: appcode.Default,
RoomID: roomID, RoomID: roomID,
RocketID: rocket.GetRocketId(), RocketID: pending.GetRocketId(),
Level: rocket.GetCurrentLevel(), Level: pending.GetLevel(),
LaunchAtMS: rocket.GetLaunchAtMs(), LaunchAtMS: pending.GetLaunchAtMs(),
CommandID: "cmd_room_rocket_launch_" + rocket.GetRocketId(), ResetAtMS: pending.GetResetAtMs(),
CommandID: "cmd_room_rocket_launch_" + pending.GetRocketId(),
} }
if err := svc.HandleRoomRocketLaunchDue(ctx, due); err == nil { if err := svc.HandleRoomRocketLaunchDue(ctx, due); err == nil {
@ -317,7 +336,7 @@ func TestRoomRocketLaunchDueEarlyMessageDoesNotPoisonCommandID(t *testing.T) {
} }
now.now = now.now.Add(time.Second) now.now = now.now.Add(time.Second)
if err := svc.HandleRoomRocketLaunchDue(ctx, due); err != nil { 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{ info, err := svc.GetRoomRocket(ctx, &roomv1.GetRoomRocketRequest{
RoomId: roomID, RoomId: roomID,
@ -330,11 +349,11 @@ func TestRoomRocketLaunchDueEarlyMessageDoesNotPoisonCommandID(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("get rocket failed: %v", err) t.Fatalf("get rocket failed: %v", err)
} }
if info.GetRocket().GetState().GetStatus() != state.RocketStatusCharging || info.GetRocket().GetState().GetCurrentLevel() != 2 { if info.GetRocket().GetState().GetStatus() != state.RocketStatusCharging || info.GetRocket().GetState().GetCurrentLevel() != 2 || len(info.GetRocket().GetState().GetPendingLaunches()) != 0 {
t.Fatalf("rocket should open once after early retry: %+v", info.GetRocket().GetState()) t.Fatalf("rocket should launch once after early retry: %+v", info.GetRocket().GetState())
} }
if len(wallet.grants) == 0 { 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) svc := newRocketTestService(t, repository, wallet, now)
writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000)) writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000))
roomID := "room-rocket-open" roomID := "room-rocket-launch"
ownerID := int64(301) ownerID := int64(301)
igniterID := int64(302) igniterID := int64(302)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9002) createRocketRoom(t, ctx, svc, roomID, ownerID, 9002)
@ -369,7 +388,7 @@ func TestRoomRocketLaunchGrantsLockedTop1AndIgniterAfterLeave(t *testing.T) {
now.now = now.now.Add(1100 * time.Millisecond) now.now = now.now.Add(1100 * time.Millisecond)
if err := svc.SweepRoomRocketLaunchings(ctx); err != nil { 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) { if !hasRocketGrant(wallet.grants, igniterID, 2001) || !hasRocketGrant(wallet.grants, igniterID, 3001) {
@ -388,14 +407,14 @@ func TestRoomRocketLaunchGrantsLockedTop1AndIgniterAfterLeave(t *testing.T) {
ViewerUserId: ownerID, ViewerUserId: ownerID,
}) })
if err != nil { 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() stateView := info.GetRocket().GetState()
if stateView.GetCurrentLevel() != 2 || stateView.GetStatus() != state.RocketStatusCharging { if stateView.GetCurrentLevel() != 2 || stateView.GetStatus() != state.RocketStatusCharging || len(stateView.GetPendingLaunches()) != 0 {
t.Fatalf("opened rocket must advance to next idle level: %+v", stateView) t.Fatalf("launch rocket must keep current next-level progress and clear pending launch: %+v", stateView)
} }
if len(stateView.GetLastRewards()) != 3 { 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}) { if got := rocketRewardUserIDs(stateView.GetLastRewards()); !slices.Equal(got, []int64{301, 302, 302}) {
t.Fatalf("reward users mismatch: got %+v rewards=%+v", got, stateView.GetLastRewards()) t.Fatalf("reward users mismatch: got %+v rewards=%+v", got, stateView.GetLastRewards())
@ -429,8 +448,8 @@ func TestRoomRocketUTCResetCancelsCrossDayIgnited(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("send cross-day gift failed: %v", err) t.Fatalf("send cross-day gift failed: %v", err)
} }
if fillResp.GetRocket().GetStatus() != state.RocketStatusIgnited || fillResp.GetRocket().GetLaunchAtMs() <= fillResp.GetRocket().GetResetAtMs() { if len(fillResp.GetRocket().GetPendingLaunches()) != 1 || fillResp.GetRocket().GetPendingLaunches()[0].GetLaunchAtMs() <= fillResp.GetRocket().GetResetAtMs() {
t.Fatalf("fixture must create a ignited whose open time crosses UTC reset: %+v", fillResp.GetRocket()) 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) 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()) }, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
} }
func rocketConfigForTest(threshold int64, openDelayMS int64) roomservice.RoomRocketConfig { func rocketConfigForTest(threshold int64, launchDelayMS int64) roomservice.RoomRocketConfig {
levels := make([]roomservice.RoomRocketLevelConfig, 0, 7) levels := make([]roomservice.RoomRocketLevelConfig, 0, 5)
for level := int32(1); level <= 7; level++ { for level := int32(1); level <= 5; level++ {
levels = append(levels, roomservice.RoomRocketLevelConfig{ levels = append(levels, roomservice.RoomRocketLevelConfig{
Level: level, Level: level,
FuelThreshold: threshold + int64(level-1)*100, FuelThreshold: threshold + int64(level-1)*100,
CoverURL: fmt.Sprintf("https://example.test/rocket/%d-cover.png", level), CoverURL: fmt.Sprintf("https://example.test/rocket/%d-cover.png", level),
AnimationURL: fmt.Sprintf("https://example.test/rocket/%d-idle.webp", level), AnimationURL: fmt.Sprintf("https://example.test/rocket/%d-idle.webp", level),
LaunchAnimationURL: fmt.Sprintf("https://example.test/rocket/%d-opening.webp", level), LaunchAnimationURL: fmt.Sprintf("https://example.test/rocket/%d-launch.webp", level),
LaunchedImageURL: fmt.Sprintf("https://example.test/rocket/%d-open.png", level), LaunchedImageURL: fmt.Sprintf("https://example.test/rocket/%d-launched.png", level),
InRoomRewards: []roomservice.RoomRocketRewardItem{{ InRoomRewards: []roomservice.RoomRocketRewardItem{{
RewardItemID: fmt.Sprintf("level-%d-in-room", level), RewardItemID: fmt.Sprintf("level-%d-in-room", level),
ResourceGroupID: 1000 + int64(level), ResourceGroupID: 1000 + int64(level),
@ -523,8 +542,8 @@ func rocketConfigForTest(threshold int64, openDelayMS int64) roomservice.RoomRoc
AppCode: appcode.Default, AppCode: appcode.Default,
Enabled: true, Enabled: true,
ConfigVersion: 1, ConfigVersion: 1,
FuelSource: "gift_point_added", FuelSource: "heat_value",
LaunchDelayMS: openDelayMS, LaunchDelayMS: launchDelayMS,
BroadcastEnabled: true, BroadcastEnabled: true,
BroadcastScope: "region", BroadcastScope: "region",
RewardStackPolicy: "allow_stack", RewardStackPolicy: "allow_stack",

View File

@ -166,7 +166,7 @@ type RocketPendingLaunch struct {
IgniterUserID int64 IgniterUserID int64
// ConfigVersion 锁定本枚火箭的配置版本CoverURL 给区域飘屏和客户端展示使用。 // ConfigVersion 锁定本枚火箭的配置版本CoverURL 给区域飘屏和客户端展示使用。
ConfigVersion int64 ConfigVersion int64
CoverURL string CoverURL string
} }
// RocketState 保存 Room Cell 内当前火箭进度和最近一次发射结果。 // RocketState 保存 Room Cell 内当前火箭进度和最近一次发射结果。