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

16 KiB
Raw Permalink Blame History

语音房宝箱功能架构

本文定义当前语音房宝箱的产品规则、服务边界、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_idopen_at_ms 和 UTC reset_at_ms
溢出和倒计时送礼 当前等级溢出能量作废;倒计时期间送礼不累加到下一级,也不改变点火人或贡献第一
到期开箱 room-service 通过 RocketMQ 延迟消息到 open_at_ms 唤醒;本地已加载 Cell 扫描 worker 仍作为兜底
奖励结算 room-service 在开箱命令内按配置加权抽取奖励,并调用 wallet-service.GrantResourceGroup 幂等发放
离房/下线 top1igniter 在倒计时开始时锁定;开箱前离房或下线仍结算对应角色奖励
在房奖励 只发给开箱命令执行时 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。

状态机

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 固定为:

cmd_room_treasure_open_<box_id>

奖励发放命令 ID 固定由 box_id + role + user_id + reward_item_id 派生,重复开箱或 MQ 重投不会重复发放同一条奖励。

能量规则

规则 决策
能量来源 默认使用 DebitGiftResponse.gift_point_added;后台可配置 gift_idgift_type_code 的倍率、覆盖值和排除规则
生效条件 wallet 扣费成功、Room Cell 提交 SendGift、礼物能量值大于 0
幂等 同一 SendGift.command_id 只能增加一次宝箱能量
点火人 第一笔让当前等级 progress >= threshold 的送礼用户
贡献第一 当前等级倒计时前累计有效能量最高用户
溢出能量 单笔礼物超过当前等级剩余阈值时,只计入补满当前等级所需能量,多余能量作废
倒计时送礼 当前等级已经满能量,继续送礼不再增加宝箱能量,也不会排队到下一等级
7 级后送礼 当天不再累计宝箱能量,礼物仍正常扣费、热度和榜单照常更新

无效能量只进入 RoomTreasureProgressChanged 的审计字段,不计入当前等级贡献、不计入下一等级进度、不改变点火人或贡献第一。

开箱与 MQ 唤醒

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

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

获取宝箱信息

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

Response data 核心字段:

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

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

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

{
  "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_stackpriority_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-servicenotice-serviceroom-service IM bridge、后续 push/inbox
hyapp_room_treasure_open room-service treasure open scheduler room-service treasure open consumer

推荐线上配置:

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_msUTC 日界优先,旧宝箱不再结算
exhausted 下一个 UTC 日恢复 level 1

必须覆盖 UTC 边界测试:day_start_msnext_day_start_ms - 1next_day_start_ms。不要重新引入 time.Localtask_timezone 或客户端时区。

验收清单

场景 验收
送礼成功 wallet 扣费成功Room Cell 进度增加,SendGiftResponse.treasure 返回最新状态
单笔溢出 进度封顶当前阈值,溢出能量作废,不进入下一等级
倒计时送礼 礼物正常扣费和加热度,但宝箱进度、点火人、贡献第一不变化
满能量 RoomTreasureCountdownStartedoutbox worker 安排 RocketMQ delayed open
提前延迟消息 不写开箱 no-op command logMQ 后续可重试
到点开箱 即使房间未加载MQ 唤醒后 room-service 恢复 Room Cell 并开箱
top1/igniter 离房 仍发角色奖励
在房奖励 只发开箱瞬间在线用户
UTC 重置 到达 UTC 零点后进度回到 level 1跨日倒计时不结算
MQ/outbox 重投 event_idbox_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/rocketmqxpkg/roommqservices/room-service/internal/integration/rocketmq_outbox.go
App HTTP services/gateway-service/internal/transport/http/roomapi
后台配置 server/admin/internal/modules/roomtreasure