# 语音房宝箱功能架构 本文定义当前语音房宝箱的产品规则、服务边界、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。 ## 状态机 ```mermaid stateDiagram-v2 [*] --> idle idle --> idle: SendGift progress 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 固定为: ```text cmd_room_treasure_open_ ``` 奖励发放命令 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 唤醒 ```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) 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` 按资源组发放金币、钻石或资源权益。 ```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`: | 策略 | 语义 | | --- | --- | | `allow_stack` | 同一用户可同时获得在房、top1、igniter 多个角色奖励 | | `priority_only` | 同一用户只获得优先级最高的一份,优先级为 `igniter -> top1 -> in_room` | ## App 接口 ### 获取宝箱信息 ```http GET /api/v1/rooms/{room_id}/treasure Authorization: Bearer ``` 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` 已携带 `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 ```json { "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 ```json { "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 ```json { "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 | 推荐线上配置: ```yaml 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` |