591 lines
22 KiB
Markdown
591 lines
22 KiB
Markdown
# 语音房宝箱功能架构
|
||
|
||
本文定义语音房宝箱的产品规则、服务边界、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` round,CloseRoom 必须先提交开箱快照或取消策略;推荐先快照结算,再关闭房间 |
|
||
| 房间无人 | 在房奖励参与人为空;点火人和贡献第一仍按 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 的重复客户端消息 |
|