# 语音房宝箱功能架构

本文定义当前语音房宝箱的产品规则、服务边界、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<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 固定为：

```text
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 唤醒

```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 <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` 已携带 `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` |
