22 KiB
语音房宝箱功能架构
本文定义语音房宝箱的产品规则、服务边界、App 接口、IM 事件、后台配置、奖励结算和验收边界。宝箱是房间内送礼驱动的互动玩法:用户在语音房送礼积攒能量,当前等级宝箱能量满后进入倒计时,倒计时结束时按房间快照抽奖并发放奖励。所有周期、重置和统计口径统一使用 UTC。
Goals
| 目标 | 规则 |
|---|---|
| 礼物积攒能量 | 只有 SendGift 扣费成功并落 Room Cell 后才增加宝箱能量 |
| 7 级宝箱 | 每个 UTC 自然日从 1 级开始,最多开启 7 个等级 |
| 满能量倒计时开启 | 能量达到当前等级阈值后锁定本轮,按后台 open_delay_ms 设置 open_at_ms |
| 满能量播报 | 满能量后立即或按后台延迟发起播报,播报范围支持 none/region/global |
| 开箱抽奖 | open_at_ms 到达后快照房间在线用户、点火人和贡献第一,生成奖励结算事实 |
| 自动发奖 | 奖励由 activity-service 幂等抽奖并调用 wallet-service 发放资源组,不需要 App 手动领取 |
| UTC 重置 | 进度、贡献和已开启等级按 UTC 日期重置,不使用服务器本地时区或客户端时区 |
Non-Goals
| 不做 | 原因 |
|---|---|
| 客户端提交能量、奖励或中奖结果 | 能量和奖励必须来自服务端扣费、配置和随机结果 |
| 在 room-service 保存钱包余额或发奖账本 | 钱包资产和资源发放仍由 wallet-service 拥有 |
| 由 activity-service 判断谁在房 | 房间 presence 和开箱快照必须由 Room Cell 产出 |
| 只用 Redis 保存宝箱进度 | MySQL snapshot/command log/宝箱表才是恢复来源 |
| 宝箱发奖失败时提前展示已到账 | 客户端只能展示 wallet-service 已成功发放的奖励 |
Missing Interfaces
你的描述已经覆盖了核心 App 查询和 3 类 IM,但还缺这些接口和后台能力。
| 类型 | 接口 | 必要性 |
|---|---|---|
| App 查询 | GET /api/v1/rooms/{room_id}/treasure-chest |
房间页主动同步宝箱配置、当前进度、倒计时、重置时间和最近结算 |
| App 查询 | GET /api/v1/rooms/{room_id}/treasure-chest/rewards?round_id=...&scope=mine|public |
用户重连或错过 IM 后查询自己奖励、公开奖励摘要和发奖状态 |
| App 查询 | JoinRoom / RoomDetail 可内嵌 treasure_chest_summary |
避免进房首屏先空白,再等单独接口返回 |
| App 命令响应 | SendGiftResponse 增加 treasure_chest_delta |
送礼人成功送礼后立即更新能量和倒计时,不只依赖异步 IM |
| Admin 配置 | GET/PUT /api/v1/admin/room-treasure/config |
配置开关、7 级阈值、物料、倒计时、播报范围、奖励池和生效版本 |
| Admin 审计 | GET /api/v1/admin/room-treasure/rounds |
排查某房某天宝箱进度、点火人、贡献第一和状态 |
| Admin 审计 | GET /api/v1/admin/room-treasure/reward-records |
查询用户中奖、发奖、失败和重试记录 |
| Admin 补偿 | POST /api/v1/admin/room-treasure/rounds/{round_id}/retry-settlement |
对抽奖或 wallet 发放失败的轮次做幂等补偿 |
| Internal room | ProcessDueTreasureChests |
cron 或 room-service worker 触发到期开箱,不能依赖单机内存 timer |
| Internal room | ApplyTreasureChestSettlement |
activity-service 发奖完成后回写结算摘要,由 room-service 发房间发奖 IM |
| Internal activity | ExecuteTreasureChestSettlement 或消费 RoomTreasureChestOpened outbox |
按 Room Cell 快照做抽奖、写奖励记录、调用 wallet 发奖 |
| Internal wallet | 复用 GrantResourceGroup,后续补 BatchGrantResourceGroup |
每个在房用户都可能获奖,首版可逐个幂等发放,大房间需要批量优化 |
如果坚持“所有奖励完全自动到账”,不需要 App claim 接口;如果产品改成手动领取,必须新增 POST /api/v1/rooms/{room_id}/treasure-chest/rewards/{reward_id}/claim,并重新定义过期和未领取回收规则。
Ownership
| 模块 | 拥有 | 不拥有 |
|---|---|---|
room-service |
宝箱进度、等级、状态机、点火人、贡献榜、开箱在线用户快照、房间宝箱 IM | 奖励实际入账、用户完整资料、全局/区域播报群成员 |
activity-service |
宝箱奖励抽奖、奖励记录、发奖重试、全局/区域播报 outbox | Room Cell presence、房间核心状态 |
wallet-service |
资源组发放、金币/钻石入账、钱包 outbox、幂等账本 | 宝箱进度和中奖资格 |
gateway-service |
App HTTP envelope、鉴权、聚合 room/activity/wallet 读模型 | 能量、抽奖和发奖事实 |
server/admin |
后台权限、表单校验、审计、调用 owner service 管理 RPC | 直接写 room/activity/wallet 业务表 |
宝箱进度必须经过 Room Cell 命令链路。activity-service 可以消费 room outbox 做奖励结算和播报,但不能自己判断房间内用户、不能直接改宝箱进度。
State Machine
stateDiagram-v2
[*] --> disabled
disabled --> charging: config enabled
charging --> countdown: energy >= threshold
countdown --> opening: now >= open_at_ms
opening --> settling: RoomTreasureChestOpened outbox committed
settling --> settled: rewards terminal
settled --> charging: next level exists
settled --> exhausted: level 7 settled
exhausted --> charging: next UTC day
charging --> charging: UTC daily reset
| 状态 | 语义 |
|---|---|
disabled |
后台关闭,不累计能量,不发宝箱 IM |
charging |
当前等级可累积能量 |
countdown |
当前等级已满,open_at_ms 已确定,本轮进度、点火人和配置版本锁定 |
opening |
到期开箱命令正在 Room Cell 内提交,快照参与用户 |
settling |
activity-service 正在抽奖和调用 wallet 发奖 |
settled |
本轮奖励达到终态,房间已发结算 IM |
exhausted |
当天 7 级都已开启,等下一个 UTC 自然日 |
round_id 建议稳定生成:
treasure:<room_id>:<cycle_day>:<level>:<sequence>
cycle_day 固定为 UTC YYYY-MM-DD。同一房间同一 UTC 日同一等级只允许一个 active round。
Energy Rules
| 规则 | 决策 |
|---|---|
| 能量来源 | 默认使用 DebitGiftResponse.gift_point_added;后台可配置 gift_id 级别倍率或覆盖值 |
| 生效条件 | wallet 扣费成功、room-service 已提交 RoomGiftSent、礼物能量值大于 0 |
| 幂等 | 同一 SendGift.command_id 只能增加一次宝箱能量 |
| 点火人 | 第一笔让当前等级 progress >= threshold 的送礼用户 |
| 贡献第一 | 当前 round 内累计能量最高用户;同分按首次达到该分值时间,再按 user_id 升序 |
| 溢出能量 | 单笔礼物超过当前等级剩余阈值时,只计入补满当前等级所需能量,多余能量作废 |
| 倒计时期间送礼 | 当前等级已经满能量,继续送礼不再增加宝箱能量,也不会排队到下一等级 |
| 7 级后送礼 | exhausted 后当天不再累计宝箱能量,礼物仍正常扣费、热度和榜单照常 |
溢出和倒计时期间的无效能量只用于审计,不计入当前 round 贡献、不计入下一等级进度、不改变点火人或贡献第一。送礼本身仍正常扣费、热度和房间礼物榜照常更新。
Opening Rules
| 场景 | 处理 |
|---|---|
| 满能量 | Room Cell 把 round 切到 countdown,写 RoomTreasureChestCountdownStarted outbox |
| 播报 | activity-service 消费 countdown 事件,按配置发 region/global 播报;失败只重试播报,不回滚宝箱 |
| 到期开箱 | room-service worker 或 cron-service 触发 ProcessDueTreasureChests,由 Room Cell 提交开箱命令 |
| 开箱快照 | 以开箱命令执行时的 OnlineUsers 为准生成在房奖励参与人 |
| 房间关闭 | 如果已有 countdown round,CloseRoom 必须先提交开箱快照或取消策略;推荐先快照结算,再关闭房间 |
| 房间无人 | 在房奖励参与人为空;点火人和贡献第一仍按 round 贡献事实抽奖 |
点火人和贡献第一不要求开箱时仍在房或在线。只要用户在本 round 已经形成点火人或贡献第一事实,即使开箱前离房、断线或下线,也必须保留对应角色奖励;在房奖励仍只发给开箱快照里的 OnlineUsers。
Reward Rules
| 奖励槽位 | 资格 | 默认是否可与其他槽位叠加 |
|---|---|---|
in_room |
开箱时 Room Cell OnlineUsers 内的每个用户 |
可叠加 |
top1 |
当前 round 贡献第一用户 | 可叠加 |
igniter |
当前 round 点火人 | 可叠加 |
奖励配置按等级拆分,每个槽位是一个加权奖励池。奖励项推荐引用 resource_group_id,由 wallet-service 按资源组快照发放金币、钻石或资源权益。
{
"level": 3,
"slot": "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"
}
]
}
发奖幂等键:
treasure_reward:<round_id>:<slot>:<user_id>
activity-service 对每个奖励记录先写 treasure_chest_reward_records,再调用 wallet-service。wallet 成功后才能把该记录标记为 granted。失败进入 retryable/failed,后台补偿必须复用同一幂等键。
App Interfaces
Get Treasure Chest
GET /api/v1/rooms/{room_id}/treasure-chest
Authorization: Bearer <access_token>
Response data:
{
"enabled": true,
"server_time_ms": 1779120000000,
"reset_at_ms": 1779148800000,
"config_version": 12,
"current": {
"cycle_day": "2026-05-19",
"round_id": "treasure:lalu_room_1:2026-05-19:3:1",
"level": 3,
"status": "charging",
"progress": 3600,
"threshold": 10000,
"progress_ppm": 360000,
"opened_level_count": 2,
"open_at_ms": 0,
"igniter_user_id": "0",
"top1_user_id": "10001"
},
"levels": [
{
"level": 1,
"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",
"rewards": {
"in_room": [],
"top1": [],
"igniter": []
}
}
],
"latest_round": {
"round_id": "treasure:lalu_room_1:2026-05-19:2:1",
"level": 2,
"status": "settled",
"opened_at_ms": 1779116400000,
"settled_at_ms": 1779116401200,
"my_rewards": []
}
}
规则:
levels固定返回 7 个等级的物料和奖励展示配置。reset_at_ms是下一个 UTC 零点,不是用户本地零点。progress_ppm = floor(progress * 1000000 / threshold),客户端不自行除浮点。- 倒计时期间送礼和单笔溢出的无效能量不返回为下一等级进度;客户端只展示服务端返回的当前等级进度。
Get Treasure Rewards
GET /api/v1/rooms/{room_id}/treasure-chest/rewards?round_id=treasure:...&scope=mine
Authorization: Bearer <access_token>
Response data:
{
"round_id": "treasure:lalu_room_1:2026-05-19:3:1",
"level": 3,
"status": "settled",
"reward_status": "granted",
"items": [
{
"reward_record_id": "treasure_reward:treasure:lalu_room_1:2026-05-19:3:1:in_room:10001",
"slot": "in_room",
"user_id": "10001",
"resource_group_id": 12001,
"display_name": "Room Reward",
"icon_url": "https://cdn.example/reward.png",
"grant_status": "granted",
"granted_at_ms": 1779120012000
}
],
"next_page_token": ""
}
scope=public 只返回公开摘要,例如点火人、贡献第一和最多 N 条可展示奖励;不要把所有用户的私派奖励大列表塞进 IM payload。
IM Contracts
房间内宝箱 IM 仍走腾讯云房间群 TIMCustomElem,公共字段保持稳定:
{
"event_id": "room_treasure:...",
"event_type": "room_treasure_progress_changed",
"room_id": "lalu_room_1",
"room_version": 102,
"round_id": "treasure:lalu_room_1:2026-05-19:3:1",
"level": 3,
"cycle_day": "2026-05-19",
"server_time_ms": 1779120000000
}
Progress Changed
event_type=room_treasure_progress_changed
{
"sender_user_id": "10001",
"gift_id": "rose",
"gift_count": 10,
"energy_added": 500,
"invalid_energy": 0,
"progress_before": 3100,
"progress_after": 3600,
"threshold": 10000,
"status": "charging",
"top1_user_id": "10001",
"reset_at_ms": 1779148800000
}
Countdown Started
event_type=room_treasure_countdown_started
{
"progress_after": 10000,
"threshold": 10000,
"igniter_user_id": "10002",
"top1_user_id": "10001",
"full_at_ms": 1779120000000,
"open_at_ms": 1779120030000,
"broadcast_scope": "region"
}
该事件进入 room outbox 后,activity-service 按配置生成全局或区域播报:
{
"event_id": "treasure_broadcast:treasure:lalu_room_1:2026-05-19:3:1",
"broadcast_type": "room_treasure_ready",
"scope": "region",
"region_id": 1001,
"room_id": "lalu_room_1",
"round_id": "treasure:lalu_room_1:2026-05-19:3:1",
"level": 3,
"open_at_ms": 1779120030000,
"action": {
"type": "enter_room",
"room_id": "lalu_room_1"
}
}
Chest Opened
event_type=room_treasure_opened
{
"opened_at_ms": 1779120030000,
"participant_count": 86,
"igniter_user_id": "10002",
"top1_user_id": "10001",
"settlement_status": "pending"
}
Rewards Settled
event_type=room_treasure_rewards_settled
{
"settled_at_ms": 1779120031200,
"reward_status": "granted",
"participant_reward_count": 86,
"top1_user_id": "10001",
"igniter_user_id": "10002",
"public_rewards": [
{
"slot": "top1",
"user_id": "10001",
"display_name": "Top1 Reward",
"icon_url": "https://cdn.example/reward.png"
},
{
"slot": "igniter",
"user_id": "10002",
"display_name": "Igniter Reward",
"icon_url": "https://cdn.example/reward.png"
}
],
"full_result_required": true
}
群 IM 只承载公开摘要。用户自己的完整发奖结果通过 GET /treasure-chest/rewards?scope=mine 查询;后续接 notice-service 时,可以再补私有单聊或 inbox 通知。
Admin Config
后台配置必须带 config_version,保存后只影响新 round;已经进入 countdown/opening/settling 的 round 使用锁定的配置快照。
| 字段 | 含义 |
|---|---|
enabled |
总开关 |
energy_source |
默认 gift_point_added |
open_delay_ms |
满能量到开箱的倒计时 |
broadcast_enabled |
是否满能量后播报 |
broadcast_scope |
none/region/global |
broadcast_delay_ms |
满能量后多久发播报,默认 0 |
reward_stack_policy |
allow_stack 或 priority_only |
levels |
7 个等级配置,包含阈值、物料 URL 和奖励池 |
gift_energy_rules |
gift_id 或 gift_type_code 的能量倍率、覆盖值和排除规则 |
effective_from_ms |
配置开始生效时间,UTC epoch ms |
updated_by_admin_id |
审计字段 |
每个等级必须配置:
| 字段 | 规则 |
|---|---|
level |
1 到 7,不能缺级 |
threshold |
大于 0,建议单调递增 |
cover_url |
静态封面 |
animation_url |
未开启待机动效 |
opening_animation_url |
开启倒计时或开箱动效 |
opened_image_url |
开启后静态图 |
in_room_rewards |
在房用户奖励池 |
top1_rewards |
贡献第一奖励池 |
igniter_rewards |
点火人奖励池 |
奖励池保存时应校验:
resource_group_id必须存在且 active。- 同一槽位权重总和必须大于 0。
- 展示字段可以保存快照,但发放仍以 wallet-service 资源组事实为准。
- 配置不能直接填写金币数量绕过资源组,除非新增明确的钱包奖励配置类型。
Internal Events
建议在 api/proto/events/room/v1/events.proto 增加:
| Event | 生产方 | 消费方 | 用途 |
|---|---|---|---|
RoomTreasureChestProgressChanged |
room-service | Tencent IM/activity audit | 房间内进度 IM、审计 |
RoomTreasureChestCountdownStarted |
room-service | Tencent IM/activity broadcast | 房间倒计时 IM、区域/全局播报 |
RoomTreasureChestOpened |
room-service | activity-service | 奖励抽奖和发放输入,包含参与人快照和奖励配置快照 |
RoomTreasureChestSettlementApplied |
room-service | Tencent IM/audit | 发奖完成后的房间 IM |
RoomTreasureChestOpened 必须包含:
round_id
cycle_day
level
config_version
room_id
visible_region_id
opened_at_ms
participant_user_ids
igniter_user_id
top1_user_id
contribution_top
reward_config_snapshot_json
activity-service 不反查 Room Cell 判断参与人,避免开奖时与房间状态发生竞态。
Data Model Sketch
room-service 持久化:
room_treasure_rounds(
app_code,
room_id,
cycle_day,
round_id,
level,
status,
progress,
threshold,
invalid_energy,
config_version,
igniter_user_id,
top1_user_id,
participant_count,
full_at_ms,
open_at_ms,
opened_at_ms,
settled_at_ms,
reset_at_ms,
created_at_ms,
updated_at_ms
);
room_treasure_contributions(
app_code,
round_id,
user_id,
energy,
first_contributed_at_ms,
last_contributed_at_ms
);
activity-service 持久化:
treasure_chest_settlements(
app_code,
settlement_id,
round_id,
room_id,
level,
status,
reward_config_snapshot_json,
participant_count,
created_at_ms,
updated_at_ms
);
treasure_chest_reward_records(
app_code,
reward_record_id,
settlement_id,
round_id,
slot,
user_id,
reward_item_id,
resource_group_id,
grant_command_id,
grant_status,
grant_id,
retry_count,
last_error,
created_at_ms,
updated_at_ms
);
Room Cell snapshot 也要包含当前宝箱轻量状态,保证节点恢复后不依赖 Redis 重建进度。表结构用于查询、due scan 和后台审计;command log 仍是写命令恢复的关键事实。
Daily Reset
| 规则 | 决策 |
|---|---|
| 日期来源 | time.Now().UTC(),cycle_day=YYYY-MM-DD |
| 重置时间 | 每天 UTC 00:00:00.000 |
| 时间区间 | [day_start_ms, next_day_start_ms) |
| charging round | 到达新 UTC 日时清空进度、贡献和 queued energy,创建新 day level 1 round |
| countdown/opening/settling round | 已满能量的 round 继续开箱和发奖,使用原 cycle_day |
| exhausted | 下一个 UTC 日恢复 level 1 |
必须补 UTC 边界测试:day_start_ms、next_day_start_ms - 1、next_day_start_ms。不要重新引入 time.Local、task_timezone 或客户端时区。
Implementation Flow
sequenceDiagram
participant C as Client
participant G as gateway-service
participant R as room-service
participant W as wallet-service
participant A as activity-service
participant IM as Tencent IM
C->>G: POST /api/v1/rooms/gift/send
G->>R: SendGift(command_id)
R->>W: DebitGift(command_id)
W-->>R: gift_point_added, receipt
R->>R: Room Cell updates heat/rank/treasure
R-->>C: SendGiftResponse + treasure summary
R-->>IM: room_treasure_progress_changed
alt energy full
R-->>IM: room_treasure_countdown_started
R-->>A: room outbox RoomTreasureChestCountdownStarted
A-->>IM: region/global room_treasure_ready broadcast
end
R->>R: ProcessDueTreasureChests at open_at_ms
R-->>IM: room_treasure_opened
R-->>A: room outbox RoomTreasureChestOpened
A->>A: draw rewards idempotently
A->>W: GrantResourceGroup per reward
W-->>A: grant result
A->>R: ApplyTreasureChestSettlement(summary)
R-->>IM: room_treasure_rewards_settled
ApplyTreasureChestSettlement 是 gRPC/protobuf 边界,不允许 activity-service import room-service internal 包。它只提交结算摘要和可展示公共结果,不让 activity-service 改写宝箱进度。
Failure Handling
| 失败 | 处理 |
|---|---|
| wallet DebitGift 失败 | 不增加能量,不写宝箱进度,不发宝箱 IM |
| room outbox 投递 IM 失败 | 房间状态已提交,outbox 重试或死信;App 可用 GET 接口补状态 |
| broadcast 失败 | activity broadcast outbox 重试,不影响开箱 |
| due open worker 宕机 | MySQL open_at_ms/status 可被下一轮 worker/cron 重新扫描 |
| activity 抽奖失败 | settlement 保持 retryable,不发“已发奖” IM |
| wallet 发奖部分失败 | 成功记录保持 granted,失败记录重试;房间 IM 标记 partial 或等待全部终态后再发 |
| App 错过 IM | GET /treasure-chest 和 GET /treasure-chest/rewards 补齐 |
Tests
必须覆盖:
| 场景 | 验证 |
|---|---|
| 送礼幂等 | 同一 command_id 重试不重复加能量 |
| 阈值边界 | progress = threshold - 1 不点火,progress = threshold 点火一次 |
| 溢出能量 | 单笔礼物超过阈值时只补满当前等级,剩余能量作废且不进入下一等级 |
| 倒计时送礼 | countdown/opening/settling 期间送礼不增加宝箱能量,不改变点火人和 top1 |
| 倒计时开箱 | open_at_ms 前不开,等于或超过时只开一次 |
| 在线快照 | 开箱时在线用户获得 in_room 抽奖资格,离房用户不获得 in_room 奖励 |
| top1/igniter | 同分 tie-break 固定;离房、断线或下线后仍结算角色奖励 |
| UTC 重置 | 覆盖 UTC 日开始、结束前一毫秒、结束边界 |
| room close | countdown 后关房不会丢掉已满宝箱奖励 |
| 发奖幂等 | reward_record 重试不重复发资源组 |
| 播报范围 | none/region/global 分别不发、发区域、发全局 |
| IM 补偿 | outbox 重试不会产生不同 event_id 的重复客户端消息 |