# 语音房宝箱功能架构 本文定义语音房宝箱的产品规则、服务边界、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 ```mermaid 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` 建议稳定生成: ```text treasure:::: ``` `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 按资源组快照发放金币、钻石或资源权益。 ```json { "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" } ] } ``` 发奖幂等键: ```text treasure_reward::: ``` activity-service 对每个奖励记录先写 `treasure_chest_reward_records`,再调用 wallet-service。wallet 成功后才能把该记录标记为 `granted`。失败进入 `retryable/failed`,后台补偿必须复用同一幂等键。 ## App Interfaces ### Get Treasure Chest ```http GET /api/v1/rooms/{room_id}/treasure-chest Authorization: Bearer ``` Response `data`: ```json { "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 ```http GET /api/v1/rooms/{room_id}/treasure-chest/rewards?round_id=treasure:...&scope=mine Authorization: Bearer ``` Response `data`: ```json { "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`,公共字段保持稳定: ```json { "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` ```json { "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` ```json { "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 按配置生成全局或区域播报: ```json { "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` ```json { "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` ```json { "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` 必须包含: ```text 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 持久化: ```sql 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 持久化: ```sql 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 ```mermaid 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 的重复客户端消息 |