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

591 lines
22 KiB
Markdown
Raw 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 接口、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:<room_id>:<cycle_day>:<level>:<sequence>
```
`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` roundCloseRoom 必须先提交开箱快照或取消策略;推荐先快照结算,再关闭房间 |
| 房间无人 | 在房奖励参与人为空;点火人和贡献第一仍按 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:<round_id>:<slot>:<user_id>
```
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 <access_token>
```
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 <access_token>
```
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 的重复客户端消息 |