Configure room rocket launch messaging
This commit is contained in:
parent
3417ee44ef
commit
9001fce4b7
10
README.md
10
README.md
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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" +
|
||||||
|
|||||||
@ -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"`
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 不影响领取接口的后端金额校验 |
|
||||||
|
|||||||
@ -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` 重试,不生成新命令。 |
|
||||||
|
|
||||||
|
|||||||
@ -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` 是当前用户返奖入账后的余额。 |
|
||||||
|
|
||||||
发送后处理:
|
发送后处理:
|
||||||
|
|||||||
@ -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。
|
||||||
|
|||||||
@ -267,7 +267,7 @@ rocketmq:
|
|||||||
1. 钱包余额通知:已按 `walletnotice` 模块落地。
|
1. 钱包余额通知:已按 `walletnotice` 模块落地。
|
||||||
2. 房间踢人私有通知:已按 `roomnotice` 模块落地,只消费 `RoomUserKicked` 的 C2C 通知。
|
2. 房间踢人私有通知:已按 `roomnotice` 模块落地,只消费 `RoomUserKicked` 的 C2C 通知。
|
||||||
3. 幸运礼物中奖:仍由 `wallet-service` 写余额 outbox;notice 不需要新增特殊账务逻辑,只要客户端按 `balance_version` 更新。
|
3. 幸运礼物中奖:仍由 `wallet-service` 写余额 outbox;notice 不需要新增特殊账务逻辑,只要客户端按 `balance_version` 更新。
|
||||||
4. 语音房宝箱私有通知:如产品需要私聊提醒,可在 `roomnotice` 中消费 `RoomTreasureRewardGranted` 或由活动/系统消息模块转发;奖励入账事实仍由 room-service 调 wallet 完成,notice 只投通知。
|
4. 语音房火箭私有通知:如产品需要私聊提醒,可在 `roomnotice` 中消费 `RoomRocketRewardGranted` 或由活动/系统消息模块转发;奖励入账事实仍由 room-service 调 wallet 完成,notice 只投通知。
|
||||||
5. 活动/系统私有通知:新增 `activitynotice` 模块,读取 activity outbox,复用 `notice_delivery_events` 或新增同语义位点表。
|
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`,独立重试和死信。
|
||||||
|
|||||||
@ -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 状态、消费幂等和领域状态校验。
|
||||||
|
|
||||||
|
|||||||
@ -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` |
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -1,378 +1,125 @@
|
|||||||
# 语音房火箭功能架构
|
# 语音房火箭功能架构
|
||||||
|
|
||||||
本文定义当前语音房火箭的产品规则、服务边界、App 接口、后台配置、MQ 唤醒、room outbox 事件和验收边界。火箭是房间内送礼驱动的互动玩法:用户在语音房送礼积攒燃料,当前等级燃料满后进入倒计时,倒计时结束时由 `room-service` 按 Room Cell 快照结算并调用 `wallet-service` 发放资源组奖励。所有周期、重置和统计口径统一使用 UTC。
|
|
||||||
|
|
||||||
## 当前实现结论
|
|
||||||
|
|
||||||
| 能力 | 当前事实 |
|
|
||||||
| --- | --- |
|
|
||||||
| App 初始化 | `GET /api/v1/rooms/{room_id}/rocket` 返回 7 级物料、当前等级、当前进度、重置时间、奖励展示配置 |
|
|
||||||
| 送礼累积 | `SendGift` 同步调用 `wallet-service.DebitGift` 成功后,Room Cell 内更新热度、礼物榜和火箭进度 |
|
|
||||||
| 燃料满 | 当前等级进度封顶为阈值,锁定点火人、贡献第一、`box_id`、`open_at_ms` 和 UTC `reset_at_ms` |
|
|
||||||
| 溢出和倒计时送礼 | 当前等级溢出燃料作废;倒计时期间送礼不累加到下一级,也不改变点火人或贡献第一 |
|
|
||||||
| 到期发射 | `room-service` 通过 RocketMQ 延迟消息到 `open_at_ms` 唤醒;本地已加载 Cell 扫描 worker 仍作为兜底 |
|
|
||||||
| 奖励结算 | `room-service` 在发射命令内按配置加权抽取奖励,并调用 `wallet-service.GrantResourceGroup` 幂等发放 |
|
|
||||||
| 离房/下线 | `top1` 和 `igniter` 在倒计时开始时锁定;发射前离房或下线仍结算对应角色奖励 |
|
|
||||||
| 在房奖励 | 只发给发射命令执行时 Room Cell `OnlineUsers` 中的用户 |
|
|
||||||
| 播报 | `activity-service` 消费 `RoomRocketCountdownStarted`,按 `broadcast_scope` 发区域或全局播报 |
|
|
||||||
| 事件分发 | `room_outbox` 先落 MySQL,再由 outbox worker 发布 RocketMQ;activity、notice、IM bridge 各自 consumer group 消费 |
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
| 不做 | 原因 |
|
|
||||||
| --- | --- |
|
|
||||||
| 客户端提交燃料、奖励或中奖结果 | 燃料和奖励必须来自服务端扣费、配置和确定性抽奖 |
|
|
||||||
| 只用 Redis 保存火箭进度 | MySQL snapshot、command log 和 `room_outbox` 才是恢复来源 |
|
|
||||||
| activity-service 判断谁在房 | 房间 presence 和发射快照必须由 Room Cell 产出 |
|
|
||||||
| activity-service 发放火箭奖励 | 当前发奖在 `room-service` 发射命令内调用 `wallet-service`,避免跨服务二次改写房间结算事实 |
|
|
||||||
| cron-service 到点发射 | 到点唤醒属于房间事件消费和 Room Cell 命令触发,不是通用 cron 任务 |
|
|
||||||
| 倒计时期间继续排队燃料 | 产品规则明确无效,不累加到下一个火箭 |
|
|
||||||
|
|
||||||
## 服务边界
|
## 服务边界
|
||||||
|
|
||||||
| 模块 | 拥有 | 不拥有 |
|
- `room-service` 是火箭进度、点火、待发射队列和奖励结算 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 只投 RocketMQ,activity/notice/IM bridge 各自消费,推荐线上 |
|
|
||||||
| `dual` | direct + MQ 双写,只用于迁移验证,所有消费者必须幂等 |
|
|
||||||
|
|
||||||
## Daily Reset
|
|
||||||
|
|
||||||
| 规则 | 决策 |
|
|
||||||
| --- | --- |
|
|
||||||
| 日期来源 | `time.Now().UTC()` |
|
|
||||||
| 重置时间 | 每天 UTC `00:00:00.000` |
|
|
||||||
| 时间区间 | `[day_start_ms, next_day_start_ms)` |
|
|
||||||
| idle | 到达新 UTC 日时回到 level 1,清空进度和贡献 |
|
|
||||||
| countdown | 如果 `open_at_ms` 跨过 `reset_at_ms`,UTC 日界优先,旧火箭不再结算 |
|
|
||||||
| exhausted | 下一个 UTC 日恢复 level 1 |
|
|
||||||
|
|
||||||
必须覆盖 UTC 边界测试:`day_start_ms`、`next_day_start_ms - 1`、`next_day_start_ms`。不要重新引入 `time.Local`、`task_timezone` 或客户端时区。
|
|
||||||
|
|
||||||
## 验收清单
|
|
||||||
|
|
||||||
| 场景 | 验收 |
|
|
||||||
| --- | --- |
|
|
||||||
| 送礼成功 | wallet 扣费成功,Room Cell 进度增加,`SendGiftResponse.rocket` 返回最新状态 |
|
|
||||||
| 单笔溢出 | 进度封顶当前阈值,溢出燃料作废,不进入下一等级 |
|
|
||||||
| 倒计时送礼 | 礼物正常扣费和加热度,但火箭进度、点火人、贡献第一不变化 |
|
|
||||||
| 燃料满 | 写 `RoomRocketCountdownStarted`,outbox worker 安排 RocketMQ delayed open |
|
|
||||||
| 提前延迟消息 | 不写发射 no-op command log,MQ 后续可重试 |
|
|
||||||
| 到点发射 | 即使房间未加载,MQ 唤醒后 room-service 恢复 Room Cell 并发射 |
|
|
||||||
| top1/igniter 离房 | 仍发角色奖励 |
|
|
||||||
| 在房奖励 | 只发发射瞬间在线用户 |
|
|
||||||
| UTC 重置 | 到达 UTC 零点后进度回到 level 1,跨日倒计时不结算 |
|
|
||||||
| MQ/outbox 重投 | `event_id`、`box_id`、wallet grant command 保证幂等 |
|
|
||||||
|
|
||||||
## 代码位置
|
|
||||||
|
|
||||||
| 模块 | 路径 |
|
|
||||||
| --- | --- |
|
|
||||||
| 火箭状态机和结算 | `services/room-service/internal/room/service/room_rocket.go` |
|
|
||||||
| MQ delayed open | `services/room-service/internal/room/service/room_rocket_mq.go` |
|
|
||||||
| outbox worker | `services/room-service/internal/room/service/outbox_worker.go` |
|
|
||||||
| RocketMQ 适配 | `pkg/rocketmqx`、`pkg/roommq`、`services/room-service/internal/integration/rocketmq_outbox.go` |
|
|
||||||
| App HTTP | `services/gateway-service/internal/transport/http/roomapi` |
|
|
||||||
| 后台配置 | `server/admin/internal/modules/roomrocket` |
|
|
||||||
|
|||||||
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 走 MQ;room-service 同进程启动 IM bridge consumer 补偿房间群消息。
|
# Docker 和 testbox 走 MQ;room-service 同进程启动 IM bridge consumer 补偿房间群消息。
|
||||||
|
|||||||
@ -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,并顺带安排火箭延迟发射唤醒。
|
||||||
|
|||||||
@ -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 的异步投递通道;失败退避重试,超过上限转死信。
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 需要头像、昵称和短 ID,room-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
|
||||||
|
|||||||
@ -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{
|
||||||
|
|||||||
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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_added;GIFT_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,
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 内当前火箭进度和最近一次发射结果。
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user