16 KiB
语音房宝箱功能架构
本文定义当前语音房宝箱的产品规则、服务边界、App 接口、后台配置、MQ 唤醒、room outbox 事件和验收边界。宝箱是房间内送礼驱动的互动玩法:用户在语音房送礼积攒能量,当前等级满能量后进入倒计时,倒计时结束时由 room-service 按 Room Cell 快照结算并调用 wallet-service 发放资源组奖励。所有周期、重置和统计口径统一使用 UTC。
当前实现结论
| 能力 | 当前事实 |
|---|---|
| App 初始化 | GET /api/v1/rooms/{room_id}/treasure 返回 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 消费 RoomTreasureCountdownStarted,按 broadcast_scope 发区域或全局播报 |
| 事件分发 | room_outbox 先落 MySQL,再由 outbox worker 发布 RocketMQ;activity、notice、IM bridge 各自 consumer group 消费 |
Non-Goals
| 不做 | 原因 |
|---|---|
| 客户端提交能量、奖励或中奖结果 | 能量和奖励必须来自服务端扣费、配置和确定性抽奖 |
| 只用 Redis 保存宝箱进度 | MySQL snapshot、command log 和 room_outbox 才是恢复来源 |
| activity-service 判断谁在房 | 房间 presence 和开箱快照必须由 Room Cell 产出 |
| activity-service 发放宝箱奖励 | 当前发奖在 room-service 开箱命令内调用 wallet-service,避免跨服务二次改写房间结算事实 |
| cron-service 到点开箱 | 到点唤醒属于房间事件消费和 Room Cell 命令触发,不是通用 cron 任务 |
| 倒计时期间继续排队能量 | 产品规则明确无效,不累加到下一个宝箱 |
服务边界
| 模块 | 拥有 | 不拥有 |
|---|---|---|
room-service |
宝箱进度、等级、状态机、点火人、贡献第一、开箱在线用户快照、抽奖结果、房间宝箱 IM outbox、RocketMQ 延迟开箱唤醒消费 | 用户完整资料、全局/区域播报群成员、钱包余额账本 |
wallet-service |
送礼扣费、资源组发放、钱包账务、发放幂等 | 宝箱进度、中奖资格、房间 presence |
activity-service |
满能量后区域/全局播报、成长值等房间事件派生消费 | Room Cell 状态、宝箱奖励结算 |
notice-service |
通过 room outbox/MQ 消费需要私信的房间事实,写自己的投递位点和死信 | 房间群系统消息、群成员控制、房间状态 |
gateway-service |
App HTTP envelope、鉴权、协议转换、调用 room gRPC | 能量、抽奖和发奖事实 |
server/admin |
后台菜单、配置表单、审计、调用 owner service 管理接口 | 直接写 room/wallet/activity 业务表 |
宝箱进度和开箱必须经过 Room Cell 命令链路。MQ 只承担“到点唤醒”和“已提交事实分发”,不能成为宝箱状态 owner。
状态机
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 |
当前等级可累积能量 |
countdown |
当前等级已满,open_at_ms 已确定,本轮进度、点火人、贡献第一和配置版本锁定 |
opened |
本轮已开箱,奖励已在命令内完成抽取和资源组发放请求 |
exhausted |
当天 7 级都已开启,等下一个 UTC 自然日 |
当前实现没有单独持久化 round_id 表。box_id 是单轮宝箱幂等和奖励选择的核心 ID,开箱命令 ID 固定为:
cmd_room_treasure_open_<box_id>
奖励发放命令 ID 固定由 box_id + role + user_id + reward_item_id 派生,重复开箱或 MQ 重投不会重复发放同一条奖励。
能量规则
| 规则 | 决策 |
|---|---|
| 能量来源 | 默认使用 DebitGiftResponse.gift_point_added;后台可配置 gift_id 或 gift_type_code 的倍率、覆盖值和排除规则 |
| 生效条件 | wallet 扣费成功、Room Cell 提交 SendGift、礼物能量值大于 0 |
| 幂等 | 同一 SendGift.command_id 只能增加一次宝箱能量 |
| 点火人 | 第一笔让当前等级 progress >= threshold 的送礼用户 |
| 贡献第一 | 当前等级倒计时前累计有效能量最高用户 |
| 溢出能量 | 单笔礼物超过当前等级剩余阈值时,只计入补满当前等级所需能量,多余能量作废 |
| 倒计时送礼 | 当前等级已经满能量,继续送礼不再增加宝箱能量,也不会排队到下一等级 |
| 7 级后送礼 | 当天不再累计宝箱能量,礼物仍正常扣费、热度和榜单照常更新 |
无效能量只进入 RoomTreasureProgressChanged 的审计字段,不计入当前等级贡献、不计入下一等级进度、不改变点火人或贡献第一。
开箱与 MQ 唤醒
sequenceDiagram
participant C as App
participant R as room-service
participant W as wallet-service
participant DB as hyapp_room
participant MQ as RocketMQ
participant A as activity-service
participant IM as Tencent IM
C->>R: SendGift(command_id)
R->>W: DebitGift(command_id)
W-->>R: gift_point_added, heat_value
R->>R: Room Cell updates treasure
R->>DB: command_log + snapshot + room_outbox
R-->>C: SendGiftResponse.treasure
R->>DB: outbox worker claims RoomTreasureCountdownStarted
R->>MQ: delayed RoomTreasureOpenDue(open_at_ms)
R->>MQ: room_outbox event
MQ-->>A: RoomTreasureCountdownStarted
A-->>IM: region/global broadcast
MQ-->>R: RoomTreasureOpenDue at open_at_ms
R->>R: OpenRoomTreasure 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_treasure_open_scan_interval只扫描本节点已加载且仍持有 lease 的 Cell,是兜底,不解决未加载房间;未加载房间依赖 MQ delayed wakeup。
奖励规则
| 奖励角色 | 资格 |
|---|---|
in_room |
开箱命令执行时 Room Cell OnlineUsers 内的每个用户 |
top1 |
倒计时开始时锁定的贡献第一用户 |
igniter |
倒计时开始时锁定的点火人 |
奖励配置按等级拆分,每个角色是一个加权奖励池。奖励项引用 resource_group_id,由 wallet-service 按资源组发放金币、钻石或资源权益。
{
"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:
| 策略 | 语义 |
|---|---|
allow_stack |
同一用户可同时获得在房、top1、igniter 多个角色奖励 |
priority_only |
同一用户只获得优先级最高的一份,优先级为 igniter -> top1 -> in_room |
App 接口
获取宝箱信息
GET /api/v1/rooms/{room_id}/treasure
Authorization: Bearer <access_token>
Response data 核心字段:
{
"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 已携带 treasure 状态,送礼人成功送礼后可立即更新宝箱 UI,不必等待 IM。
房间 IM 事件
房间内宝箱 IM 走腾讯云房间群 TIMCustomElem,由 room outbox 事件转换。当前事件类型:
| Event | 客户端 event_type |
用途 |
|---|---|---|
RoomTreasureProgressChanged |
room_treasure_progress_changed |
宝箱进度增加 |
RoomTreasureCountdownStarted |
room_treasure_countdown_started |
满能量进入倒计时 |
RoomTreasureOpened |
room_treasure_opened |
宝箱打开并给出公开奖励摘要 |
RoomTreasureRewardGranted |
room_treasure_reward_granted |
发奖结果事件,包含奖励列表 |
Progress Changed
{
"event_type": "room_treasure_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
{
"event_type": "room_treasure_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
{
"event_type": "room_treasure_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 消费 RoomTreasureRewardGranted,不要让 room-service 同步投递私信。
后台配置
后台配置带 config_version。倒计时开始后,当前宝箱的 box_id、等级、进度、open_at_ms、点火人和贡献第一不再因配置修改而变化;当前实现开箱时读取最新启用配置的奖励池,因此运营在倒计时窗口内修改奖励配置要按发布窗口处理。
| 字段 | 含义 |
|---|---|
enabled |
总开关 |
energy_source |
默认 gift_point_added |
open_delay_ms |
满能量到开箱的倒计时 |
broadcast_enabled |
是否满能量后播报 |
broadcast_scope |
none/region/global |
broadcast_delay_ms |
满能量后多久发播报,当前代码写入配置和读模型,实际倒计时事件立即进入 room outbox |
reward_stack_policy |
allow_stack 或 priority_only |
levels |
7 个等级配置,包含阈值、物料 URL 和三类奖励池 |
gift_energy_rules |
gift_id 或 gift_type_code 的能量倍率、覆盖值和排除规则 |
updated_by_admin_id |
审计字段 |
奖励池保存时应校验:
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_treasure_open |
room-service treasure open scheduler |
room-service treasure open consumer |
推荐线上配置:
outbox_worker:
enabled: true
publish_mode: "mq"
rocketmq:
enabled: true
room_outbox:
enabled: true
topic: "hyapp_room_outbox"
treasure_open:
enabled: true
topic: "hyapp_room_treasure_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.treasure 返回最新状态 |
| 单笔溢出 | 进度封顶当前阈值,溢出能量作废,不进入下一等级 |
| 倒计时送礼 | 礼物正常扣费和加热度,但宝箱进度、点火人、贡献第一不变化 |
| 满能量 | 写 RoomTreasureCountdownStarted,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_treasure.go |
| MQ delayed open | services/room-service/internal/room/service/room_treasure_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/roomtreasure |