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

22 KiB
Raw Blame History

语音房宝箱功能架构

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

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 建议稳定生成:

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 按资源组快照发放金币、钻石或资源权益。

{
  "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"
    }
  ]
}

发奖幂等键:

treasure_reward:<round_id>:<slot>:<user_id>

activity-service 对每个奖励记录先写 treasure_chest_reward_records,再调用 wallet-service。wallet 成功后才能把该记录标记为 granted。失败进入 retryable/failed,后台补偿必须复用同一幂等键。

App Interfaces

Get Treasure Chest

GET /api/v1/rooms/{room_id}/treasure-chest
Authorization: Bearer <access_token>

Response data:

{
  "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

GET /api/v1/rooms/{room_id}/treasure-chest/rewards?round_id=treasure:...&scope=mine
Authorization: Bearer <access_token>

Response data:

{
  "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,公共字段保持稳定:

{
  "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

{
  "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

{
  "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 按配置生成全局或区域播报:

{
  "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

{
  "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

{
  "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_stackpriority_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 必须包含:

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 持久化:

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 持久化:

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_msnext_day_start_ms - 1next_day_start_ms。不要重新引入 time.Localtask_timezone 或客户端时区。

Implementation Flow

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-chestGET /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 的重复客户端消息