hyapp-server/docs/语音房宝箱功能架构.md
2026-05-20 14:34:10 +08:00

379 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 语音房宝箱功能架构
本文定义当前语音房宝箱的产品规则、服务边界、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 发布 RocketMQactivity、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 只投 RocketMQactivity/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 logMQ 后续可重试 |
| 到点开箱 | 即使房间未加载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` |