379 lines
16 KiB
Markdown
379 lines
16 KiB
Markdown
# 语音房宝箱功能架构
|
||
|
||
本文定义当前语音房宝箱的产品规则、服务边界、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` |
|