幸运礼物文档的逻辑和房间用户列表接口

This commit is contained in:
zhx 2026-05-27 15:11:00 +08:00
parent b10f3e120b
commit aa43bf65a9
27 changed files with 3197 additions and 808 deletions

File diff suppressed because it is too large Load Diff

View File

@ -34,6 +34,18 @@ message RoomUser {
int64 last_seen_at_ms = 4;
}
// RoomOnlineUser 线
// role presence room_role is_owner
message RoomOnlineUser {
int64 user_id = 1;
string role = 2;
string room_role = 3;
int64 gift_value = 4;
int64 joined_at_ms = 5;
int64 last_seen_at_ms = 6;
bool is_owner = 7;
}
// SeatState RTC
message SeatState {
int32 seat_no = 1;
@ -903,6 +915,7 @@ message ListRoomOnlineUsersRequest {
int64 viewer_user_id = 3;
int32 page = 4;
int32 page_size = 5;
string sort = 6;
}
message ListRoomOnlineUsersResponse {
@ -911,6 +924,7 @@ message ListRoomOnlineUsersResponse {
int32 page = 3;
int32 page_size = 4;
int64 server_time_ms = 5;
repeated RoomOnlineUser items = 6;
}
// FollowRoomRequest

View File

@ -73,7 +73,7 @@ App 不要展示后台 RTP 数值,不要展示实时概率,不要本地推
1. 进入房间后调用 `GET /api/v1/rooms/{room_id}/gift-panel` 获取礼物面板、金币余额和收礼人。
2. 找到 `gift_type_code=lucky``gift_type_code=super_lucky` 的礼物,读取该礼物对应的 `pool_id`。当前可从 `presentation_json.lucky_pool_id` 下发,后续建议提升为顶层字段 `lucky_pool_id`
3. 用户点击幸运礼物前,可调用 `POST /api/v1/activities/lucky-gifts/check` 判断奖池是否启用。
3. 用户点击幸运礼物前,可调用 `POST /api/v1/activities/lucky-gifts/check` 判断奖池是否启用;当前 check 接口要求 `pool_id` 必填
4. 用户确认送礼时调用 `POST /api/v1/rooms/gift/send`,请求体带上 `pool_id`
5. 成功响应里如果有 `lucky_gift`,立即播放中奖或未中奖反馈。
6. 房间内监听 `room_gift_sent``lucky_gift_drawn` IM给其他用户同步展示。
@ -134,7 +134,7 @@ X-App-Code: lalu
## 检查幸运礼物状态
建议新增 App HTTP 接口:
已实现 App HTTP 接口:
```http
POST /api/v1/activities/lucky-gifts/check
@ -159,7 +159,7 @@ Content-Type: application/json
| --- | --- | --- | --- |
| `room_id` | string | 是 | 当前房间 ID。 |
| `gift_id` | string | 是 | 用户准备送出的幸运礼物 ID。 |
| `pool_id` | string | 否 | 奖池 ID为空时服务端使用 `default`。 |
| `pool_id` | string | 是 | 奖池 ID当前 gateway check 接口必填。 |
成功响应:
@ -200,6 +200,8 @@ Content-Type: application/json
| `enabled=false` | 置灰幸运抽奖入口,但普通礼物展示可继续。 |
| 接口失败 | 不在本地兜底开奖;可以提示稍后重试或隐藏幸运玩法。 |
注意:`room-service` 内部仍会把空 `pool_id` 归一化为默认奖池,但 App check 接口不接受空值。客户端必须从礼物面板拿到明确 `pool_id` 后再走幸运礼物流程。
## 送礼并抽奖
已存在 App HTTP 入口:
@ -236,7 +238,7 @@ Content-Type: application/json
| `target_user_id` | int64 | 否 | 单目标兼容字段;新代码优先用 `target_user_ids`。 |
| `gift_id` | string | 是 | 礼物 ID。 |
| `gift_count` | int32 | 是 | 礼物数量,必须大于 0。 |
| `pool_id` | string | | 幸运礼物奖池 ID不同奖池完全隔离。普通礼物可不传。 |
| `pool_id` | string | 幸运礼物必填 | 幸运礼物奖池 ID不同奖池完全隔离。普通礼物可不传。 |
成功响应节选:
@ -279,7 +281,7 @@ Content-Type: application/json
}
```
`lucky_gift` 字段为建议新增。普通礼物或奖池未启用时可以为空。
`lucky_gift` 字段已实现。普通礼物或未执行幸运礼物抽奖时可以为空。
`data` 字段:
@ -293,7 +295,7 @@ Content-Type: application/json
| `gift_rank` | array | 房间礼物榜快照。 |
| `room` | object | 房间快照。 |
| `treasure` | object | 宝箱状态;如果房间宝箱开启可直接更新。 |
| `lucky_gift` | object | 幸运礼物抽奖结果;建议新增。 |
| `lucky_gift` | object | 幸运礼物抽奖结果;已实现。 |
`lucky_gift` 字段:
@ -307,7 +309,7 @@ Content-Type: application/json
| `rule_version` | int64 | 抽奖时使用的规则版本。 |
| `experience_pool` | string | `novice``intermediate``advanced`。 |
| `selected_tier_id` | string | 服务端选中的奖档 ID。 |
| `multiplier_ppm` | int64 | 建议新增;用于直接展示倍率。后端暂未返回时客户端不要本地解析倍率。 |
| `multiplier_ppm` | int64 | 已返回;用于直接展示倍率。客户端不要从 `selected_tier_id` 解析倍率。 |
| `base_reward_coins` | int64 | 基础 RTP 返奖。 |
| `room_atmosphere_reward_coins` | int64 | 房间气氛补贴奖励。 |
| `activity_subsidy_coins` | int64 | 活动补贴奖励。 |
@ -407,7 +409,7 @@ Payload 示例:
## IM幸运礼物开奖结果
建议新增房间 IM
已实现房间 IM
| 字段 | 值 |
| --- | --- |
@ -460,7 +462,7 @@ Payload 示例:
| `target_user_id` | int64 | 收礼用户。 |
| `coin_spent` | int64 | 本次实际扣费。 |
| `selected_tier_id` | string | 中奖奖档。 |
| `multiplier_ppm` | int64 | 中奖倍率,建议新增。 |
| `multiplier_ppm` | int64 | 中奖倍率,已返回。 |
| `effective_reward_coins` | int64 | 用户可见奖励金币。 |
| `reward_status` | string | `pending``granted``failed`。 |
| `stage_feedback` | bool | 是否阶段反馈。 |
@ -529,7 +531,7 @@ Payload 示例:
## IM高倍全站或区域播报
建议新增播报 IM用于高倍中奖或运营配置的大额中奖展示。
已实现区域播报 IM用于高倍中奖或运营配置的大额中奖展示;当前服务端触发阈值是 `multiplier_ppm >= 1000000` 且有 `visible_region_id`
| 字段 | 值 |
| --- | --- |

View File

@ -28,7 +28,7 @@
| --- | --- | --- | --- |
| `room_id` | string | 是 | 当前房间 ID |
| `gift_id` | string | 是 | 实际送出的礼物 ID例如 `rose` |
| `pool_id` | string | 是 | 幸运礼物奖池 ID例如 `lucky``super_lucky` |
| `pool_id` | string | 是 | 幸运礼物奖池 ID例如 `lucky``super_lucky`;当前 check 接口必填 |
返回 `data`
@ -59,11 +59,13 @@
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 房间 ID |
| `target_user_id` | int/string | 是 | 收礼用户 ID |
| `command_id` | string | 是 | 本次送礼动作幂等键;同一次超时重试必须复用 |
| `target_user_id` | int/string | 兼容 | 旧单目标字段;新客户端优先使用 `target_user_ids` |
| `target_user_ids` | int[] | 是 | 当前 `target_type=user` 时必须且只能 1 个 |
| `target_type` | string | 否 | 当前只支持 `user`,为空按 `user` |
| `gift_id` | string | 是 | 礼物 ID例如 `rose` |
| `gift_count` | int | 是 | 数量,必须大于 0 |
| `pool_id` | string | 幸运礼物必填 | `lucky``super_lucky` |
| `client_sequence` | string | 建议 | 客户端幂等序列 |
返回 `data.lucky_gift`
@ -81,30 +83,37 @@
| `room_atmosphere_reward_coins` | int | 房间气氛池返奖 |
| `activity_subsidy_coins` | int | 活动补贴返奖 |
| `effective_reward_coins` | int | 用户实际获得金币 |
| `reward_status` | string | 初始通常为 `pending`,到账由后端 outbox 补偿 |
| `reward_status` | string | 有返奖或阶段反馈时初始通常为 `pending`,到账和房间 IM 由后端 outbox 补偿 |
| `stage_feedback` | bool | 是否阶段反馈,可播放轻量中奖表现 |
| `high_multiplier` | bool | 是否高倍 |
| `created_at_ms` | int | UTC epoch ms |
客户端处理:
- `data.lucky_gift != null`:立即调用 `_applyLuckyGiftResult()`,播放中奖内容和数量 SVGA。
- `effective_reward_coins > 0`:展示中奖金额;余额不要本地相加,以 `WalletBalanceChanged` 或余额刷新为准。
- `data.lucky_gift != null`:立即调用 `_applyLuckyGiftResult()`,播放中奖或阶段反馈表现。
- `effective_reward_coins > 0 && reward_status == "pending"`:展示中奖金额和“奖励处理中”,余额不要本地相加。
- `effective_reward_coins > 0 && reward_status == "granted"`:可以展示已到账状态;余额仍以 `WalletBalanceChanged` 或余额刷新为准。
- `data.lucky_gift == null`:按普通礼物表现处理。
## 3. 错误处理
| code | 场景 | 客户端处理 |
| --- | --- | --- |
| `INVALID_ARGUMENT` | 参数缺失、数量非法、pool_id 缺失 | 停止提交,提示用户重试或刷新礼物面板 |
| `INVALID_ARGUMENT` | 参数缺失、数量非法、`pool_id` 缺失`target_user_ids` 非单目标 | 停止提交,提示用户重试或刷新礼物面板 |
| `UNAUTHENTICATED` | token 失效 | 重新登录 |
| `INSUFFICIENT_BALANCE` | 金币不足 | 打开充值入口或提示余额不足 |
| `RULE_NOT_ACTIVE` | 幸运礼物规则未启用 | 降级普通礼物或刷新礼物配置 |
| `UPSTREAM_ERROR`/`UNAVAILABLE` | 内部服务或 IM/钱包临时不可用 | 不重复扣本地余额;允许用户稍后重试 |
| `IDEMPOTENCY_CONFLICT`/`CONFLICT` | 同一客户端命令参数变化 | 重新生成 `client_sequence` 后再提交 |
| `IDEMPOTENCY_CONFLICT`/`CONFLICT` | 同一 `command_id` 对应的请求参数变化,或规则/奖池/风控冲突 | 参数变化时重新生成 `command_id`;规则冲突时刷新礼物面板 |
送礼失败时后端不会返回 `lucky_gift`,客户端不要播放中奖 UI。送礼成功但返奖到账延迟时房间 IM 和钱包 IM 会继续补偿。
幂等规则:
- HTTP 超时、断网、502同一次用户动作使用原 `command_id` 重试。
- 已收到成功响应或钱包余额已扣减:不要生成新 `command_id` 再发同一笔礼物。
- 需要重新点击送礼:生成新的 `command_id`
## 4. 房间 IM
腾讯云 IM 群自定义消息:
@ -132,7 +141,13 @@
"sender_user_id": 42,
"target_user_id": 99,
"coin_spent": 100,
"rule_version": 1,
"experience_pool": "novice",
"selected_tier_id": "novice_2x",
"multiplier_ppm": 2000000,
"base_reward_coins": 200,
"room_atmosphere_reward_coins": 0,
"activity_subsidy_coins": 0,
"effective_reward_coins": 200,
"stage_feedback": true,
"high_multiplier": false,
@ -145,6 +160,7 @@
- 只处理 `Desc == "lucky_gift_drawn"``event_type == "lucky_gift_drawn"`
- 用 `event_id``draw_id` 去重;同一抽奖不要重复播放。
- 当前用户已通过 HTTP 送礼响应播放过同一 `draw_id` 时,收到 IM 只更新状态,不重复播放。
- `effective_reward_coins > 0` 但还没有收到钱包余额 IM 时,只展示中奖结果和处理中状态,不本地加金币。
## 5. 钱包余额 IM

View File

@ -2,6 +2,8 @@
本文档定义幸运礼物在 `activity-service` 中的开发落地。产品规则见 `docs/幸运礼物产品方案.md`
当前代码已经落地到 `api/proto/activity/v1/activity.proto``services/activity-service/internal/service/luckygift``services/activity-service/internal/storage/mysql/lucky_gift_repository.go`。线上事实以 `docs/幸运礼物线上落地文档.md` 为准;本文件保留开发阶段设计背景,涉及“建议新增 proto”“固定概率表”“后台权重表”的段落不再代表当前实现。当前实现的核心配置是 `pool_id + multiplier_ppms`,后台配置多个倍率,服务端在抽奖时按 RTP 窗口、奖池和风控自动生成运行时权重。
## Ownership
幸运礼物归属 `activity-service`,但金币账务仍归属 `wallet-service`,房间状态仍归属 `room-service`

View File

@ -2,6 +2,8 @@
本文档定义语音房幸运礼物的产品规则。幸运礼物使用金币赠送,奖励也返还金币
当前代码实现以 `docs/幸运礼物线上落地文档.md` 为准。已落地版本不是固定概率表模型,而是后台配置 `multiplier_ppms` 多个倍率,服务端按 RTP 窗口、三层奖池和风控容量在运行时自动生成候选权重;因此本文中“概率表/权重表”只保留为产品抽象,不代表后台可以直接配置固定命中概率。
## Positioning
| Object | Meaning |

View File

@ -1,146 +1,157 @@
# 幸运礼物线上落地文档
本文档定义当前幸运礼物在 `activity-service` 的线上落地边界、后台参数、抽奖事务和验证方式。幸运礼物只拥有活动决策、RTP 窗口、奖池和抽奖事实;金币扣费和返奖入账仍归 `wallet-service`,房间状态和房间表现仍归 `room-service`
本文档按当前代码实现整理幸运礼物的服务边界、配置语义、抽奖链路、体验模拟方式和已知问题。当前幸运礼物由 `activity-service` 持有规则、RTP 窗口、奖池、风控计数、抽奖事实和异步副作用 outbox金币扣费与返奖入账仍归 `wallet-service`,房间状态、热度、榜单和送礼消息仍归 `room-service`
## Current Scope
## 相关代码
本次落地包含:
| Scope | Status | Notes |
| 模块 | 文件 | 当前职责 |
| --- | --- | --- |
| `LuckyGiftService.CheckLuckyGift` | done | 房间送礼前检查礼物是否启用,并返回当前体验池 |
| `LuckyGiftService.ExecuteLuckyGiftDraw` | done | 扣费成功后执行抽奖决策,写抽奖事实和 outbox |
| `AdminLuckyGiftService.GetLuckyGiftConfig` | done | 后台按 `pool_id` 读取幸运礼物奖池规则;未配置时返回默认草稿 |
| `AdminLuckyGiftService.UpsertLuckyGiftConfig` | done | 后台按 `pool_id` 发布独立幸运礼物奖池规则版本 |
| `AdminLuckyGiftService.ListLuckyGiftDraws` | done | 后台审计抽奖记录 |
| 三层基础奖池 | done | platform / room / gift |
| RTP 双窗口 | done | global + gift |
| 风控计数 | done | user hour/day、device day、room hour、anchor day |
| 房间气氛池 | done | 独立预算,不污染基础 RTP |
| 活动预算池 | done | 独立补贴,不污染基础 RTP |
| 返奖入账 | done | `activity_outbox` worker 调 `wallet-service.CreditLuckyGiftReward`,钱包 outbox 继续触发余额私信 |
| 房间中奖 IM | done | `activity_outbox` worker 投递腾讯云 IM 群消息 `lucky_gift_drawn` |
| App 检查接口 | done | gateway 暴露 `POST /api/v1/activities/lucky-gifts/check` |
| protobuf 契约 | `api/proto/activity/v1/activity.proto` | `LuckyGiftConfig``multiplier_ppms``LuckyGiftDrawResult.multiplier_ppm`、后台配置和审计 RPC |
| 房间送礼主链路 | `services/room-service/internal/room/service/gift.go` | 送礼前可检查奖池,扣费成功后同步调用 `ExecuteLuckyGiftDraw`,把 `lucky_gift` 放入 `SendGiftResponse` |
| 幸运礼物领域模型 | `services/activity-service/internal/domain/luckygift/lucky_gift.go` | 规则、奖档、Check/Draw 命令、抽奖结果和审计查询结构 |
| 规则归一化 | `services/activity-service/internal/service/luckygift/config.go` | 后台输入 `multiplier_ppms` 后生成三档体验池奖档;权重不是后台配置项 |
| 抽奖服务 | `services/activity-service/internal/service/luckygift/service.go` | Check、Draw、outbox worker、返奖入账、房间 IM、区域播报 |
| MySQL 抽奖事务 | `services/activity-service/internal/storage/mysql/lucky_gift_repository.go` | 规则锁、RTP 窗口、三层池、风控计数、候选过滤、随机、draw fact、outbox |
| activity schema | `services/activity-service/deploy/mysql/initdb/001_activity_service.sql` | `lucky_gift_rules``lucky_rtp_windows``lucky_pools``lucky_user_states``lucky_draw_records` 等表 |
| gateway 检查接口 | `services/gateway-service/internal/transport/http/activityapi/lucky_gift_handler.go` | `POST /api/v1/activities/lucky-gifts/check`,当前要求 `room_id/gift_id/pool_id` 都非空 |
| gateway 送礼接口 | `services/gateway-service/internal/transport/http/roomapi/room_handler.go` | `POST /api/v1/rooms/gift/send` 透传 `pool_id``command_id` |
| admin 配置接口 | `server/admin/internal/modules/luckygift/handler.go` | 后台读写 `pool_id` 维度的规则,支持 `multiplier_ppms` |
| 体验模拟工具 | `tools/lucky-gift-v2-demo/main.go` | 离线模拟用户 RTP 分布、奖池水位、风控限制和高倍命中 |
`activity-service` 抽奖事务内仍不调用钱包、房间 RPC 或腾讯云 IM。抽奖事务只写事实和 `activity_outbox`;事务外由 lucky gift worker 抢占 outbox先幂等返奖再投递房间中奖 IM最后标记 draw 和 outbox 状态。
## 当前范围
## Call Flow
```text
room-service SendGift
1. CheckLuckyGift(meta, user_id, room_id, gift_id, pool_id)
2. wallet-service 扣费成功
3. ExecuteLuckyGiftDraw(command_id, user_id, target_user_id, gift_count, device_id, room_id, anchor_id, gift_id, pool_id, coin_spent, paid_at_ms)
4. activity-service 本地事务写 draw fact + outbox
5. lucky gift worker 幂等调用 wallet-service.CreditLuckyGiftReward
6. wallet-service 写 WalletBalanceChangednotice-service 投递私信余额通知
7. lucky gift worker 投递房间群 IM lucky_gift_drawn
8. activity-service 标记 reward_status=granted、outbox=delivered
```
`command_id` 是抽奖幂等键。重复 `ExecuteLuckyGiftDraw` 会返回同一条 `lucky_draw_records`,不会重复消耗 RTP、奖池或预算。
后台按 `pool_id` 维护多份互相独立的幸运礼物奖池规则,实际抽奖仍记录用户发送的真实 `gift_id`。后台不暴露单抽消耗;服务端保存固定内部换算单位,不同幸运礼物实际消耗不同,抽奖事务按实际 `coin_spent` 等比缩放基础奖档,并按实际 `coin_spent` 累计 RTP 目标和奖池入账。同一个 `gift_id` 可以请求不同 `pool_id`,不同 `pool_id` 的 RTP 窗口、基础奖池、房间气氛池、活动预算、用户阶段和风控计数互不共享。
## Transaction Boundary
`ExecuteLuckyGiftDraw` 使用一个 MySQL 本地事务,事务内必须完成:
1. 按 `command_id` 锁定幂等记录。
2. 锁定 `lucky_gift_rules``pool_id` 对应规则。
3. 锁定或创建用户体验状态 `lucky_user_states`
4. 锁定或创建全站 RTP 窗口、幸运礼物整体子窗口。
5. 锁定或创建平台、房间、幸运礼物整体三层基础奖池。
6. 锁定或创建房间气氛池。
7. 锁定或创建活动总预算桶和 UTC 日预算桶。
8. 锁定用户、设备、房间、主播风控计数。
9. 抽前过滤所有不可支付候选。
10. 执行一次服务端安全随机。
11. 更新 RTP 窗口、奖池、预算、风控计数、用户状态。
12. 插入 `lucky_draw_records`
13. 必要时插入 `activity_outbox`
事务内不得调用 `wallet-service``room-service` 或腾讯云 IM避免外部 RPC 卡住奖池行锁。
## RTP Accounting
基础返奖只使用 `base_reward_coins`
```text
base_rtp = sum(base_reward_coins) / sum(coin_spent)
```
用户可见返奖使用:
```text
effective_reward_coins = base_reward_coins
+ room_atmosphere_reward_coins
+ activity_subsidy_coins
```
房间气氛池和活动预算只能提高 `effective_rtp`,不能用于补平基础 RTP。
## Admin Parameters
后台参数分为 6 类。
后台 HTTP 入口:
| Method | Path | Permission | Meaning |
| --- | --- | --- | --- |
| `GET` | `/api/v1/admin/activity/lucky-gifts/config?pool_id=pool_95` | `lucky-gift:view` | 读取指定奖池规则;未配置返回默认草稿 |
| `PUT` | `/api/v1/admin/activity/lucky-gifts/config?pool_id=pool_95` | `lucky-gift:update` | 发布指定奖池的新规则版本 |
| `GET` | `/api/v1/admin/activity/lucky-gifts/draws` | `lucky-gift:view` | 查询抽奖审计记录 |
| Group | Fields | Meaning |
| 能力 | 状态 | 代码事实 |
| --- | --- | --- |
| RTP 成本 | `target_rtp_ppm`, `pool_rate_ppm`, `global_window_draws`, `gift_window_draws` | 控制基础 RTP 和收敛窗口 |
| 三层池 | `platform_pool_weight_ppm`, `room_pool_weight_ppm`, `gift_pool_weight_ppm`, `initial_*_pool`, `*_reserve` | 控制平台、房间、礼物各自承担的基础返奖能力 |
| 倍率配置 | `multiplier_ppms`, generated `tiers` | 运营只配置用户可能命中的倍率;服务按 RTP 窗口、水位和风控自动生成实时概率 |
| 体验池 | `novice_draw_limit`, `intermediate_draw_limit` | 控制新手/中级/高级阶段,不直接决定成本 |
| 高倍 | `high_multiplier`, `high_water_pool_multiple`, `large_tier_enabled` | 控制 100x/500x 等高倍倍率是否可进入候选 |
| 风控 | `max_single_payout`, `user_hourly_payout_cap`, `user_daily_payout_cap`, `device_daily_payout_cap`, `room_hourly_payout_cap`, `anchor_daily_payout_cap` | 控制单用户、设备、房间、主播关联风险敞口 |
| 娱乐补贴 | `room_atmosphere_rate_ppm`, `room_atmosphere_initial`, `room_atmosphere_reserve`, `activity_budget`, `activity_daily_limit` | 控制房间爆点和活动补贴 |
| 送礼前检查 | 已实现 | gateway check 要求 `pool_id`activity 按 `pool_id` 读规则,未配置返回 `not_configured` |
| 扣费后抽奖 | 已实现 | `room-service` 先调 `wallet-service.DebitGift`,扣费成功后调 `activity-service.ExecuteLuckyGiftDraw` |
| 同步返回抽奖结果 | 已实现 | `SendGiftResponse.lucky_gift` 直接返回给送礼用户 |
| 后台多奖池 | 已实现 | 规则主键实际是 `(app_code, gift_id)`,但当前 `gift_id` 列保存的是 `pool_id` |
| 多倍数配置 | 已实现 | 后台配置 `multiplier_ppms`,服务端生成每个体验池的候选倍率 |
| RTP 双窗口 | 已实现 | 当前窗口 scope 是 `pool``pool_gift`,都按 `pool_id` 隔离 |
| 三层基础奖池 | 已实现 | platform / room / gift 三层池按 `pool_id` 隔离room 池再加 `room_id` |
| 风控计数 | 已实现 | user hour/day、device day、room hour、anchor day |
| 房间气氛池 | 已实现 | 只在基础 RTP 不需要强制追平时参与候选 |
| 活动补贴池 | 已实现 | 只在基础 RTP 不需要强制追平时参与候选 |
| 返奖入账 | 已实现 | 抽奖事务写 `activity_outbox`worker 调 `wallet-service.CreditLuckyGiftReward` |
| 房间中奖 IM | 已实现 | worker 投递 `lucky_gift_drawn` 群消息 |
| 区域中奖播报 | 已实现 | `multiplier_ppm >= 1x` 且有 `visible_region_id` 时写区域播报 outbox |
| 完整候选审计 | 未完整 | `candidate_tiers_json` 当前只保存命中候选和限制标记,不保存完整过滤后候选表 |
| 随机摘要审计 | 未实现 | 当前没有 `random_digest` 字段落库 |
调整方向:
## 主链路
| Goal | Primary Change |
| --- | --- |
| 降成本 | 降 `target_rtp_ppm`,降补贴预算,提高高水位倍数 |
| 增刺激 | 增加高倍倍率,增房间气氛和活动预算 |
| 新手更友好 | 增加 0.2x/0.5x/1x 等小倍率,延长 `novice_draw_limit` |
| 防套利 | 降低用户/设备/房间/主播上限,关闭高倍 |
| RTP 更稳 | 缩小 RTP 窗口 |
| 体验更自然 | 放大 RTP 窗口,但接受短周期波动 |
## Data Tables
| Table | Owner Data |
| --- | --- |
| `lucky_gift_rules` | 按 `pool_id` 保存幸运礼物奖池规则和后台可调参数 |
| `lucky_rtp_windows` | 按 `pool_id` 保存 RTP 控制窗口 |
| `lucky_pools` | 按 `pool_id` 保存 platform / room / gift 三层基础奖池 |
| `lucky_room_atmosphere_pools` | 按 `pool_id + room_id` 保存房间气氛预算 |
| `lucky_activity_budgets` | 按 `pool_id` 保存活动补贴预算;`budget_day=__total__` 控总额UTC 日期行控日释放 |
| `lucky_user_states` | 用户在单个 `pool_id` 内的累计付费抽数 |
| `lucky_risk_counters` | 按 `pool_id` 保存风控窗口计数 |
| `lucky_draw_records` | 每次抽奖事实和审计快照 |
## Verification
Proto 变更后必须运行:
```bash
PATH=/tmp/codex-protoc/bin:$HOME/go/bin:$PATH make proto
```text
Client
-> gateway POST /api/v1/rooms/gift/send
-> room-service SendGift
-> 可选 CheckLuckyGift(pool_id 非空时送礼前检查)
-> wallet-service DebitGift
-> activity-service ExecuteLuckyGiftDraw
-> activity-service 本地事务写 draw fact + activity_outbox
-> room-service 更新 Room Cell 热度、榜单、礼物 outbox
-> gateway 返回 data.lucky_gift
-> lucky gift worker 补偿返奖入账和 lucky_gift_drawn IM
```
服务编译验证
当前有两个触发入口:
```bash
go test ./services/activity-service/...
| 入口 | 行为 |
| --- | --- |
| 请求显式传 `pool_id` | `room-service` 会在扣费前调用 `CheckLuckyGift`,规则未启用则不扣费 |
| 请求未传 `pool_id` 但钱包返回 `gift_type_code=lucky/super_lucky` | `room-service` 会在扣费后按默认奖池抽奖,`activity-service` 把空 `pool_id` 归一化为 `default` |
客户端 check 接口当前要求 `pool_id` 必填,所以 App 不应依赖“空 `pool_id` 默认奖池”的隐式路径。礼物面板需要下发明确的 `pool_id`
## 多倍数配置
后台现在的核心配置不是固定概率表,而是倍率列表:
```json
{
"pool_id": "lucky",
"gift_price": 100,
"target_rtp_ppm": 950000,
"pool_rate_ppm": 950000,
"high_multiplier": 100,
"large_tier_enabled": true,
"multiplier_ppms": [
0,
200000,
500000,
1000000,
2000000,
5000000,
10000000,
20000000,
50000000,
100000000,
500000000
]
}
```
模型验证:
倍率单位是 ppm
| 值 | 含义 | `gift_price=100` 时基础返奖 |
| ---: | --- | ---: |
| `0` | 未中奖 | `0` |
| `200000` | `0.2x` | `20` |
| `500000` | `0.5x` | `50` |
| `1000000` | `1x` | `100` |
| `2000000` | `2x` | `200` |
| `100000000` | `100x` | `10000` |
| `500000000` | `500x` | `50000` |
服务端发布配置时会执行这些归一化:
1. 如果 `multiplier_ppms` 非空,直接用它去重、排序并过滤负数。
2. 如果 `multiplier_ppms` 为空,则从传入 `tiers` 里反推倍率。
3. 如果两者都为空,则使用默认倍率列表。
4. 每个倍率都会生成 `novice``intermediate``advanced` 三个体验池各一条奖档。
5. 生成奖档的 `weight` 固定为 `0`,真实权重由抽奖时 RTP 窗口动态计算。
6. `multiplier_ppm >= high_multiplier * 1000000` 的奖档会标记 `high_water_only`
这意味着当前后台不能按体验池单独配置概率,也不能直接配置“新手 2x 权重大、中级 5x 权重大”这类固定权重。后台能控制的是可用倍率集合、RTP 目标、窗口大小、奖池水位、风控上限和高倍开关。
## 抽奖算法事实
抽奖事务按 `command_id` 幂等。重复请求返回同一条 `lucky_draw_records`,不会重复消耗奖池或 RTP。
核心步骤:
```text
1. 锁定 command_id 对应 draw fact存在则直接返回
2. 按 pool_id 锁定幸运礼物规则;未启用则失败
3. 用真实 coin_spent 替换配置里的参考 gift_price并等比缩放奖档和风控上限
4. 锁定用户在该 pool_id 下的累计付费抽数,选择 novice/intermediate/advanced
5. 锁定 pool RTP 窗口和 pool_gift RTP 子窗口
6. 锁定 platform、room、gift 三层基础奖池
7. 锁定房间气氛池、活动预算和风控计数
8. 计算本次基础 RTP 最小必付额 min_required
9. 过滤低于 min_required、超过奖池容量、超过风控容量或高水位不足的倍率
10. 从可支付基础候选中按 RTP 缺口自动生成 1 个或 2 个带权候选
11. 当 min_required=0 时,额外加入房间气氛、活动补贴和阶段反馈候选
12. 使用 crypto/rand 做一次加权随机
13. 更新 RTP 窗口、三层池、气氛池、活动预算、风控计数、用户抽数
14. 写 lucky_draw_records有返奖或展示副作用时写 activity_outbox
```
运行时基础候选权重不是后台配置权重。`luckyAutoWeightedBaseCandidates` 会按当前窗口缺口选择:
| RTP 状态 | 当前行为 |
| --- | --- |
| 没有基础 RTP 缺口 | 优先选择 `0x` 候选;如果倍率列表没有 `0`,会选择最低可用倍率 |
| 缺口正好落在某个倍率以下 | 只给最接近且可支付的低档或高档候选分配 `1000000` 权重 |
| 缺口落在两个倍率之间 | 只保留相邻两个倍率,权重按线性插值分配 |
| 没有可用候选但必须追平 | 生成 `rtp_balance` 修正候选;如果超过容量则返回冲突 |
因此,多倍数列表不是“所有倍率每次都按固定概率参与随机”。它是 RTP 控制器可选择的离散返奖点。
## 用户体验模拟
体验模拟工具用于评估参数是否会带来可接受的用户体感分布。它输出全局 RTP、有效 RTP、100k/1m 窗口偏差、奖池水位、用户 RTP 分位数和用户净输赢分位数。
基础命令:
```bash
go run ./tools/lucky-gift-v2-demo \
@ -151,40 +162,110 @@ go run ./tools/lucky-gift-v2-demo \
-draws-max 100000 \
-unique-draws \
-base-rtp 95 \
-initial 1000000000 \
-cost 500 \
-global-window 100000 \
-gift-window 100000
-gift-window 100000 \
-pool-rate-ppm 950000 \
-high-multiplier 100 \
-high-water-pool-multiple 2 \
-user-csv /tmp/lucky-gift-users.csv
```
重点看这些输出:
| 输出 | 判断方式 |
| --- | --- |
| `base_rtp` | 基础成本口径,应贴近 `base-rtp` |
| `effective_rtp` | 用户可见返还,允许因气氛池和活动补贴高于基础 RTP |
| `100k_blocks max_abs_deviation` | 应小于 `1pp` |
| `1m_blocks max_abs_deviation` | 应小于 `0.5pp` |
| `high_multiplier_hits` | 评估高倍实际出现频率是否符合运营预期 |
| `risk_cap_limited_draws` | 过高说明风控上限压得太低,会牺牲 RTP 追平能力 |
| `pool_cap_limited_draws` | 过高说明初始奖池、入池比例或 reserve 配置不健康 |
| `user_effective_rtp_p05/median/p95` | 用户体验分布;单用户不会收敛到全局 RTP |
| `user_net_p05/median/p95` | 用户净输赢体感;用于判断投诉风险和高倍刺激强度 |
当前模拟工具的限制:
| 限制 | 影响 |
| --- | --- |
| 不能直接输入 `multiplier_ppms` | 它使用内置新手/中级/高级 tier table不能完全复现后台任意倍率列表 |
| 使用伪随机 `math/rand` | 便于复现实验,不等同线上 `crypto/rand` |
| 不连接真实 MySQL | 不验证行锁、幂等、outbox 和钱包补偿 |
| 不模拟真实礼物面板 | 只模拟付费抽数、成本、奖池和用户结果分布 |
使用模拟结论上线前,仍需用真实 `activity-service` MySQL 测试覆盖配置发布、抽奖事务、重复 `command_id`、outbox 返奖和 IM 投递。
## 当前问题
| 问题 | 影响 | 建议动作 |
| --- | --- | --- |
| `lucky_gift_rules.gift_id` 实际保存 `pool_id` | 文档、后台和审计容易把真实礼物 ID 与奖池 ID 混淆 | 后续 DB 字段改名为 `pool_id`,或在所有接口文档明确“规则表 gift_id 是历史列名” |
| 后台传入 `tiers.weight` 不生效 | 运营以为配置了固定概率,但线上按 RTP 缺口自动权重抽 | 后台隐藏或只读展示 `tiers.weight`,主编辑项改为 `multiplier_ppms` |
| `multiplier_ppms` 可以不含 `0` | 没有 RTP 缺口时最低正倍率可能被强制选中,用户体感和成本都会异常 | 后台校验建议强制包含 `0`,除非明确做“每抽必返”奖池 |
| 三个体验池使用同一倍率集合 | 无法做到新手池低倍密集、高级池高倍更多的差异化倍率表 | 增加按 pool 分组的倍率配置,或保留统一倍率但明确只能靠阶段和 RTP 控制差异 |
| 当前服务不使用后台固定概率 | “概率表版本”“权重表”类文档与代码不一致 | 文档统一改为“倍率列表 + 运行时 RTP 自动权重” |
| `candidate_tiers_json` 不保存完整候选表 | 后台审计无法复盘每次过滤后的完整随机空间 | 落库完整候选列表、过滤原因、总权重和随机落点 |
| 没有 `random_digest` | 不能证明随机材料与候选表一致 | 增加随机摘要或 seed commit-reveal 方案 |
| 同步返回通常是 `pending` | 客户端如果看到返奖就展示“已到账”,会早于钱包入账事实 | 客户端只在钱包 IM 或余额刷新后更新余额HTTP 只展示“中奖/处理中” |
| check 接口要求 `pool_id`,送礼链路又支持空 `pool_id` 默认奖池 | App 行为和服务端隐式兜底不一致 | 礼物面板必须明确下发 `pool_id`,客户端幸运礼物送礼必传 |
| 区域播报阈值硬编码为 `>= 1x` | 后台不能独立控制哪些中奖进入区域飘屏 | 增加 `broadcast_min_multiplier_ppm``broadcast_min_reward_coins` |
| 模拟工具不能输入多倍数列表 | 不能验证“多个倍数配置”对实际体感的影响 | 给 `tools/lucky-gift-v2-demo` 增加 `-multipliers-ppm` 参数,或新增基于线上 `Config` 的模拟入口 |
## 后台配置建议
| 目标 | 建议 |
| --- | --- |
| 常规幸运礼物 | `multiplier_ppms` 包含 `0/0.2x/0.5x/1x/2x/5x/10x/20x`,高倍关闭或保持较高水位门槛 |
| 超级幸运礼物 | 在常规列表基础上增加 `50x/100x/500x`,同时提高初始池、水位倍数和风控 cap |
| 新手体验 | 保留 `0.2x/0.5x/1x/2x`,不要只配置 `1x` 以上倍率 |
| 降低投诉 | 降低 `effective_rtp` 波动,增加小倍率数量,减少 `500x` 等极高倍 |
| 控制成本 | 降低 `target_rtp_ppm` 或补贴预算,不要用过低风控 cap 硬压基础 RTP |
高风险配置示例:
| 配置 | 风险 |
| --- | --- |
| `multiplier_ppms=[1000000,2000000]` | 没有未中奖档,低缺口时仍可能返 `1x` |
| `target_rtp_ppm=950000` 但最大倍率低于 `1x` | 发布会被校验拒绝,无法追平 RTP |
| `large_tier_enabled=true` 但初始池很低 | 高倍长期被过滤,用户看到配置但实际不出 |
| `user_hourly_payout_cap` 低于小时期望返奖 | 风控会阻断 RTP 追平,触发 `cannot be settled` |
## 验证
文档或配置语义变更后至少跑相关单元测试:
```bash
go test ./services/activity-service/internal/service/luckygift
go test ./services/activity-service/internal/storage/mysql
go test ./services/room-service/internal/room/service
go test ./services/gateway-service/internal/transport/http/activityapi
```
参数上线前跑体验模拟:
```bash
go run ./tools/lucky-gift-v2-demo \
-users 1000 \
-devices 900 \
-rooms 50 \
-draws-min 0 \
-draws-max 100000 \
-unique-draws \
-base-rtp 95 \
-cost 500 \
-global-window 100000 \
-gift-window 100000 \
-user-csv /tmp/lucky-gift-users.csv
```
验收口径:
| Check | Requirement |
| 项 | 要求 |
| --- | --- |
| `100000` paid draws | 基础 RTP 偏差 `< 1%` |
| `1000000` paid draws | 基础 RTP 偏差 `< 0.5%` |
| lucky gift RTP | 幸运礼物整体子窗口独立收敛 |
| pool safety | 三层池不低于 reserve |
| subsidy isolation | 房间气氛/活动补贴不进入基础 RTP |
| idempotency | 重复 command_id 返回同一 draw |
## Rollout
建议上线顺序:
1. 后台创建规则草稿,`enabled=false`
2. 跑模型模拟并审核参数。
3. 发布 `enabled=true` 到全局幸运礼物配置。
4. 只开放内部房间或白名单房间。
5. 观察 `base_rtp``effective_rtp`、pool balance、risk reject、outbox pending。
6. 稳定后逐步开放更多房间和礼物。
必须保留的开关:
| Switch | Effect |
| --- | --- |
| `enabled=false` | 全站幸运礼物停止抽奖 |
| `large_tier_enabled=false` | 关闭高倍奖档 |
| `activity_budget=0` | 关闭活动补贴 |
| `room_atmosphere_rate_ppm=0` | 关闭房间气氛补贴 |
| 降低各风控 cap | 快速压缩风险敞口 |
| `100000` paid draws | 基础 RTP 偏差 `< 1pp` |
| `1000000` paid draws | 基础 RTP 偏差 `< 0.5pp` |
| pool safety | 三层池和气氛池不低于 reserve |
| idempotency | 重复 `command_id` 返回同一 draw |
| reward status | 有返奖时先 `pending`worker 成功后 `granted` |
| Flutter 体验 | 不本地加余额;余额以 `WalletBalanceChanged` 或余额查询为准 |

View File

@ -0,0 +1,745 @@
# 房内红包需求接口拆分
## 1. 结论
房内红包拆成两类通道:
- HTTP 接口:承载有状态业务,包括配置、发送、领取、详情、记录、退款后台操作;房间在线态使用语音房通用接口。
- IM 消息:承载实时 UI 触达,包括房内弹窗、延时红包同语区飘窗、倒计时结束强弹、侧边栏数量刷新。
本需求以产品文档为准:
- 红包类型:即时红包、延时红包。
- 发送成功立刻扣除金币。
- 发送者本人可以领取自己发出的红包。
- 红包有效领取时间为 24 小时,过期未领取金额退回发送者。
- 即时红包不触发同语区广播飘窗。
- 延时红包发送成功后触发同语区广播飘窗。
- 当前房间内无论即时还是延时,发送成功后都需要立刻弹出抢红包窗口。
现有历史文档中的 `2 小时退款` 不适用于本次新需求,应改为 24 小时。
## 2. 统一响应约定
现有 Go 网关成功响应会同时返回 `body``data`,客户端读取任意一个都可以。
成功响应:
```json
{
"status": true,
"errorCode": 0,
"errorMsg": "success",
"body": {},
"code": "success",
"message": "success",
"data": {},
"time": 1780000000000
}
```
错误响应:
```json
{
"status": false,
"errorCode": 409,
"errorCodeName": "voice_room_red_packet_expired",
"errorMsg": "red packet expired",
"body": null,
"code": "voice_room_red_packet_expired",
"message": "red packet expired",
"data": null,
"time": 1780000000000
}
```
接口文档下文只定义 `body/data` 中的业务字段。
## 3. 哪些需要使用 IM
| 场景 | IM key | 发送范围 | 使用 IM 的原因 |
| --- | --- | --- | --- |
| 红包创建后当前房间立刻弹窗 | `ROOM_RED_PACKET``event=CREATED``scope=ROOM` | 当前房间 IM 群 | 房内所有在线用户需要实时收到弹窗,不能依赖轮询 |
| 延时红包同语区飘窗引流 | `ROOM_RED_PACKET``event=CREATED``scope=REGION` | 发送者所在同语区 IM 群 | 首页和其他房间用户没有主动请求,现有区域飘屏能力通过语区 IM 群触达 |
| 延时红包倒计时结束强弹 | `ROOM_RED_PACKET``event=OPEN_DUE``scope=ROOM` | 当前房间 IM 群 | 用户可能提前关闭弹窗,到点必须再次强制弹出 |
| 红包抢完、过期、退款后刷新侧边栏 | `ROOM_RED_PACKET``event=STATUS_CHANGED``scope=ROOM` | 当前房间 IM 群 | 房内侧边栏红包数量需要实时减少 |
不使用 IM 的动作:
| 动作 | 原因 |
| --- | --- |
| 发送红包 | 需要鉴权、扣款、档位校验、日上限、幂等落库 |
| 抢红包 | 需要鉴权、并发防超卖、领取幂等、钱包入账 |
| 查询配置 | 普通读接口,礼物面板打开时请求即可 |
| 查询详情和记录 | 需要分页、权限和稳定数据 |
| 过期退款 | 服务端定时任务和后台补偿,不应由客户端 IM 触发 |
## 4. IM 消息格式
现有腾讯 IM 自定义消息通过 `TIMCustomElem.MsgContent.Data` 发送 JSON 字符串。业务层统一使用外层:
```json
{
"type": "ROOM_RED_PACKET",
"data": {}
}
```
腾讯 IM 实际 payload
```json
{
"MsgType": "TIMCustomElem",
"MsgContent": {
"Data": "{\"type\":\"ROOM_RED_PACKET\",\"data\":{...}}",
"Desc": "customBody",
"Sound": "dingdong.aiff"
}
}
```
### 4.1 CREATED
当前房间弹窗和同语区飘窗都使用 `CREATED`,通过 `scope` 区分。
```json
{
"type": "ROOM_RED_PACKET",
"data": {
"event": "CREATED",
"scope": "ROOM",
"packetNo": "VRRP2026052712340001",
"packetId": "VRRP2026052712340001",
"roomId": "9001",
"regionCode": "AR",
"packetMode": "DELAYED",
"senderUserId": "10001",
"actualAccount": "10001",
"userNickname": "Tom",
"userAvatar": "https://example.com/avatar.png",
"totalAmount": 15000,
"totalCount": 5,
"remainCount": 5,
"status": "ACTIVE",
"delaySeconds": 300,
"claimStartTime": 1780000000000,
"expireTime": 1780086400000,
"serverTime": 1779999700000
}
}
```
字段说明:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `event` | string | 固定为 `CREATED` |
| `scope` | string | `ROOM` 表示房间弹窗,`REGION` 表示同语区飘窗 |
| `packetNo` | string | 红包唯一编号,接口参数使用该字段 |
| `packetId` | string | 兼容旧 Flutter 飘屏字段,值同 `packetNo` |
| `roomId` | string | 红包所属房间,点击飘窗进入该房间 |
| `regionCode` | string | 语区编码 |
| `packetMode` | string | `IMMEDIATE``DELAYED` |
| `senderUserId` | string | 发送者用户 ID |
| `actualAccount` | string | 发送者账号 |
| `userNickname` | string | 发送者昵称 |
| `userAvatar` | string | 发送者头像 |
| `totalAmount` | int64 | 红包总金币 |
| `totalCount` | int | 红包份数 |
| `remainCount` | int | 剩余可领份数 |
| `status` | string | 红包状态 |
| `delaySeconds` | int | 后台配置的延时秒数 |
| `claimStartTime` | int64 | 可领取开始时间,毫秒时间戳 |
| `expireTime` | int64 | 过期时间,毫秒时间戳,创建后 24 小时 |
| `serverTime` | int64 | 服务端当前时间,毫秒时间戳 |
发送规则:
- 即时红包:只发送 `scope=ROOM` 到当前房间 IM 群。
- 延时红包:发送 `scope=ROOM` 到当前房间 IM 群;同时发送 `scope=REGION` 到同语区 IM 群。
### 4.2 OPEN_DUE
延时红包倒计时结束后,服务端向当前房间发送强弹消息。
```json
{
"type": "ROOM_RED_PACKET",
"data": {
"event": "OPEN_DUE",
"scope": "ROOM",
"packetNo": "VRRP2026052712340001",
"packetId": "VRRP2026052712340001",
"roomId": "9001",
"packetMode": "DELAYED",
"claimStartTime": 1780000000000,
"expireTime": 1780086400000,
"serverTime": 1780000000000
}
}
```
### 4.3 STATUS_CHANGED
红包被抢完、过期、退款完成后,服务端向当前房间发送状态刷新消息。
```json
{
"type": "ROOM_RED_PACKET",
"data": {
"event": "STATUS_CHANGED",
"scope": "ROOM",
"packetNo": "VRRP2026052712340001",
"packetId": "VRRP2026052712340001",
"roomId": "9001",
"status": "EXPIRED",
"remainCount": 0,
"refundedAmount": 3000,
"serverTime": 1780086400000
}
}
```
客户端收到后刷新侧边栏未领取数量;如果当前展示的是该红包弹窗或详情页,同步刷新状态。
## 5. App HTTP 接口
### 5.1 获取当前用户语区 IM 群
```http
GET /app/region-im-group/current
```
兼容旧路径:
```http
GET /app/voice-room/red-packet/im-group
```
返回:
```json
{
"configured": true,
"sysOrigin": "LIKEI",
"regionCode": "AR",
"groupId": "@TGS#xxxx",
"groupName": "region-im-group-LIKEI-AR",
"status": "ACTIVE"
}
```
客户端处理:
- `configured=true``groupId` 非空时加入腾讯 IM 群。
- `joinGroup` 返回成功或已在群内都按成功处理。
- 区域变化时退出旧区域群并加入新区域群。
### 5.2 通用房间心跳
```http
POST /api/v1/rooms/heartbeat
```
请求:
```json
{
"room_id": "9001",
"command_id": "cmd_heartbeat_9001_10001_1780000000000"
}
```
返回:
```json
{
"result": {
"ok": true
},
"user": {
"user_id": "10001",
"room_id": "9001",
"last_seen_at_ms": 1780000000000
},
"room": {}
}
```
用途:
- 这是语音房通用 presence 能力,不属于红包专用接口。
- 只刷新已经存在的业务 presence不能替代进房 `JoinRoom`
- 红包发送、红包领取、侧边栏有效红包查询都依赖该 presence 判断用户是否仍在当前房间。
- 用户不在房间、已离房、被踢或房间关闭时,心跳应失败,客户端应停止心跳并清理本地房间态。
客户端心跳间隔由房间模块统一配置,必须小于 room-service 的 presence stale 窗口。
### 5.3 通用离开房间
```http
POST /api/v1/rooms/leave
```
请求:
```json
{
"room_id": "9001",
"command_id": "cmd_leave_9001_10001_1780000000000"
}
```
返回:
```json
{
"result": {
"ok": true
},
"room": {}
}
```
用途:
- 这是语音房通用离房接口,不属于红包专用接口。
- 主动关闭房间页、切换账号、被客户端确认退出时调用。
- 离房后 room-service 移除业务 presence红包领取接口再校验该用户时应返回不在房间。
- 如果用户在麦上,离房流程同时释放麦位。
- LeaveRoom 后客户端必须停止房间心跳、退出 RTC 房、退出或忽略 IM 房间群消息。
### 5.4 红包配置
```http
GET /app/voice-room/red-packet/config
```
返回:
```json
{
"enabled": true,
"dailySendLimit": 10,
"todaySendCount": 1,
"delaySeconds": 300,
"expireSeconds": 86400,
"amountOptions": [15000, 30000, 70000, 100000],
"countOptions": [5, 10, 20, 50],
"ruleUrl": "https://example.com/red-packet-rule"
}
```
参数定义:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `enabled` | bool | 红包功能开关 |
| `dailySendLimit` | int | 每日发送上限 |
| `todaySendCount` | int | 当前用户当天已发送数量 |
| `delaySeconds` | int | 延时红包等待秒数,后台全局唯一配置 |
| `expireSeconds` | int | 固定 86400 秒 |
| `amountOptions` | int64[] | 后台配置金额档位 |
| `countOptions` | int[] | 后台配置红包份数档位 |
| `ruleUrl` | string | 红包规则 H5 地址 |
### 5.5 发送红包
```http
POST /app/voice-room/red-packet/send
```
请求:
```json
{
"requestId": "client-unique-id",
"roomId": "9001",
"packetMode": "DELAYED",
"totalAmount": 15000,
"totalCount": 5
}
```
参数定义:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `requestId` | string | 是 | 客户端幂等 ID重复请求返回同一红包 |
| `roomId` | string | 是 | 当前房间 ID |
| `packetMode` | string | 是 | `IMMEDIATE``DELAYED` |
| `totalAmount` | int64 | 是 | 总金币,必须命中后台金额档位 |
| `totalCount` | int | 是 | 红包份数,必须命中后台份数档位 |
返回:
```json
{
"packetNo": "VRRP2026052712340001",
"roomId": "9001",
"packetMode": "DELAYED",
"totalAmount": 15000,
"totalCount": 5,
"remainAmount": 15000,
"remainCount": 5,
"claimStartTime": "2026-05-27T21:05:00+08:00",
"claimStartTimeMs": 1780000000000,
"expireTime": "2026-05-28T21:00:00+08:00",
"expireTimeMs": 1780086400000,
"status": "ACTIVE",
"balanceAfter": 100000
}
```
服务端逻辑:
1. 校验登录态、功能开关,并通过 room-service 通用 presence 校验发送者仍在 `roomId` 房间内。
2. 校验金额和份数档位。
3. 校验每日发送上限。
4. 校验余额并立刻扣金币。
5. 预拆随机份额,写红包主表和份额表。
6. 即时红包 `claimStartTime = createdAt`
7. 延时红包 `claimStartTime = createdAt + delaySeconds`
8. 所有红包 `expireTime = createdAt + 24h`
9. 发送对应 IM 消息。
### 5.6 房间有效红包列表
```http
GET /app/voice-room/red-packet/active?roomId=9001
```
用途:
- 进入房间时初始化侧边栏红包数量。
- 用户关闭弹窗后,通过侧边栏图标重新唤起红包窗口。
- 收到 IM 状态变化后刷新列表。
返回:
```json
{
"roomId": "9001",
"unclaimedCount": 3,
"items": [
{
"packetNo": "VRRP2026052712340001",
"packetMode": "DELAYED",
"senderUserId": "10001",
"senderNickname": "Tom",
"senderAvatar": "https://example.com/avatar.png",
"totalAmount": 15000,
"totalCount": 5,
"remainCount": 5,
"claimStartTime": "2026-05-27T21:05:00+08:00",
"claimStartTimeMs": 1780000000000,
"expireTime": "2026-05-28T21:00:00+08:00",
"expireTimeMs": 1780086400000,
"status": "ACTIVE",
"currentUserClaimed": false,
"currentUserAmount": 0
}
]
}
```
`unclaimedCount` 统计当前用户未领取且未过期、未抢完的红包数量。
### 5.7 抢红包
```http
POST /app/voice-room/red-packet/claim
```
请求:
```json
{
"packetNo": "VRRP2026052712340001"
}
```
返回:
```json
{
"packetNo": "VRRP2026052712340001",
"claimed": true,
"amount": 3000,
"remainCount": 4,
"totalCount": 5,
"claimTime": "2026-05-27T21:05:10+08:00",
"claimTimeMs": 1780000010000,
"status": "ACTIVE"
}
```
领取失败但业务可展示的情况通过错误码返回:
- `voice_room_red_packet_room_presence_required`:用户不在红包所属房间内,需要先进入房间。
- `voice_room_red_packet_not_started`:延时红包未到可领取时间。
- `voice_room_red_packet_expired`:红包已过期。
- `voice_room_red_packet_finished`:红包已抢完。
- `voice_room_region_not_match`:非同语区不可领取。
领取前校验:
- 抢红包接口不创建、不刷新房间 presence只读取 room-service 当前 presence。
- 用户必须仍在 `packetNo` 对应的 `roomId` 房间内,否则返回 `voice_room_red_packet_room_presence_required`
- 进入房间和离开房间仍由通用房间接口负责,不在红包接口里重复实现。
幂等要求:
- 同一用户重复领取同一红包,返回第一次领取结果。
- 钱包入账处理中,返回 `claim_processing`,客户端可重试或查详情。
### 5.8 红包详情
```http
GET /app/voice-room/red-packet/detail?packetNo=VRRP2026052712340001
```
返回:
```json
{
"packetNo": "VRRP2026052712340001",
"roomId": "9001",
"packetMode": "DELAYED",
"sendTime": "2026-05-27 21:00:00",
"sendTimeMs": 1779999700000,
"totalCount": 5,
"totalAmount": 15000,
"remainCount": 4,
"remainAmount": 12000,
"claimedCount": 1,
"claimedAmount": 3000,
"refundAmount": 0,
"claimStartTime": "2026-05-27T21:05:00+08:00",
"claimStartTimeMs": 1780000000000,
"expireTime": "2026-05-28T21:00:00+08:00",
"expireTimeMs": 1780086400000,
"status": "ACTIVE",
"sender": {
"userId": "10001",
"account": "10001",
"nickname": "Tom",
"avatar": "https://example.com/avatar.png"
},
"currentUserClaimed": true,
"currentUserAmount": 3000
}
```
### 5.9 领取记录列表
```http
GET /app/voice-room/red-packet/claim-records?packetNo=VRRP2026052712340001&cursor=&limit=20
```
返回:
```json
{
"packetNo": "VRRP2026052712340001",
"totalCount": 5,
"claimedCount": 1,
"claimedAmount": 3000,
"records": [
{
"id": "2052643780674195456",
"userId": "10002",
"account": "10002",
"nickname": "Jerry",
"avatar": "https://example.com/avatar2.png",
"amount": 3000,
"claimTime": "2026-05-27 21:05:10",
"claimTimeMs": 1780000010000
}
],
"nextCursor": "2052643780674195456",
"hasMore": false
}
```
### 5.10 发送记录列表
```http
GET /app/voice-room/red-packet/sent-records?cursor=&limit=20
```
返回:
```json
{
"records": [
{
"packetNo": "VRRP2026052712340001",
"roomId": "9001",
"packetMode": "DELAYED",
"sendTime": "2026-05-27 21:00:00",
"sendTimeMs": 1779999700000,
"totalCount": 5,
"totalAmount": 15000,
"refundAmount": 0,
"status": "ACTIVE"
}
],
"nextCursor": "VRRP2026052712340001",
"hasMore": false
}
```
列表页展示字段:
- 发送时间:`sendTime`
- 红包个数:`totalCount`
- 红包总金额:`totalAmount`
- 退回金额:`refundAmount`
## 6. 后台接口
### 6.1 查询配置
```http
GET /resident-activity/voice-room-red-packet/config
```
返回:
```json
{
"enabled": true,
"dailySendLimit": 10,
"delaySeconds": 300,
"expireSeconds": 86400,
"amountOptions": [15000, 30000, 70000, 100000],
"countOptions": [5, 10, 20, 50],
"ruleUrl": "https://example.com/red-packet-rule"
}
```
### 6.2 保存配置
```http
POST /resident-activity/voice-room-red-packet/config
```
请求:
```json
{
"enabled": true,
"dailySendLimit": 10,
"delaySeconds": 300,
"amountOptions": [15000, 30000, 70000, 100000],
"countOptions": [5, 10, 20, 50],
"ruleUrl": "https://example.com/red-packet-rule"
}
```
说明:
- `expireSeconds` 固定为 86400不建议后台配置。
- `amountOptions``countOptions` 必须都是正整数数组。
- `totalAmount >= totalCount`,保证每份最小金额大于 0。
### 6.3 红包记录查询
```http
GET /resident-activity/voice-room-red-packet/records?sysOrigin=LIKEI&roomId=&senderUserId=&status=&startTime=&endTime=&page=1&pageSize=20
```
返回:
```json
{
"page": 1,
"pageSize": 20,
"total": 1,
"records": [
{
"packetNo": "VRRP2026052712340001",
"roomId": "9001",
"senderUserId": "10001",
"senderNickname": "Tom",
"packetMode": "DELAYED",
"totalAmount": 15000,
"totalCount": 5,
"claimedAmount": 3000,
"claimedCount": 1,
"refundAmount": 0,
"status": "ACTIVE",
"sendTime": "2026-05-27 21:00:00",
"expireTime": "2026-05-28 21:00:00"
}
]
}
```
### 6.4 退款重试
```http
POST /resident-activity/voice-room-red-packet/refund/retry
```
请求:
```json
{
"packetNo": "VRRP2026052712340001"
}
```
返回:
```json
{
"packetNo": "VRRP2026052712340001",
"refundAmount": 3000,
"refundStatus": "SUCCESS"
}
```
## 7. 错误码
| code | 场景 | 客户端处理 |
| --- | --- | --- |
| `voice_room_red_packet_disabled` | 功能关闭 | 隐藏入口或提示暂不可用 |
| `voice_room_red_packet_amount_invalid` | 金额不在后台档位 | 刷新配置 |
| `voice_room_red_packet_count_invalid` | 个数不在后台档位 | 刷新配置 |
| `voice_room_red_packet_daily_limit_exceeded` | 今日发送上限 | 提示今日发送次数已达上限 |
| `voice_room_red_packet_room_presence_required` | 用户不在当前房间在线态 | 提示需在房间内操作 |
| `voice_room_red_packet_insufficient_balance` | 余额不足 | 拉起充值弹窗 |
| `voice_room_region_missing` | 用户区域缺失 | 提示暂不可参与 |
| `voice_room_region_not_match` | 非同语区领取 | 提示非同语区不可领取 |
| `voice_room_red_packet_not_found` | 红包不存在 | 刷新列表 |
| `voice_room_red_packet_not_started` | 延时红包未到时间 | 展示倒计时 |
| `voice_room_red_packet_expired` | 红包已过期 | 提示已过期并刷新侧边栏 |
| `voice_room_red_packet_finished` | 红包已抢完 | 提示已抢完并刷新侧边栏 |
| `claim_processing` | 钱包入账处理中 | 展示处理中,可重试或查详情 |
| `voice_room_red_packet_wallet_deduct_failed` | 扣款失败 | 提示发送失败 |
| `voice_room_red_packet_wallet_income_failed` | 入账失败 | 提示领取处理中或稍后重试 |
| `voice_room_red_packet_refund_failed` | 退款失败 | 后台重试 |
## 8. 验证点
功能验证:
- 发送即时红包,当前房间用户收到 `ROOM_RED_PACKET CREATED scope=ROOM`,同语区群不收到飘窗。
- 发送延时红包,当前房间用户收到 `scope=ROOM` 弹窗,同语区用户收到 `scope=REGION` 飘窗。
- 延时红包未到 `claimStartTime` 领取返回 `voice_room_red_packet_not_started`
- 延时红包到点后房间收到 `OPEN_DUE`,已关闭弹窗的用户被强弹。
- 发送者本人可以领取自己发送的红包。
- 同一用户重复领取同一红包返回同一领取结果。
- 并发领取不会超过 `totalAmount``totalCount`
- 红包创建 24 小时后,未领取金额退回发送者。
- 领取成功、抢完、过期后房间侧边栏未领取数量实时减少。
接口验证:
- 所有成功接口同时返回 `body``data`
- 所有失败接口 `status=false``body/data=null``code``errorCodeName` 一致。
- `requestId` 重复发送只生成一个红包。
- `packetNo + userId` 重复领取只生成一条领取记录。

View File

@ -0,0 +1,420 @@
# 语音房房间用户列表前端接入文档
## 1. 接入范围
- 房间在线用户列表接口。
- 设置或取消房间管理员接口。
- 相关腾讯云 IM 房间系统消息。
- 前端错误处理建议。
## 2. 通用约定
### 2.1 鉴权
所有接口都需要登录态:
```http
Authorization: Bearer <access_token>
```
### 2.2 ID 类型
HTTP 返回里的用户 ID 统一按字符串处理,避免 JavaScript 大整数精度问题。
示例:
```json
{
"user_id": "10001"
}
```
### 2.3 时间类型
所有时间字段都是毫秒时间戳:
```json
{
"server_time_ms": 1760000006000
}
```
### 2.4 房间身份
房间用户身份拆成两个字段:
| 字段 | 说明 |
| --- | --- |
| `is_owner` | 是否是当前房间房主 |
| `room_role` | 是否是当前房间管理员,只表示管理员集合 |
`room_role` 只会返回:
| 值 | 说明 |
| --- | --- |
| `normal` | 当前房间普通用户 |
| `admin` | 当前房间管理员 |
注意:
- 房主不是管理员,房主用户返回 `is_owner = true`
- 房主如果不在管理员集合里,`room_role` 返回 `normal`
- 前端展示“房主”标识时看 `is_owner`,展示“管理员”标识时看 `room_role = admin`
- 当前登录用户是否展示管理员操作入口,优先看房间详情里的 `permissions.is_owner`;如果只在用户列表内判断,则看当前登录用户对应 item 的 `is_owner`
- `role` 是进房 presence 角色,例如 `audience`,不要用于判断房主或管理员。
## 3. 查询房间在线用户列表
### 3.1 接口定义
```http
GET /api/v1/rooms/{room_id}/online-users
```
### 3.2 Path 参数
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 房间 ID |
### 3.3 Query 参数
| 参数 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| `page` | int | 否 | `1` | 页码,从 `1` 开始 |
| `page_size` | int | 否 | `50` | 每页数量,最大 `100` |
| `sort` | string | 否 | `online` | `online` 按在线活跃排序;`gift_value` 按礼物价值排序 |
### 3.4 请求示例
```bash
curl -H "Authorization: Bearer $TOKEN" \
"https://api.global-interaction.com/api/v1/rooms/room_10001/online-users?page=1&page_size=50"
```
### 3.5 返回示例
```json
{
"items": [
{
"user_id": "10001",
"role": "audience",
"is_owner": true,
"room_role": "normal",
"gift_value": 188800,
"joined_at_ms": 1760000000000,
"last_seen_at_ms": 1760000005000,
"profile": {
"nickname": "Alice",
"avatar_url": "https://example.com/a.png"
}
},
{
"user_id": "10002",
"role": "audience",
"is_owner": false,
"room_role": "admin",
"gift_value": 0,
"joined_at_ms": 1760000001000,
"last_seen_at_ms": 1760000004000,
"profile": {
"nickname": "Bob",
"avatar_url": "https://example.com/b.png"
}
}
],
"total": 2,
"page": 1,
"page_size": 50,
"server_time_ms": 1760000006000
}
```
### 3.6 返回字段
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `items` | array | 当前页在线用户 |
| `items[].user_id` | string | 用户 ID |
| `items[].role` | string | 进房 presence 角色 |
| `items[].is_owner` | bool | 是否是当前房间房主 |
| `items[].room_role` | string | 当前房间管理员身份:`normal``admin` |
| `items[].gift_value` | int64 | 当前房间内累计送礼价值,未送礼返回 `0` |
| `items[].joined_at_ms` | int64 | 进房时间 |
| `items[].last_seen_at_ms` | int64 | 最近房间业务心跳时间 |
| `items[].profile.nickname` | string | 用户昵称 |
| `items[].profile.avatar_url` | string | 用户头像 |
| `total` | int64 | 在线用户总数 |
| `page` | int | 当前页 |
| `page_size` | int | 当前每页数量 |
| `server_time_ms` | int64 | 服务端时间 |
### 3.7 前端展示建议
- `gift_value` 展示的是该用户在当前房间内“送出”的礼物价值,不是收到的礼物价值。
- `gift_value = 0` 时仍然展示该用户。
- 房主标识看 `is_owner = true`
- 管理员标识看 `room_role = admin`
- 房主不是管理员,不要因为 `is_owner = true` 就把管理员标识点亮。
- 用户列表弹窗打开时,可以先拉第一页;滚动到底再拉下一页。
- 收到相关 IM 后,如果列表弹窗正在展示,可以局部更新;状态不确定时重新拉接口。
## 4. 设置或取消管理员,只有房主才显示
### 4.1 接口定义
```http
POST /api/v1/rooms/admin/set
Content-Type: application/json
```
### 4.2 请求参数
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 房间 ID |
| `command_id` | string | 是 | 客户端生成的幂等 ID重试同一次操作时保持不变 |
| `target_user_id` | string/int64 | 是 | 被设置或取消管理员的用户 ID |
| `enabled` | bool | 是 | `true` 设置管理员;`false` 取消管理员 |
### 4.3 设置管理员请求示例
```json
{
"room_id": "room_10001",
"command_id": "cmd-10001",
"target_user_id": "10002",
"enabled": true
}
```
### 4.4 取消管理员请求示例
```json
{
"room_id": "room_10001",
"command_id": "cmd-10002",
"target_user_id": "10002",
"enabled": false
}
```
### 4.5 返回示例
建议后端返回:
```json
{
"result": {
"applied": true,
"room_version": 12
},
"target_user_id": "10002",
"room_role": "admin",
"server_time_ms": 1760000006000
}
```
字段说明:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `result.applied` | bool | 本次请求是否实际改变了房间状态 |
| `result.room_version` | int64 | 房间状态版本 |
| `target_user_id` | string | 被操作用户 |
| `room_role` | string | 操作后的身份,`admin``normal` |
| `server_time_ms` | int64 | 服务端时间 |
如果后端第一版仍直接返回完整 room-service gRPC 结构,前端只需要关注命令是否成功;最终用户列表状态以接口重拉或 IM 刷新为准。
### 4.6 前端操作边界
- 只有房主可以设置或取消管理员。
- 房主可以操作所有非房主用户的管理员身份。
- 管理员之间不可互相设置或取消管理员。
- 房主不是管理员,不允许对房主执行设置或取消管理员。
- 目标用户必须当前在房间内。
- 前端重试同一次操作时,必须复用同一个 `command_id`
- 操作成功后,可以等待 `room_admin_changed` IM也可以直接刷新用户列表。
## 5. 错误处理
### 5.1 通用错误
| HTTP 状态 | 场景 | 前端处理 |
| --- | --- | --- |
| `401` | 未登录或 token 失效 | 跳登录或刷新登录态 |
| `403` | 无权限,例如用户不在房间或不是房主 | 提示无权限,并刷新房间状态 |
| `404` | 房间不存在或目标用户不在房间 | 提示目标不存在或用户已离开 |
| `409` | 状态冲突,例如目标用户是房主 | 提示当前状态不允许操作,并刷新用户列表 |
| `5xx` | 服务端异常 | 保留当前 UI提示稍后重试 |
### 5.2 查询用户列表错误
| 场景 | 前端处理 |
| --- | --- |
| 当前用户不在房间 | 关闭用户列表弹窗,刷新当前房间状态 |
| 分页为空 | 正常展示空列表或结束加载更多 |
| `page_size` 超过上限 | 使用服务端返回的 `page_size` 为准 |
### 5.3 设置管理员错误
| 场景 | 前端处理 |
| --- | --- |
| 非房主操作 | 隐藏或禁用管理入口,提示无权限 |
| 操作目标是房主 | 不展示管理员操作入口;如果服务端返回错误,刷新列表 |
| 目标用户已离房 | 提示用户已离开,刷新列表 |
| 重复点击 | 前端按钮进入 loading同一次请求复用同一个 `command_id` |
| 请求超时 | 可以用同一个 `command_id` 重试 |
## 6. 相关 IM
### 6.1 IM 消息形态
房间系统消息通过腾讯云 IM `TIMCustomElem` 下发。
前端解析入口:
```text
TIMCustomElem.MsgContent.Data
```
`Data` 是 JSON 字符串。
示例:
```json
{
"event_id": "evt-admin-10001",
"room_id": "room_10001",
"event_type": "room_admin_changed",
"actor_user_id": 10001,
"target_user_id": 10002,
"room_version": 12,
"attributes": {
"enabled": "true"
}
}
```
通用字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `event_id` | string | 事件唯一 ID前端用于去重 |
| `room_id` | string | 房间 ID |
| `event_type` | string | 事件类型 |
| `actor_user_id` | int64 | 操作人 |
| `target_user_id` | int64 | 被操作人 |
| `room_version` | int64 | 房间版本 |
| `attributes` | object | 字符串键值扩展字段 |
### 6.2 管理员变更 IM
事件类型:
```text
room_admin_changed
```
消息示例:
```json
{
"event_id": "evt-admin-10001",
"room_id": "room_10001",
"event_type": "room_admin_changed",
"actor_user_id": 10001,
"target_user_id": 10002,
"room_version": 12,
"attributes": {
"enabled": "true"
}
}
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `actor_user_id` | 发起操作的房主 |
| `target_user_id` | 被设置或取消管理员的用户 |
| `attributes.enabled` | `"true"` 设置管理员;`"false"` 取消管理员 |
前端处理:
- 使用 `event_id` 去重。
- 如果当前用户是 `target_user_id`,更新当前用户管理权限 UI。
- 如果用户列表已打开,更新对应用户的 `room_role`
- 如果列表数据不完整,重新请求在线用户列表接口。
### 6.3 送礼成功 IM
事件类型:
```text
room_gift_sent
```
消息示例:
```json
{
"event_id": "evt-gift-10001",
"room_id": "room_10001",
"event_type": "room_gift_sent",
"actor_user_id": 10002,
"target_user_id": 10003,
"gift_value": 5200,
"room_heat": 999999,
"room_version": 13,
"attributes": {
"gift_id": "rose",
"pool_id": "",
"gift_count": "10",
"billing_receipt_id": "receipt-10001",
"coin_spent": "5200",
"gift_point_added": "5200",
"price_version": "v1"
}
}
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `actor_user_id` | 送礼用户,用户列表里需要累加 `gift_value` 的用户 |
| `target_user_id` | 收礼用户 |
| `gift_value` | 本次送礼价值增量 |
| `room_heat` | 送礼后的房间热度 |
| `attributes.gift_id` | 礼物 ID |
| `attributes.gift_count` | 礼物数量,字符串 |
| `attributes.billing_receipt_id` | 钱包结算流水 |
前端处理:
- 如果用户列表已打开,并且当前页包含 `actor_user_id`,可以执行 `gift_value += event.gift_value`
- 如果当前列表按 `gift_value` 排序,建议重新拉接口,避免分页顺序错乱。
- 如果当前页不包含 `actor_user_id`,不要手动插入用户,分页完整性以接口为准。
### 6.4 进出房 IM
用户列表也会受进出房影响:
| 事件 | 建议处理 |
| --- | --- |
| `room_user_joined` | 用户列表打开时重新拉列表 |
| `room_user_left` | 用户列表打开时移除用户或重新拉列表 |
## 7. 前端刷新策略
推荐策略:
1. 打开用户列表弹窗时,请求 `GET /api/v1/rooms/{room_id}/online-users`
2. 收到 `room_admin_changed` 时,局部更新 `room_role`;不确定时重拉。
3. 收到 `room_gift_sent` 时,局部累加 `gift_value`;如果当前排序依赖礼物价值,重拉。
4. 收到 `room_user_joined``room_user_left` 时,列表打开状态下重拉。
5. 所有 IM 使用 `event_id` 去重。

View File

@ -0,0 +1,574 @@
# 语音房房间用户列表后端技术架构设计
## 1. 背景
当前 `room-service` 已有基础能力:
- `GET /api/v1/rooms/{room_id}/online-users` 查询在线用户。
- `POST /api/v1/rooms/admin/set` 设置或取消管理员。
- `RoomAdminChanged` outbox 事件和腾讯云 IM `room_admin_changed` 消息。
- `RoomGiftSent` outbox 事件和腾讯云 IM `room_gift_sent` 消息。
现有在线用户列表只返回 presence 信息,不包含:
- 用户在当前房间内累计送出的礼物价值。
- 用户针对当前房间的管理身份:`normal``admin`
## 2. 目标
本次后端开发目标:
1. 在线用户列表返回所有当前在房间内的用户。
2. 每个用户返回当前房间内累计送礼价值 `gift_value`
3. 每个用户返回是否为房主 `is_owner`
4. 每个用户返回当前房间管理员身份 `room_role`,只允许 `normal``admin`
5. 复用同一个接口完成设置管理员和取消管理员。
6. 管理员变更和送礼成功继续通过 IM 通知客户端刷新。
## 3. 非目标
本次不做:
- 历史房间贡献榜。
- 日榜、周榜、月榜。
- 离线预设管理员。
- 平台后台管理员权限。
- 客户端上报价格参与礼物价值计算。
- 使用 `GiftRank` 作为完整用户礼物价值数据源。
## 4. 现有代码基础
### 4.1 Proto
文件:
```text
api/proto/room/v1/room.proto
```
现有 RPC
```proto
rpc ListRoomOnlineUsers(ListRoomOnlineUsersRequest) returns (ListRoomOnlineUsersResponse);
rpc SetRoomAdmin(SetRoomAdminRequest) returns (SetRoomAdminResponse);
```
现有 `RoomUser.role` 已用于 presence 角色,不能改成 `normal/admin`
### 4.2 房间状态
文件:
```text
services/room-service/internal/room/state/state.go
```
当前 `RoomState` 已包含:
| 字段 | 说明 |
| --- | --- |
| `OnlineUsers` | 当前房间业务在线用户 |
| `AdminUsers` | 当前房间管理员集合 |
| `GiftRank` | 房间礼物榜 Top N |
注意:`GiftRank``rank_limit` 限制,不能覆盖所有用户。
### 4.3 设置管理员
文件:
```text
services/room-service/internal/room/service/management.go
```
`SetRoomAdmin` 需要满足以下行为:
- 只有房主可操作。
- 目标用户必须在当前房间内。
- 房主不是管理员,不允许对房主执行设置或取消管理员。
- 管理员之间不可互相操作。
- 重复设置同一状态是 no-op。
- 状态变化时生成 `RoomAdminChanged` outbox 事件。
- IM 事件类型为 `room_admin_changed`
这部分服务逻辑可以复用,主要补 HTTP DTO 和文档约束。
### 4.4 送礼事件
文件:
```text
services/room-service/internal/room/service/gift.go
```
当前 `SendGift` 成功后:
- 使用 wallet-service 结算结果。
- 使用 `heatValue` 增加房间热度。
- 使用 `heatValue` 更新本地礼物榜。
- 生成 `RoomGiftSent``RoomHeatChanged``RoomRankChanged` outbox 事件。
- 对客户端只投递 `room_gift_sent` IM。
新增 `gift_value` 必须使用同一个 `heatValue` 口径。
## 5. 数据模型设计
### 5.1 在线来源
在线用户来源保持不变:
```text
room_user_presence
```
判断条件:
```text
app_code = ?
room_id = ?
status = active
```
### 5.2 礼物价值统计表
新增完整房间用户送礼统计表:
```sql
CREATE TABLE room_user_gift_stats (
app_code VARCHAR(64) NOT NULL,
room_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
gift_value BIGINT NOT NULL DEFAULT 0,
last_gift_at_ms BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, room_id, user_id),
KEY idx_room_user_gift_stats_room_value (
app_code,
room_id,
gift_value DESC,
last_gift_at_ms DESC,
user_id
)
);
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `app_code` | 应用编码 |
| `room_id` | 房间 ID |
| `user_id` | 送礼用户 ID |
| `gift_value` | 当前房间生命周期内累计送礼价值 |
| `last_gift_at_ms` | 最近一次成功送礼时间 |
| `created_at_ms` | 创建时间 |
| `updated_at_ms` | 更新时间 |
### 5.3 为什么不复用 `GiftRank`
`GiftRank` 是榜单结构,不是完整统计结构:
- 受 `rank_limit` 限制,默认只保留 Top N。
- 不包含送礼价值为 `0` 的用户。
- 在线用户超过 Top N 时无法返回完整 `gift_value`
所以在线用户列表必须读取完整统计表,并且对无统计记录的在线用户返回 `gift_value = 0`
## 6. Proto 设计
新增在线用户展示结构,不改原 `RoomUser` 语义:
```proto
message RoomOnlineUser {
int64 user_id = 1;
string role = 2;
string room_role = 3;
int64 gift_value = 4;
int64 joined_at_ms = 5;
int64 last_seen_at_ms = 6;
bool is_owner = 7;
}
```
扩展响应:
```proto
message ListRoomOnlineUsersResponse {
repeated RoomUser users = 1;
int64 total = 2;
int32 page = 3;
int32 page_size = 4;
int64 server_time_ms = 5;
repeated RoomOnlineUser items = 6;
}
```
兼容策略:
- `users = 1` 保留旧客户端和旧 gateway 兼容。
- 新 gateway 优先使用 `items = 6`
- 不把 `RoomUser.role` 改成 `normal/admin`
## 7. 查询链路设计
### 7.1 HTTP 链路
```text
gateway-service
-> room-service ListRoomOnlineUsers
-> user-service/profile 聚合用户展示资料
-> HTTP 返回 items
```
HTTP 返回字段需要新增:
| 字段 | 来源 |
| --- | --- |
| `is_owner` | `user_id == owner_user_id` |
| `room_role` | room-service 基于管理员集合派生 |
| `gift_value` | room-service 查询统计表 |
| `joined_at_ms` | room_user_presence |
| `last_seen_at_ms` | room_user_presence |
| `profile` | gateway 现有聚合逻辑 |
### 7.2 room-service 查询流程
1. 校验 `room_id``viewer_user_id`、分页参数。
2. 校验调用方当前在该房间内。
3. 读取当前房间 snapshot拿到 `owner_user_id``admin_user_ids`
4. 查询 `room_user_presence` 当前在线用户分页。
5. 左连接 `room_user_gift_stats` 获取 `gift_value`
6. 服务层派生每个用户的 `is_owner``room_role`
7. 返回 `items` 和兼容字段 `users`
### 7.3 `is_owner``room_role` 派生规则
```text
if user_id == owner_user_id:
is_owner = true
else:
is_owner = false
if user_id in admin_user_ids:
room_role = "admin"
else:
room_role = "normal"
```
房主不是管理员,所以不能因为 `is_owner = true` 就返回 `room_role = admin`
不建议把 `room_role` 冗余写入 `room_user_presence`,因为管理员身份属于房间状态,不属于 presence 状态。
如果当前代码为了兼容权限把房主写入或恢复进 `AdminUsers`,在线列表展示和管理员操作必须做过滤:
- 展示时 `user_id == owner_user_id` 固定按 `is_owner = true` 返回,`room_role` 不因房主身份变成 `admin`
- 设置或取消管理员时,`target_user_id == owner_user_id` 直接拒绝。
- 管理员集合里的 owner 不能对外表现为管理员。
### 7.4 排序
第一版建议保持现有在线排序,避免影响旧客户端:
```sql
ORDER BY last_seen_at_ms DESC, user_id ASC
```
如果要支持 `sort=gift_value`
```sql
ORDER BY gift_value DESC, last_seen_at_ms DESC, user_id ASC
```
分页总数仍以当前在线用户数为准。
## 8. 写入链路设计
### 8.1 送礼成功累计 `gift_value`
`SendGift` 钱包扣款成功并准备提交房间状态时,使用结算后的 `heatValue` 累加:
```sql
INSERT INTO room_user_gift_stats (
app_code,
room_id,
user_id,
gift_value,
last_gift_at_ms,
created_at_ms,
updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
gift_value = gift_value + VALUES(gift_value),
last_gift_at_ms = VALUES(last_gift_at_ms),
updated_at_ms = VALUES(updated_at_ms);
```
写入边界:
- 只在送礼成功后写入。
- 使用 `heatValue`,不能使用客户端价格。
- 统计送礼用户 `actor_user_id`,不是收礼用户。
- 必须和现有命令幂等边界一致,重复命令不能重复累计。
### 8.2 幂等要求
已有房间命令链路通过 `command_id` 判断命令是否已应用。
新增统计写入必须满足:
- 命令第一次成功应用时累计一次。
- 同一 `command_id` 重试时不再次累计。
- recovery replay 不能重复写统计表。
推荐做法:
- 将统计写入纳入保存 mutation 的同一事务或同一 applied 分支。
- 已 applied 的命令直接返回已有结果,不进入统计累加。
### 8.3 历史数据补偿
如果上线前已有房间内送礼数据,需要决定是否补偿:
- 不补偿:上线后新送礼才有 `gift_value`,旧房间可能显示不完整。
- 补偿:从已有 `RoomGiftSent` outbox 或命令日志回放,按 `sender_user_id``gift_value` 聚合写入 `room_user_gift_stats`
产品如果要求老房间准确,必须执行补偿。
## 9. 管理员接口设计
### 9.1 现有 HTTP 接口
```http
POST /api/v1/rooms/admin/set
```
请求:
```json
{
"room_id": "room_10001",
"command_id": "cmd-10001",
"target_user_id": "10002",
"enabled": true
}
```
### 9.2 服务边界
保持现有 `SetRoomAdmin` 逻辑:
- 只有房主可设置或取消。
- 目标用户必须当前在房间内。
- 房主不是管理员,不能对房主执行设置或取消管理员。
- 管理员之间不可互相操作。
- 重复设置同一状态不生成重复 outbox 和 IM。
- 成功变更时写入 `RoomAdminChanged` outbox。
### 9.3 Gateway DTO
建议不要直接透出完整 `RoomSnapshot`,新增稳定 HTTP DTO
```json
{
"result": {
"applied": true,
"room_version": 12
},
"target_user_id": "10002",
"room_role": "admin",
"server_time_ms": 1760000006000
}
```
## 10. IM 设计
### 10.1 投递形态
room-service 通过 outbox worker 向腾讯云 IM 投递 `TIMCustomElem`
| Tencent 字段 | 值 |
| --- | --- |
| `MsgType` | `TIMCustomElem` |
| `MsgContent.Ext` | `room_system_message` |
| `MsgContent.Desc` | 客户端事件类型,例如 `room_admin_changed` |
| `MsgContent.Data` | JSON 序列化后的 `RoomEvent` |
| `CloudCustomData` | 同一份 JSON用于服务端透传和排查 |
### 10.2 管理员变更
outbox 事件:
```text
RoomAdminChanged
```
客户端事件:
```text
room_admin_changed
```
payload
```json
{
"event_id": "evt-admin-10001",
"room_id": "room_10001",
"event_type": "room_admin_changed",
"actor_user_id": 10001,
"target_user_id": 10002,
"room_version": 12,
"attributes": {
"enabled": "true"
}
}
```
字段来源:
| 字段 | 来源 |
| --- | --- |
| `event_id` | outbox event id |
| `actor_user_id` | `RoomAdminChanged.actor_user_id` |
| `target_user_id` | `RoomAdminChanged.target_user_id` |
| `attributes.enabled` | `RoomAdminChanged.enabled` 转字符串 |
### 10.3 送礼成功
outbox 事件:
```text
RoomGiftSent
```
客户端事件:
```text
room_gift_sent
```
payload
```json
{
"event_id": "evt-gift-10001",
"room_id": "room_10001",
"event_type": "room_gift_sent",
"actor_user_id": 10002,
"target_user_id": 10003,
"gift_value": 5200,
"room_heat": 999999,
"room_version": 13,
"attributes": {
"gift_id": "rose",
"pool_id": "",
"gift_count": "10",
"billing_receipt_id": "receipt-10001",
"coin_spent": "5200",
"gift_point_added": "5200",
"price_version": "v1"
}
}
```
字段来源:
| 字段 | 来源 |
| --- | --- |
| `actor_user_id` | 送礼用户 |
| `target_user_id` | 收礼用户 |
| `gift_value` | 本次 `heatValue` |
| `room_heat` | 送礼后的房间热度 |
| `attributes.billing_receipt_id` | wallet-service 结算流水 |
### 10.4 不新增列表专用 IM
用户列表接口本身不新增 IM。
客户端刷新依赖已有事件:
| 变化 | IM |
| --- | --- |
| 进房 | `room_user_joined` |
| 离房 | `room_user_left` |
| 管理员变化 | `room_admin_changed` |
| 送礼成功 | `room_gift_sent` |
## 11. 错误边界
| 场景 | gRPC code | HTTP code |
| --- | --- | --- |
| `room_id` 为空 | `INVALID_ARGUMENT` | `400` |
| `viewer_user_id` 为空 | `INVALID_ARGUMENT` | `400` |
| 调用方不在房间内 | `PERMISSION_DENIED` | `403` |
| 房间不存在 | `NOT_FOUND` | `404` |
| 非房主设置管理员 | `PERMISSION_DENIED` | `403` |
| 目标用户不在房间 | `NOT_FOUND` | `404` |
| 操作目标是房主 | `FAILED_PRECONDITION` | `409` |
| `page_size` 超过上限 | 正常截断 | `200` |
## 12. 开发步骤
1. 新增 MySQL migration`room_user_gift_stats`
2. 修改 `api/proto/room/v1/room.proto`,新增 `RoomOnlineUser``ListRoomOnlineUsersResponse.items`
3. 重新生成 proto 代码。
4. 扩展 room repository
- 送礼统计累加方法。
- 在线用户列表查询左连接礼物统计。
5. 修改 `SendGift` 成功提交路径,累计 `gift_value`
6. 修改 `ListRoomOnlineUsers` service
- 校验 viewer 在房间内。
- 读取 snapshot 派生 `is_owner``room_role`
- 返回 `items`
7. 修改 gateway 在线用户 DTO
- 增加 `is_owner`
- 增加 `room_role`
- 增加 `gift_value`
- 增加 `joined_at_ms`
8. 修改 gateway 管理员设置响应 DTO避免直接暴露完整 snapshot。
9. 补充单元测试和 HTTP handler 测试。
## 13. 测试清单
### 13.1 在线用户列表
- 普通用户返回 `room_role = normal`
- 房主返回 `is_owner = true`
- 房主不是管理员时返回 `room_role = normal`
- 被设置管理员的用户返回 `room_role = admin`
- 未送礼用户返回 `gift_value = 0`
- 已送礼用户返回累计 `gift_value`
- 在线用户超过 `rank_limit` 时,非 Top N 用户仍有正确 `gift_value`
- 不在房间内的用户查询失败。
### 13.2 管理员设置
- 房主设置管理员成功。
- 房主取消管理员成功。
- 非房主设置失败。
- 目标用户不在房间时失败。
- 对房主执行设置或取消管理员失败。
- 管理员之间互相设置或取消管理员失败。
- 重复设置同一状态不重复发送 `RoomAdminChanged`
### 13.3 送礼统计
- 成功送礼累计一次。
- 钱包扣款失败不累计。
- 同一 `command_id` 重试不重复累计。
- recovery replay 不重复累计。
### 13.4 IM
- 管理员变更投递 `room_admin_changed`
- `attributes.enabled = "true"` 表示设置管理员。
- `attributes.enabled = "false"` 表示取消管理员。
- 送礼成功投递 `room_gift_sent`
- `room_gift_sent.gift_value` 与统计表累加值口径一致。
建议执行:
```bash
go test ./services/room-service/... ./services/gateway-service/...
```

View File

@ -55,14 +55,17 @@ func DefaultConfig(appCode, poolID string) domain.Config {
}
}
// validateConfig 只校验会破坏线上抽奖不变量的字段;展示类默认值留给后台表单处理。
func validateConfig(config domain.Config) error {
if normalizePoolID(config.PoolID) == "" {
return xerr.New(xerr.InvalidArgument, "lucky gift pool id is required")
}
// 当前 DB 历史列名仍叫 gift_id但业务语义已经是 pool_id这里强制二者一致避免后台提交真实礼物 ID 后把窗口和奖池切散。
config.GiftID = normalizePoolID(config.PoolID)
if config.GiftPrice <= 0 || config.TargetRTPPPM <= 0 || config.TargetRTPPPM > ppmScale {
return xerr.New(xerr.InvalidArgument, "gift price or target RTP is invalid")
}
// 入池比例低于目标 RTP 时,即使长期抽数足够也无法靠奖池支付基础返奖,必须在发布前拒绝。
if config.PoolRatePPM < config.TargetRTPPPM || config.PoolRatePPM > ppmScale {
return xerr.New(xerr.InvalidArgument, "pool rate must be between target RTP and 100%")
}
@ -83,6 +86,7 @@ func validateConfig(config domain.Config) error {
config.DeviceDailyPayoutCap <= 0 || config.RoomHourlyPayoutCap <= 0 || config.AnchorDailyPayoutCap <= 0 {
return xerr.New(xerr.InvalidArgument, "risk payout caps must be positive")
}
// tiers 是倍率列表归一化后的运行时候选集合;即使后台只提交 multiplier_ppms发布前也必须先生成 tiers。
if len(config.Tiers) == 0 {
return xerr.New(xerr.InvalidArgument, "reward tiers are required")
}
@ -95,18 +99,21 @@ func validateConfig(config domain.Config) error {
maxMultiplierPPM = tier.MultiplierPPM
}
}
// 最大倍率低于目标 RTP 时,窗口最后几抽可能没有足够高的基础候选追平目标,只能提前拒绝这类配置。
if maxMultiplierPPM < config.TargetRTPPPM {
return xerr.New(xerr.InvalidArgument, "max lucky gift multiplier must not be lower than target RTP")
}
return nil
}
// normalizeTiers 把后台“可中倍率”转换成三档体验池候选;后台传入的权重不会成为线上概率。
func normalizeTiers(tiers []domain.Tier, multipliers []int64, giftPrice, highMultiplier int64) ([]domain.Tier, []int64) {
multiplierPPMs := normalizeMultiplierPPMs(multipliers, tiers, giftPrice)
return buildMultiplierTiers(giftPrice, highMultiplier, multiplierPPMs), multiplierPPMs
}
func normalizeMultiplierPPMs(input []int64, tiers []domain.Tier, giftPrice int64) []int64 {
// 新后台直接提交 multiplier_ppms旧调用方如果仍提交 tiers则从 reward_coins 反推倍率以减少迁移断点。
values := append([]int64(nil), input...)
if len(values) == 0 {
for _, tier := range tiers {
@ -119,18 +126,21 @@ func normalizeMultiplierPPMs(input []int64, tiers []domain.Tier, giftPrice int64
}
}
}
// 没有任何输入时使用保守默认倍率,保证默认草稿可被后台查看和复制,但仍默认 disabled。
if len(values) == 0 {
values = defaultMultiplierPPMs()
}
seen := map[int64]bool{}
out := make([]int64, 0, len(values))
for _, value := range values {
// 负倍率没有业务含义;重复倍率会造成同一返奖点被重复计权,因此在入口处去重。
if value < 0 || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
// 全部被过滤时回退默认列表,避免构造出空 tiers 后在更深层事务里失败。
if len(out) == 0 {
out = defaultMultiplierPPMs()
}
@ -147,6 +157,7 @@ func buildMultiplierTiers(cost, highMultiplier int64, multipliers []int64) []dom
TierID: pool + "_" + multiplierTierID(multiplierPPM),
RewardCoins: cost * multiplierPPM / ppmScale,
MultiplierPPM: multiplierPPM,
// 权重固定为 0 是有意设计:线上概率由 RTP 缺口、奖池容量和风控容量运行时生成。
Weight: 0,
HighWaterOnly: highMultiplier > 0 && multiplierPPM >= highMultiplier*ppmScale,
Enabled: true,
@ -185,6 +196,7 @@ func multiplierTierID(multiplierPPM int64) string {
if fraction == 0 {
return fmt.Sprintf("%dx", whole)
}
// 小数倍率用下划线表达,避免 tier_id 出现点号后和后台路径/筛选语法冲突。
text := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%d_%06dx", whole, fraction), "0"), "_")
return text
}

View File

@ -100,6 +100,7 @@ func (s *Service) Check(ctx context.Context, cmd domain.CheckCommand) (domain.Ch
if err := s.requireRepository(); err != nil {
return domain.CheckResult{}, err
}
// Check 是送礼前拦截,不产生抽奖事实;这里只归一化入参,最终启停仍以仓储中的当前规则为准。
cmd.PoolID = normalizePoolID(cmd.PoolID)
cmd.GiftID = strings.TrimSpace(cmd.GiftID)
cmd.RoomID = strings.TrimSpace(cmd.RoomID)
@ -113,6 +114,7 @@ func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.Draw
if err := s.requireRepository(); err != nil {
return domain.DrawResult{}, err
}
// Draw 只接受扣费成功后的服务端事实;客户端不能提交价格、倍率、奖励或风控绕过字段。
cmd.CommandID = strings.TrimSpace(cmd.CommandID)
cmd.PoolID = normalizePoolID(cmd.PoolID)
cmd.GiftID = strings.TrimSpace(cmd.GiftID)
@ -126,6 +128,7 @@ func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.Draw
return domain.DrawResult{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid")
}
if cmd.PaidAtMS <= 0 {
// paid_at_ms 是扣费事实时间,缺失时用 UTC 服务端时间补齐,避免使用本地时区切风控窗口。
cmd.PaidAtMS = s.now().UTC().UnixMilli()
}
return s.repository.ExecuteLuckyGiftDraw(ctx, cmd, s.now().UTC().UnixMilli())
@ -162,12 +165,14 @@ func (s *Service) ProcessPendingDrawOutbox(ctx context.Context, options WorkerOp
return 0, err
}
options = normalizeWorkerOptions(options)
// outbox 先抢占再处理,避免多实例同时给同一 draw 返奖或重复投递房间 IM。
events, err := s.repository.ClaimPendingLuckyGiftOutbox(ctx, options.WorkerID, s.now().UTC().UnixMilli(), options.LockTTL, options.BatchSize)
if err != nil {
return 0, err
}
processed := 0
for _, event := range events {
// 单条失败立即返回,让外层记录批次错误;已处理成功的事件已经各自落 delivered不会被回滚。
if err := s.processDrawOutbox(ctx, event, options); err != nil {
return processed, err
}
@ -179,6 +184,7 @@ func (s *Service) ProcessPendingDrawOutbox(ctx context.Context, options WorkerOp
func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox, options WorkerOptions) error {
payload, err := decodeDrawOutboxPayload(event.PayloadJSON)
if err != nil {
// payload 不可解析说明抽奖事实旁路已经损坏,重试也无法恢复,直接走失败收敛。
return s.markOutboxFailed(ctx, event, options, err, false)
}
eventCtx := appcode.WithContext(ctx, event.AppCode)
@ -188,6 +194,7 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox
if s.wallet == nil {
return s.markOutboxFailed(eventCtx, event, options, fmt.Errorf("lucky gift wallet client is not configured"), false)
}
// 返奖 command_id 派生自 draw_id钱包侧可用它做幂等worker 重试不会重复加币。
resp, err := s.wallet.CreditLuckyGiftReward(eventCtx, &walletv1.CreditLuckyGiftRewardRequest{
CommandId: "lucky_reward:" + payload.DrawID,
AppCode: event.AppCode,
@ -206,12 +213,14 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox
credited = true
}
if s.publisher == nil {
// 已返奖但缺少房间发布依赖时不能把 draw 标记 failed否则会误导账务只让 outbox 保持可重试。
return s.markOutboxFailed(eventCtx, event, options, fmt.Errorf("lucky gift room publisher is not configured"), credited)
}
if shouldPublishLuckyGiftRegionBroadcast(payload) {
if s.broadcaster == nil {
return s.markOutboxFailed(eventCtx, event, options, fmt.Errorf("lucky gift region broadcaster is not configured"), credited)
}
// 区域播报先写 broadcast outbox不直接发 IM这里失败意味着大额表现未落地需要保留重试。
if err := s.publishLuckyGiftRegionBroadcast(eventCtx, payload); err != nil {
return s.markOutboxFailed(eventCtx, event, options, err, credited)
}
@ -220,6 +229,7 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox
return s.markOutboxFailed(eventCtx, event, options, err, credited)
}
nowMS := s.now().UTC().UnixMilli()
// 所有外部副作用都成功后再把 draw 从 pending 收敛到 granted客户端据此区分“处理中”和“已完成”。
if err := s.repository.MarkLuckyGiftDrawGranted(eventCtx, event.AppCode, payload.DrawID, walletTransactionID, nowMS); err != nil {
return err
}
@ -231,6 +241,7 @@ func (s *Service) publishLuckyGiftDrawn(ctx context.Context, payload luckyGiftDr
if err != nil {
return err
}
// 房间 IM 只是表现层事实同步,不修改 Room Cell超时由 outbox 重试兜底。
publishCtx, cancel := context.WithTimeout(ctx, options.PublishTimeout)
defer cancel()
return s.publisher.PublishGroupCustomMessage(publishCtx, tencentim.CustomGroupMessage{
@ -264,10 +275,12 @@ func (s *Service) markOutboxFailed(ctx context.Context, event domain.DrawOutbox,
nextRetryCount := event.RetryCount + 1
if nextRetryCount >= options.MaxRetry {
if !alreadyCredited {
// 未返奖的 draw 可以标记 failed已返奖但 IM 失败时不能覆盖账务事实,只停 outbox 并告警。
_ = s.repository.MarkLuckyGiftDrawFailed(ctx, event.AppCode, drawIDFromPayload(event.PayloadJSON), cause.Error(), nowMS)
}
return s.repository.MarkLuckyGiftOutboxFailed(ctx, event, nextRetryCount, cause.Error(), nowMS)
}
// 指数退避降低故障期间对钱包和 IM 的压力,同时保留 pending 状态给客户端展示“处理中”。
nextRetryAtMS := nowMS + luckyGiftBackoff(nextRetryCount).Milliseconds()
return s.repository.MarkLuckyGiftOutboxRetryable(ctx, event, nextRetryCount, nextRetryAtMS, cause.Error(), nowMS)
}
@ -282,6 +295,7 @@ func (s *Service) GetConfig(ctx context.Context, poolID string) (domain.Config,
return domain.Config{}, err
}
if !exists {
// 未配置时返回 disabled 草稿,后台可以直接编辑发布;不能在运行时把草稿当启用规则。
return DefaultConfig(appcode.FromContext(ctx), poolID), nil
}
return config, nil
@ -293,7 +307,9 @@ func (s *Service) UpsertConfig(ctx context.Context, config domain.Config) (domai
}
config.AppCode = appcode.FromContext(ctx)
config.PoolID = normalizePoolID(config.PoolID)
// 规则表历史上用 gift_id 承载作用域;当前以 pool_id 为真实业务键,发布时必须覆盖掉外部传入的 gift_id。
config.GiftID = config.PoolID
// 后台现在配置倍率列表,服务端生成三档体验池候选;概率权重必须留给抽奖时的 RTP 控制器。
config.Tiers, config.MultiplierPPMs = normalizeTiers(config.Tiers, config.MultiplierPPMs, config.GiftPrice, config.HighMultiplier)
if err := validateConfig(config); err != nil {
return domain.Config{}, err
@ -423,6 +439,7 @@ func decodeDrawOutboxPayload(payloadJSON string) (luckyGiftDrawnPayload, error)
if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
return luckyGiftDrawnPayload{}, fmt.Errorf("invalid lucky gift outbox payload: %w", err)
}
// 兼容早期 payload 缺省字段,避免历史 pending outbox 因新增字段永久失败。
if payload.EventType == "" {
payload.EventType = "lucky_gift_drawn"
}
@ -432,6 +449,7 @@ func decodeDrawOutboxPayload(payloadJSON string) (luckyGiftDrawnPayload, error)
if payload.SenderUserID == 0 {
payload.SenderUserID = payload.UserID
}
// 这些字段是返奖、房间 IM 和幂等去重的最小闭环,缺任何一个都不能安全补偿。
if payload.DrawID == "" || payload.EventID == "" || payload.RoomID == "" || payload.GiftID == "" || payload.UserID <= 0 {
return luckyGiftDrawnPayload{}, fmt.Errorf("lucky gift outbox payload is incomplete")
}

View File

@ -322,17 +322,20 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
if !exists || !config.Enabled {
return domain.DrawResult{}, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
}
// 后台配置的 gift_price 只是参考价格;实际抽奖必须按钱包扣费后的 coin_spent 等比缩放奖档和风控上限。
config = luckyRuntimeConfig(config, cmd.CoinSpent)
userPaidDraws, err := r.getLuckyUserStateForUpdate(ctx, tx, appCode, cmd.UserID, config.GiftID, nowMS)
if err != nil {
return domain.DrawResult{}, err
}
// 体验池只按该 pool_id 下的用户累计付费抽数切换,不承诺单用户 RTP 或保底中奖。
nextPaidDraw := userPaidDraws + 1
experiencePool := luckyExperiencePool(nextPaidDraw, config)
stageFeedback := luckyStageFeedback(nextPaidDraw, config)
minFutureMax := luckyMinFutureMax(config)
// 全站窗口和礼物子窗口当前都按 pool_id 隔离withCurrentWager 会把本次扣费先计入目标支出。
globalWindow, err := r.getOpenLuckyRTPWindow(ctx, tx, appCode, "pool", config.GiftID, config.GlobalWindowDraws, config.TargetRTPPPM, nowMS)
if err != nil {
return domain.DrawResult{}, err
@ -344,6 +347,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
}
giftWindow = giftWindow.withCurrentWager(cmd.CoinSpent)
// 三层基础奖池共同承担 base_reward_coins任一层容量不足都必须在随机前过滤对应奖档。
platformPool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, "platform", config.GiftID, config.InitialPlatformPool, config.PlatformReserve, nowMS)
if err != nil {
return domain.DrawResult{}, err
@ -356,25 +360,30 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
if err != nil {
return domain.DrawResult{}, err
}
// 房间气氛池是额外体验预算,不进入基础 RTP它独立于 Room Cell 状态。
atmosphere, err := r.getOrCreateLuckyAtmosphere(ctx, tx, appCode, luckyPoolScopedID(config.GiftID, cmd.RoomID), config.RoomAtmosphereInitial, config.RoomAtmosphereReserve, nowMS)
if err != nil {
return domain.DrawResult{}, err
}
// 每笔扣费先按入池比例补充三层基础池,再判断本次能否支付,避免同一事务里先抽后补造成水位偏差。
if err := r.creditLuckyBasePools(ctx, tx, appCode, config, cmd.CoinSpent, &platformPool, &roomPool, &giftPool, nowMS); err != nil {
return domain.DrawResult{}, err
}
if err := r.creditLuckyAtmosphere(ctx, tx, appCode, atmosphere.ScopeID, cmd.CoinSpent*config.RoomAtmosphereRatePPM/luckyPPMScale, &atmosphere, nowMS); err != nil {
return domain.DrawResult{}, err
}
// 活动预算和日预算按 UTC 天切分,只作为 effective reward 的额外补贴,不用来补基础 RTP 缺口。
activityRemaining, activityDay, err := r.getLuckyActivityRemaining(ctx, tx, appCode, config, cmd.PaidAtMS, nowMS)
if err != nil {
return domain.DrawResult{}, err
}
// 风控计数同事务锁定,确保并发抽奖不会同时越过用户、设备、房间或主播上限。
counters, err := r.getLuckyRiskCounters(ctx, tx, appCode, config.GiftID, cmd, nowMS)
if err != nil {
return domain.DrawResult{}, err
}
// 所有过滤都发生在随机前;随机后只能落事实和副作用 outbox不能再改成另一个结果。
candidate, limited, err := r.selectLuckyCandidate(config, experiencePool, stageFeedback, globalWindow, giftWindow, platformPool, roomPool, giftPool, atmosphere, activityRemaining, counters, minFutureMax)
if err != nil {
return domain.DrawResult{}, err
@ -457,6 +466,7 @@ type luckyApplyInput struct {
func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input luckyApplyInput) error {
platformOut, roomOut, giftOut := luckySplitWeighted(input.Candidate.BaseReward, input.Config)
// 只有基础返奖写入 RTP 窗口;房间气氛和活动补贴不会污染平台成本口径。
if _, err := tx.ExecContext(ctx, `
UPDATE lucky_rtp_windows
SET paid_draws = paid_draws + 1,
@ -488,6 +498,7 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
if err := r.debitLuckyPool(ctx, tx, input.AppCode, "platform", input.Config.GiftID, platformOut, input.NowMS); err != nil {
return err
}
// 基础返奖按配置权重拆到 platform/room/gift 三层池,保证任一层不能被透支到 reserve 以下。
if err := r.debitLuckyPool(ctx, tx, input.AppCode, "room", input.RoomPool.ScopeID, roomOut, input.NowMS); err != nil {
return err
}
@ -495,6 +506,7 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
return err
}
if input.Candidate.RoomReward > 0 {
// 气氛奖励只扣房间气氛池,不回写 Room Cell客户端表现由后续 IM 事件驱动。
if _, err := tx.ExecContext(ctx, `
UPDATE lucky_room_atmosphere_pools
SET balance = balance - ?, total_out = total_out + ?, updated_at_ms = ?
@ -505,6 +517,7 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
}
}
if input.Candidate.ActivityReward > 0 {
// 活动补贴同时扣总预算和 UTC 日预算;任何一个不足都已经在候选过滤阶段被排除。
if _, err := tx.ExecContext(ctx, `
UPDATE lucky_activity_budgets
SET spent_coins = spent_coins + ?,
@ -528,6 +541,7 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
if err := r.updateLuckyRiskCounters(ctx, tx, input.AppCode, input.Config.GiftID, input.Command, input.Candidate.effectiveReward(), input.NowMS); err != nil {
return err
}
// 用户体验池推进以付费抽数为准,即使未中奖也要计数,避免用户重复停留在新手阶段。
if _, err := tx.ExecContext(ctx, `
UPDATE lucky_user_states
SET paid_draws = paid_draws + 1, updated_at_ms = ?
@ -536,6 +550,7 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
); err != nil {
return err
}
// 当前审计快照先记录命中候选和过滤原因;后续若要完整复盘随机空间,需要扩展为完整候选表。
candidateSnapshot, _ := json.Marshal(map[string]any{
"base_reward": input.Candidate.BaseReward,
"limited": input.Limited,
@ -573,6 +588,7 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
return err
}
if luckyDrawNeedsOutbox(input.Candidate, input.StageFeedback) {
// outbox 是钱包入账和房间 IM 的唯一异步出口;事务内只写事实,不调用外部 RPC。
payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:" + input.DrawID,
@ -1325,7 +1341,9 @@ func (r *Repository) updateLuckyRiskCounters(ctx context.Context, tx *sql.Tx, ap
}
func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, stageFeedback bool, globalWindow, giftWindow luckyRTPWindow, platformPool, roomPool, giftPool, atmosphere luckyPool, activityRemaining int64, counters map[string]luckyCounter, minFutureMax int64) (luckyCandidate, map[string]bool, error) {
// minRequired 是为了保证窗口后续仍可追平 RTP 的本次最低基础返奖;全站和子窗口取更严格的一侧。
minRequired := maxInt64(globalWindow.minRequired(minFutureMax), giftWindow.minRequired(minFutureMax))
// 三层池按责任权重反推“本次最多可支付的基础返奖总额”,不能只看某一个池的余额。
poolCapacity := luckyWeightedPoolCapacity(platformPool, roomPool, giftPool, config)
riskCapacity := luckyRiskCapacity(config, counters)
baseMaxAllowed := minInt64(poolCapacity, riskCapacity)
@ -1334,6 +1352,7 @@ func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, sta
baseCandidates := make([]luckyCandidate, 0, len(config.Tiers))
var totalWeight int64
for _, tier := range config.Tiers {
// 体验池只决定当前用户能看到哪些倍率点;真实权重稍后按 RTP 缺口自动生成。
if tier.Pool != pool || !tier.Enabled {
continue
}
@ -1342,6 +1361,7 @@ func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, sta
limited["large_tier_disabled"] = true
continue
}
// 低于 minRequired 的档位会让窗口后续无解,因此即使它是小奖也必须在随机前过滤。
if tier.RewardCoins < minRequired {
continue
}
@ -1354,6 +1374,7 @@ func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, sta
}
continue
}
// 高倍必须同时满足 RTP 高水位和三层池容量;不能靠随机后降档来兜底。
if highMultiplier && !luckyHasHighWater(tier.RewardCoins, globalWindow, giftWindow, poolCapacity, config) {
limited["high_water"] = true
continue
@ -1367,11 +1388,13 @@ func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, sta
HighMultiplier: highMultiplier,
})
}
// 把可支付倍率点压缩成最多两个基础候选,使期望返奖贴近当前 RTP 缺口。
candidates := luckyAutoWeightedBaseCandidates(baseCandidates, minRequired, globalWindow, giftWindow)
for _, candidate := range candidates {
totalWeight += candidate.Weight
}
if minRequired == 0 {
// 只有基础 RTP 当前不强制追平时,娱乐补贴才可进入随机;补贴不能承担基础 RTP 结清责任。
if atmosphere.capacity() >= config.GiftPrice && riskCapacity >= config.GiftPrice {
candidates = append(candidates, luckyCandidate{TierID: "room_shared_1x", Source: domain.SourceRoomAtmosphere, Pool: pool, Weight: 1200, RoomReward: config.GiftPrice})
totalWeight += 1200
@ -1391,8 +1414,10 @@ func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, sta
}
if len(candidates) == 0 {
if minRequired > baseMaxAllowed {
// 窗口需要支付但奖池或风控容量不足,此时必须失败并暴露配置问题,不能透支或改结果。
return luckyCandidate{}, limited, xerr.New(xerr.Conflict, fmt.Sprintf("lucky gift RTP window cannot be settled: min_required=%d max_allowed=%d", minRequired, baseMaxAllowed))
}
// 没有离散倍率可用但容量足够时,用修正档精确补齐窗口缺口,保证长期 RTP 收敛。
return luckyCandidate{
TierID: luckyCorrectionTier(minRequired),
Source: domain.SourceBaseRTP,
@ -1402,6 +1427,7 @@ func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, sta
HighMultiplier: minRequired >= config.GiftPrice*config.HighMultiplier,
}, limited, nil
}
// 使用 crypto/rand 生成随机落点;候选集合已经全部可支付,随机后不再做业务降级。
index, err := luckySecureIndex(totalWeight)
if err != nil {
return luckyCandidate{}, limited, err
@ -1460,6 +1486,7 @@ func luckyRuntimeConfig(config domain.Config, spent int64) domain.Config {
if referenceCost <= 0 || spent <= 0 || referenceCost == spent {
return config
}
// 同一个 pool 可被不同价格礼物复用;按真实扣费缩放奖档和 cap避免低价礼物借高价配置爆奖。
config.MaxSinglePayout = luckyScaleReward(config.MaxSinglePayout, referenceCost, spent)
config.UserHourlyPayoutCap = luckyScaleReward(config.UserHourlyPayoutCap, referenceCost, spent)
config.UserDailyPayoutCap = luckyScaleReward(config.UserDailyPayoutCap, referenceCost, spent)
@ -1487,10 +1514,12 @@ func luckyStageFeedback(paidDrawNumber int64, config domain.Config) bool {
if limit <= 0 || paidDrawNumber <= 0 || paidDrawNumber > limit {
return false
}
// 阶段反馈是表现事件,不是金币保底;是否发币仍由候选预算和 outbox 决定。
return paidDrawNumber == 1 || paidDrawNumber == limit/4 || paidDrawNumber == limit/2 || paidDrawNumber == limit*3/4 || paidDrawNumber == limit
}
func luckyMinFutureMax(config domain.Config) int64 {
// 取三个体验池中“可用最大返奖”的最小值,作为未来每抽最保守的追平能力。
maxByPool := map[string]int64{
domain.PoolNovice: 0,
domain.PoolIntermediate: 0,
@ -1523,6 +1552,7 @@ func luckyAutoWeightedBaseCandidates(candidates []luckyCandidate, minRequired in
if len(candidates) == 0 {
return nil
}
// 同一返奖金额只保留一个候选,避免相同倍率点因三档配置重复而意外放大概率。
candidates = luckyDedupeRewardCandidates(candidates)
desired := maxInt64(minRequired, luckyDesiredBaseReward(globalWindow, giftWindow))
if desired <= 0 {
@ -1533,10 +1563,12 @@ func luckyAutoWeightedBaseCandidates(candidates []luckyCandidate, minRequired in
break
}
}
// 无基础缺口时优先选 0x如果运营没配置 0x就只能选最低可用倍率这也是配置侧需要关注的风险。
zero.Weight = luckyPPMScale
return []luckyCandidate{zero}
}
if desired <= candidates[0].BaseReward || len(candidates) == 1 {
// 缺口低于最低可用档时只能全权选择最低档,否则会留下无法支付的负缺口。
first := candidates[0]
first.Weight = luckyPPMScale
return []luckyCandidate{first}
@ -1561,10 +1593,12 @@ func luckyAutoWeightedBaseCandidates(candidates []luckyCandidate, minRequired in
upper.Weight = luckyPPMScale
return []luckyCandidate{upper}
}
// 在相邻两个倍率之间线性插值,使本次基础返奖期望值尽量贴近 RTP 目标缺口。
lower.Weight = luckyPPMScale - upperWeight
upper.Weight = upperWeight
return []luckyCandidate{lower, upper}
}
// 缺口超过所有离散倍率时选择最高可用档;真正无解会在上层 minRequired/baseMaxAllowed 检查处暴露。
last := candidates[len(candidates)-1]
last.Weight = luckyPPMScale
return []luckyCandidate{last}
@ -1609,6 +1643,7 @@ func luckySplitWeighted(amount int64, config domain.Config) (int64, int64, int64
func luckyWeightedPoolCapacity(platform, room, gift luckyPool, config domain.Config) int64 {
capacity := int64(^uint64(0) >> 1)
if config.PlatformPoolWeightPPM > 0 {
// 反推总返奖容量时必须除以责任权重;例如平台只承担 20%,平台可用 20 才能支撑总返奖 100。
capacity = minInt64(capacity, platform.capacity()*luckyPPMScale/config.PlatformPoolWeightPPM)
}
if config.RoomPoolWeightPPM > 0 {
@ -1624,6 +1659,7 @@ func luckyWeightedPoolCapacity(platform, room, gift luckyPool, config domain.Con
}
func luckyRiskCapacity(config domain.Config, counters map[string]luckyCounter) int64 {
// 风控是硬上限,所有会发币的来源都共享同一个 effective reward 剩余额度。
capacity := config.MaxSinglePayout
capacity = minInt64(capacity, config.UserHourlyPayoutCap-counters[luckyCounterKey("user", "hour")].Payout)
capacity = minInt64(capacity, config.UserDailyPayoutCap-counters[luckyCounterKey("user", "day")].Payout)
@ -1671,6 +1707,7 @@ func luckyMultiplierPPMsFromTiers(tiers []domain.Tier, giftPrice int64) []int64
}
func luckyHasHighWater(reward int64, globalWindow, giftWindow luckyRTPWindow, poolCapacity int64, config domain.Config) bool {
// 高倍必须来自此前少付形成的 RTP headroom并且三层池总容量要达到额外水位倍数。
if globalWindow.highWaterHeadroom(config.GiftPrice) < reward || giftWindow.highWaterHeadroom(config.GiftPrice) < reward {
return false
}

View File

@ -23,6 +23,33 @@ const (
maxRoomFeedRelationScanCount = 500
)
type flexibleInt64 int64
func (v *flexibleInt64) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
if raw == "" || raw == "null" {
*v = 0
return nil
}
if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) {
var text string
if err := json.Unmarshal(data, &text); err != nil {
return err
}
raw = strings.TrimSpace(text)
}
if raw == "" {
*v = 0
return nil
}
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return err
}
*v = flexibleInt64(parsed)
return nil
}
type roomFeedRelationCursor struct {
Tab string `json:"tab"`
Query string `json:"query,omitempty"`
@ -428,15 +455,22 @@ func (h *Handler) listRoomOnlineUsers(writer http.ResponseWriter, request *http.
ViewerUserId: auth.UserIDFromContext(request.Context()),
Page: page,
PageSize: pageSize,
Sort: strings.TrimSpace(request.URL.Query().Get("sort")),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
userIDs := make([]int64, 0, len(resp.GetUsers()))
for _, user := range resp.GetUsers() {
onlineItems := resp.GetItems()
userIDs := make([]int64, 0, len(onlineItems)+len(resp.GetUsers()))
for _, user := range onlineItems {
userIDs = append(userIDs, user.GetUserId())
}
if len(onlineItems) == 0 {
for _, user := range resp.GetUsers() {
userIDs = append(userIDs, user.GetUserId())
}
}
httpkit.WriteOK(writer, request, roomOnlineUserDataFromProto(resp, h.roomDisplayProfileMap(request, userIDs)))
}
@ -992,10 +1026,10 @@ func (h *Handler) setRoomPassword(writer http.ResponseWriter, request *http.Requ
// setRoomAdmin 把管理员增删 HTTP JSON 请求转换为 room-service SetRoomAdmin 命令。
func (h *Handler) setRoomAdmin(writer http.ResponseWriter, request *http.Request) {
var body struct {
RoomID string `json:"room_id"`
CommandID string `json:"command_id"`
TargetUserID int64 `json:"target_user_id"`
Enabled bool `json:"enabled"`
RoomID string `json:"room_id"`
CommandID string `json:"command_id"`
TargetUserID flexibleInt64 `json:"target_user_id"`
Enabled bool `json:"enabled"`
}
if !httpkit.Decode(writer, request, &body) {
@ -1004,10 +1038,23 @@ func (h *Handler) setRoomAdmin(writer http.ResponseWriter, request *http.Request
resp, err := h.roomClient.SetRoomAdmin(request.Context(), &roomv1.SetRoomAdminRequest{
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
TargetUserId: body.TargetUserID,
TargetUserId: int64(body.TargetUserID),
Enabled: body.Enabled,
})
httpkit.Write(writer, request, resp, err)
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
roomRole := "normal"
if body.Enabled {
roomRole = "admin"
}
httpkit.WriteOK(writer, request, setRoomAdminData{
Result: commandResultDataFromProto(resp.GetResult()),
TargetUserID: formatOptionalUserID(int64(body.TargetUserID)),
RoomRole: roomRole,
ServerTimeMS: resp.GetResult().GetServerTimeMs(),
})
}
// muteUser 把禁言 HTTP JSON 请求转换为 room-service MuteUser 命令。

View File

@ -76,10 +76,21 @@ type roomOnlineUsersData struct {
type roomOnlineUserData struct {
UserID string `json:"user_id"`
Role string `json:"role,omitempty"`
IsOwner bool `json:"is_owner"`
RoomRole string `json:"room_role,omitempty"`
GiftValue int64 `json:"gift_value"`
JoinedAtMS int64 `json:"joined_at_ms,omitempty"`
LastSeenAtMS int64 `json:"last_seen_at_ms,omitempty"`
Profile *roomDisplayProfileData `json:"profile,omitempty"`
}
type setRoomAdminData struct {
Result roomCommandResultData `json:"result"`
TargetUserID string `json:"target_user_id"`
RoomRole string `json:"room_role"`
ServerTimeMS int64 `json:"server_time_ms"`
}
type roomPermissionsData struct {
IsOwner bool `json:"is_owner"`
IsAdmin bool `json:"is_admin"`
@ -567,7 +578,7 @@ func roomPermissionsFromSnapshot(snapshot *roomv1.RoomSnapshot, viewerUserID int
return roomPermissionsData{}
}
isOwner := snapshot.GetOwnerUserId() == viewerUserID
isAdmin := containsInt64(snapshot.GetAdminUserIds(), viewerUserID)
isAdmin := !isOwner && containsInt64(snapshot.GetAdminUserIds(), viewerUserID)
isMuted := containsInt64(snapshot.GetMuteUserIds(), viewerUserID)
inRoom := protoRoomUserByID(snapshot, viewerUserID) != nil
return roomPermissionsData{
@ -632,11 +643,19 @@ func roomOnlineUserDataFromProto(resp *roomv1.ListRoomOnlineUsersResponse, profi
if resp == nil {
return roomOnlineUsersData{}
}
items := make([]roomOnlineUserData, 0, len(resp.GetUsers()))
for _, user := range resp.GetUsers() {
source := resp.GetItems()
if len(source) == 0 {
source = roomOnlineUsersFromLegacyUsers(resp.GetUsers())
}
items := make([]roomOnlineUserData, 0, len(source))
for _, user := range source {
item := roomOnlineUserData{
UserID: formatOptionalUserID(user.GetUserId()),
Role: user.GetRole(),
IsOwner: user.GetIsOwner(),
RoomRole: user.GetRoomRole(),
GiftValue: user.GetGiftValue(),
JoinedAtMS: user.GetJoinedAtMs(),
LastSeenAtMS: user.GetLastSeenAtMs(),
}
if profile, ok := profiles[user.GetUserId()]; ok {
@ -654,6 +673,20 @@ func roomOnlineUserDataFromProto(resp *roomv1.ListRoomOnlineUsersResponse, profi
}
}
func roomOnlineUsersFromLegacyUsers(users []*roomv1.RoomUser) []*roomv1.RoomOnlineUser {
items := make([]*roomv1.RoomOnlineUser, 0, len(users))
for _, user := range users {
items = append(items, &roomv1.RoomOnlineUser{
UserId: user.GetUserId(),
Role: user.GetRole(),
JoinedAtMs: user.GetJoinedAtMs(),
LastSeenAtMs: user.GetLastSeenAtMs(),
RoomRole: "normal",
})
}
return items
}
func protoRoomUserByID(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser {
if snapshot == nil || userID <= 0 {
return nil

View File

@ -68,6 +68,7 @@ CREATE TABLE IF NOT EXISTS room_user_presence (
mic_session_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '麦位会话 ID',
room_version BIGINT NOT NULL COMMENT 'Room Cell 状态版本',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
joined_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '进房时间UTC epoch ms',
last_seen_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最后活跃时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, user_id),
@ -75,6 +76,18 @@ CREATE TABLE IF NOT EXISTS room_user_presence (
KEY idx_room_user_presence_updated (app_code, status, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间用户在线状态表';
CREATE TABLE IF NOT EXISTS room_user_gift_stats (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
room_id VARCHAR(64) NOT NULL COMMENT '房间 ID',
user_id BIGINT NOT NULL COMMENT '送礼用户 ID',
gift_value BIGINT NOT NULL DEFAULT 0 COMMENT '当前房间生命周期内累计送礼价值',
last_gift_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最近一次送礼时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, room_id, user_id),
KEY idx_room_user_gift_stats_room_value (app_code, room_id, gift_value DESC, last_gift_at_ms DESC, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间用户送礼价值统计表';
CREATE TABLE IF NOT EXISTS room_snapshots (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
room_id VARCHAR(64) NOT NULL COMMENT '房间 ID',

View File

@ -138,6 +138,7 @@ func roomPresenceProjectionFromSnapshot(snapshot *roomv1.RoomSnapshot, updatedAt
Role: user.GetRole(),
RoomVersion: snapshot.GetVersion(),
Status: roomPresenceStatusActive,
JoinedAtMS: user.GetJoinedAtMs(),
LastSeenAtMS: user.GetLastSeenAtMs(),
UpdatedAtMS: updatedAtMS,
}

View File

@ -69,6 +69,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
}
luckyEnabled := false
if cmd.PoolID != "" {
// 客户端显式传 pool_id 时必须先检查活动规则;规则未启用就拒绝,避免先扣费再发现不能抽奖。
if s.luckyGift == nil {
return mutationResult{}, nil, xerr.New(xerr.Unavailable, "lucky gift service is not configured")
}
@ -85,6 +86,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
if checkResp == nil || !checkResp.GetEnabled() {
return mutationResult{}, nil, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
}
// Check 只说明“允许按该 pool 抽奖”,真实价格、区域可用性和扣费仍以 wallet-service 为准。
luckyEnabled = true
}
treasureConfig, err := s.roomTreasureConfig(ctx)
@ -120,33 +122,39 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
settledCommand.PriceVersion = billing.GetPriceVersion()
settledCommand.GiftTypeCode = billing.GetGiftTypeCode()
if !luckyEnabled {
// 未显式传 pool_id 的旧客户端仍可通过钱包礼物类型触发默认幸运礼物逻辑;新客户端应传明确 pool_id。
luckyEnabled = s.shouldDrawLuckyGift(cmd.PoolID, billing.GetGiftTypeCode())
}
var luckyGift *roomv1.LuckyGiftDrawResult
if luckyEnabled {
// 抽奖必须发生在扣费成功之后activity-service 只接收钱包结算后的 coin_spent不信任客户端价格。
drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &activityv1.ExecuteLuckyGiftDrawRequest{
LuckyGift: &activityv1.LuckyGiftMeta{
Meta: activityMetaFromRoom(ctx, req.GetMeta()),
CommandId: cmd.ID(),
UserId: cmd.ActorUserID(),
TargetUserId: cmd.TargetUserID,
DeviceId: luckyGiftDeviceID(cmd),
RoomId: cmd.RoomID(),
AnchorId: luckyGiftAnchorID(roomMeta),
GiftId: cmd.GiftID,
GiftCount: cmd.GiftCount,
CoinSpent: billing.GetCoinSpent(),
Meta: activityMetaFromRoom(ctx, req.GetMeta()),
CommandId: cmd.ID(),
UserId: cmd.ActorUserID(),
TargetUserId: cmd.TargetUserID,
// 目前没有独立设备 ID 字段,优先用 session_id没有时退回 command_id保证风控 scope 不为空。
DeviceId: luckyGiftDeviceID(cmd),
RoomId: cmd.RoomID(),
AnchorId: luckyGiftAnchorID(roomMeta),
GiftId: cmd.GiftID,
GiftCount: cmd.GiftCount,
CoinSpent: billing.GetCoinSpent(),
// 房间链路统一使用 UTC epoch ms不能把本地时区时间传给活动风控窗口。
PaidAtMs: now.UTC().UnixMilli(),
PoolId: cmd.PoolID,
VisibleRegionId: roomMeta.VisibleRegionID,
},
})
if err != nil {
// 抽奖失败时整笔送礼失败并依赖 wallet 幂等重试;不能落普通礼物后丢失幸运礼物抽奖事实。
return mutationResult{}, nil, err
}
if drawResp == nil {
return mutationResult{}, nil, xerr.New(xerr.Unavailable, "lucky gift draw response is empty")
}
// 同步返回只给当前送礼用户做即时表现;返奖到账和房间开奖结果仍由 activity outbox 补偿。
luckyGift = luckyGiftResultFromProto(drawResp.GetResult())
}
@ -227,6 +235,15 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
CoinSpent: roomGiftLeaderboardCoinSpent(billing),
OccurredAtMS: now.UnixMilli(),
},
roomUserGiftStats: []RoomUserGiftStatIncrement{
{
AppCode: appcode.FromContext(ctx),
RoomID: current.RoomID,
UserID: cmd.ActorUserID(),
GiftValue: heatValue,
LastGiftAtMS: now.UnixMilli(),
},
},
syncEvent: &tencentim.RoomEvent{
// 同步广播只选 GiftSent 主事件作为客户端房间系统消息Heat/Rank 通过字段携带。
EventID: giftEvent.EventID,

View File

@ -168,9 +168,9 @@ func (s *Service) SetRoomAdmin(ctx context.Context, req *roomv1.SetRoomAdminRequ
// 首版不支持预设管理员,避免把不存在或已离房用户加入管理集合。
return mutationResult{}, nil, xerr.New(xerr.NotFound, "target not in room")
}
if !cmd.Enabled && cmd.TargetUserID == current.OwnerUserID {
// owner 的管理身份由专门字段决定,不能通过 admin 集合移除
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "permission denied")
if cmd.TargetUserID == current.OwnerUserID {
// owner 不是管理员,不能对 owner 执行设置或取消管理员
return mutationResult{}, nil, xerr.New(xerr.Conflict, "owner cannot be room admin target")
}
if current.AdminUsers[cmd.TargetUserID] == cmd.Enabled {
// 重复添加或重复移除是 no-op避免重复发送 RoomAdminChanged。

View File

@ -2,6 +2,7 @@ package service
import (
"context"
"strings"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
@ -55,29 +56,64 @@ func (s *Service) ListRoomOnlineUsers(ctx context.Context, req *roomv1.ListRoomO
// 单页上限在 service 层压实repository 不接受无限制分页。
pageSize = maxRoomOnlineUsersPageSize
}
sortMode := strings.TrimSpace(req.GetSort())
if sortMode != "" && sortMode != "online" && sortMode != "gift_value" {
return nil, xerr.New(xerr.InvalidArgument, "sort is invalid")
}
snapshot, err := s.currentSnapshot(ctx, roomID)
if err != nil {
return nil, err
}
if snapshot == nil {
return nil, xerr.New(xerr.NotFound, "room not found")
}
adminUsers := make(map[int64]bool, len(snapshot.GetAdminUserIds()))
for _, userID := range snapshot.GetAdminUserIds() {
if userID > 0 && userID != snapshot.GetOwnerUserId() {
adminUsers[userID] = true
}
}
result, err := s.repository.ListRoomOnlineUsers(ctx, RoomOnlineUserQuery{
AppCode: appcode.FromContext(ctx),
RoomID: roomID,
Page: page,
PageSize: pageSize,
Sort: sortMode,
})
if err != nil {
return nil, err
}
users := make([]*roomv1.RoomUser, 0, len(result.Users))
items := make([]*roomv1.RoomOnlineUser, 0, len(result.Users))
for _, user := range result.Users {
// 在线列表只返回轻量房间用户态,完整用户资料仍由 user-service 或 gateway 聚合。
users = append(users, &roomv1.RoomUser{
UserId: user.UserID,
Role: user.Role,
JoinedAtMs: user.JoinedAtMS,
LastSeenAtMs: user.LastSeenAtMS,
})
roomRole := "normal"
if adminUsers[user.UserID] {
roomRole = "admin"
}
items = append(items, &roomv1.RoomOnlineUser{
UserId: user.UserID,
Role: user.Role,
RoomRole: roomRole,
GiftValue: user.GiftValue,
JoinedAtMs: user.JoinedAtMS,
LastSeenAtMs: user.LastSeenAtMS,
IsOwner: user.UserID == snapshot.GetOwnerUserId(),
})
}
return &roomv1.ListRoomOnlineUsersResponse{
Users: users,
Items: items,
Total: result.Total,
Page: int32(result.Page),
PageSize: int32(result.PageSize),

View File

@ -0,0 +1,95 @@
package service_test
import (
"context"
"encoding/json"
"testing"
"time"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
roomservice "hyapp/services/room-service/internal/room/service"
"hyapp/services/room-service/internal/testutil/mysqltest"
)
func TestListRoomOnlineUsersReturnsOwnerAdminRoleAndGiftValue(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
wallet := &treasureTestWallet{debits: []*walletv1.DebitGiftResponse{{
BillingReceiptId: "receipt-online-users",
CoinSpent: 321,
GiftPointAdded: 321,
HeatValue: 321,
GiftTypeCode: "normal",
}}}
now := &fixedRoomTreasureClock{now: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC)}
svc := newTreasureTestService(t, repository, wallet, now)
roomID := "room-online-users"
ownerID := int64(1001)
adminID := int64(1002)
normalID := int64(1003)
createTreasureRoom(t, ctx, svc, roomID, ownerID, 9001)
joinTreasureRoom(t, ctx, svc, roomID, adminID)
joinTreasureRoom(t, ctx, svc, roomID, normalID)
if _, err := svc.SetRoomAdmin(ctx, &roomv1.SetRoomAdminRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
TargetUserId: adminID,
Enabled: true,
}); err != nil {
t.Fatalf("set admin failed: %v", err)
}
if _, err := svc.SetRoomAdmin(ctx, &roomv1.SetRoomAdminRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
TargetUserId: ownerID,
Enabled: true,
}); err == nil {
t.Fatalf("setting owner as admin must fail")
}
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: treasureMeta(roomID, adminID, "online-users-gift"),
TargetType: "user",
TargetUserId: ownerID,
GiftId: "gift-online-users",
GiftCount: 1,
}); err != nil {
t.Fatalf("send gift failed: %v", err)
}
resp, err := svc.ListRoomOnlineUsers(ctx, &roomv1.ListRoomOnlineUsersRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
RoomId: roomID,
ViewerUserId: ownerID,
Page: 1,
PageSize: 10,
Sort: "gift_value",
})
if err != nil {
t.Fatalf("list online users failed: %v", err)
}
if len(resp.GetItems()) != 3 {
t.Fatalf("expected three online users, got %+v", resp.GetItems())
}
byID := make(map[int64]*roomv1.RoomOnlineUser, len(resp.GetItems()))
for _, item := range resp.GetItems() {
byID[item.GetUserId()] = item
}
if owner := byID[ownerID]; owner == nil || !owner.GetIsOwner() || owner.GetRoomRole() != "normal" {
t.Fatalf("owner must be is_owner=true and room_role=normal: %+v", owner)
}
if admin := byID[adminID]; admin == nil || admin.GetIsOwner() || admin.GetRoomRole() != "admin" || admin.GetGiftValue() != 321 {
t.Fatalf("admin gift value or role mismatch: %+v", admin)
}
if normal := byID[normalID]; normal == nil || normal.GetIsOwner() || normal.GetRoomRole() != "normal" || normal.GetGiftValue() != 0 {
t.Fatalf("normal user mismatch: %+v", normal)
}
payload, err := json.Marshal(resp.GetItems())
if err != nil {
t.Fatalf("marshal online users failed: %v", err)
}
t.Logf("online users response items: %s", payload)
}

View File

@ -91,13 +91,14 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
LeaseToken: lease.LeaseToken,
CreatedAtMS: now.UnixMilli(),
},
OutboxRecords: outboxRecords,
RoomStatus: result.roomStatus,
RoomSeatCount: result.roomSeatCount,
RoomPasswordHash: result.roomPasswordHash,
RoomMode: result.roomMode,
VisibleRegionID: result.visibleRegionID,
CloseInfo: result.closeInfo,
OutboxRecords: outboxRecords,
RoomStatus: result.roomStatus,
RoomSeatCount: result.roomSeatCount,
RoomPasswordHash: result.roomPasswordHash,
RoomMode: result.roomMode,
VisibleRegionID: result.visibleRegionID,
CloseInfo: result.closeInfo,
RoomUserGiftStats: result.roomUserGiftStats,
}); err != nil {
return nil, err
}

View File

@ -309,11 +309,12 @@ func replay(current *state.RoomState, cmd command.Command) error {
}
current.Version++
case *command.SetRoomAdmin:
if typed.Enabled {
if typed.TargetUserID == current.OwnerUserID {
// owner 不是管理员;旧命令或异常数据回放时不把 owner 写入 admin 集合。
} else if typed.Enabled {
// 管理员集合是持久权限,恢复后用户重新进房即可继续具备 admin 身份。
current.AdminUsers[typed.TargetUserID] = true
} else if typed.TargetUserID != current.OwnerUserID {
// owner 的管理身份不能被 admin 删除命令移除。
} else {
delete(current.AdminUsers, typed.TargetUserID)
}
current.Version++

View File

@ -71,6 +71,17 @@ type MutationCommit struct {
VisibleRegionID *int64
// CloseInfo 非 nil 时同步更新后台生命周期审计字段。
CloseInfo *RoomCloseInfo
// RoomUserGiftStats 是本次命令产生的房间用户送礼价值增量,必须和命令日志同事务提交。
RoomUserGiftStats []RoomUserGiftStatIncrement
}
// RoomUserGiftStatIncrement 表达当前房间内某个送礼用户的礼物价值增量。
type RoomUserGiftStatIncrement struct {
AppCode string
RoomID string
UserID int64
GiftValue int64
LastGiftAtMS int64
}
type RoomCloseInfo struct {
@ -293,10 +304,14 @@ type RoomPresence struct {
RoomVersion int64
// Status 表示该用户 presence 是否仍 active。
Status string
// JoinedAtMS 是用户进入当前房间的时间。
JoinedAtMS int64
// LastSeenAtMS 是用户在 Room Cell 内的最后业务心跳时间。
LastSeenAtMS int64
// UpdatedAtMS 是投影写入时间。
UpdatedAtMS int64
// GiftValue 是该用户在当前房间生命周期内作为送礼方累计送出的礼物价值。
GiftValue int64
}
// RoomOnlineUserQuery 是房间在线用户分页读模型查询条件。
@ -305,6 +320,7 @@ type RoomOnlineUserQuery struct {
RoomID string
Page int
PageSize int
Sort string
}
// RoomOnlineUserPage 是 room_user_presence 中某房间 active 用户的一页结果。

View File

@ -124,6 +124,8 @@ type mutationResult struct {
walletDebitMS int64
// roomGiftLeaderboard 是 SendGift 成功后写入跨房间金币榜的轻量增量。
roomGiftLeaderboard *RoomGiftLeaderboardIncrement
// roomUserGiftStats 是当前房间用户送礼价值统计,和命令日志同事务提交。
roomUserGiftStats []RoomUserGiftStatIncrement
}
// New 初始化 room-service 领域服务。

View File

@ -171,7 +171,7 @@ func (t *TreasureState) Clone() *TreasureState {
type RoomState struct {
// RoomID 是该状态所属房间。
RoomID string
// OwnerUserID 是房间所有者,进入 AdminUsers。
// OwnerUserID 是房间所有者owner 权限由专门字段派生,不写入 AdminUsers。
OwnerUserID int64
// Mode 是房间模式,当前只作为状态字段透出。
Mode string
@ -222,13 +222,10 @@ func NewRoomState(roomID string, ownerUserID int64, seatCount int32, mode string
ChatEnabled: true,
MicSeats: seats,
OnlineUsers: make(map[int64]*RoomUserState),
AdminUsers: map[int64]bool{
// owner 默认具备管理权限,后续可以扩展管理员命令。
ownerUserID: true,
},
BanUsers: make(map[int64]bool),
MuteUsers: make(map[int64]bool),
RoomExt: make(map[string]string),
AdminUsers: make(map[int64]bool),
BanUsers: make(map[int64]bool),
MuteUsers: make(map[int64]bool),
RoomExt: make(map[string]string),
}
}
@ -389,7 +386,7 @@ func (s *RoomState) ToProto() *roomv1.RoomSnapshot {
ChatEnabled: s.ChatEnabled,
MicSeats: seats,
OnlineUsers: users,
AdminUserIds: sortedSetIDs(s.AdminUsers),
AdminUserIds: sortedSetIDsExcept(s.AdminUsers, s.OwnerUserID),
BanUserIds: sortedSetIDs(s.BanUsers),
MuteUserIds: sortedSetIDs(s.MuteUsers),
GiftRank: rankItems,
@ -456,10 +453,12 @@ func FromProto(snapshot *roomv1.RoomSnapshot) *RoomState {
for _, userID := range snapshot.GetAdminUserIds() {
// set 在 protobuf 中用数组表达,恢复时重新转为 map。
if userID == restored.OwnerUserID {
// owner 不是管理员;旧快照里如果残留 owner恢复时过滤掉。
continue
}
restored.AdminUsers[userID] = true
}
// owner 是派生管理员身份,快照恢复后必须重新压实该不变量。
restored.AdminUsers[restored.OwnerUserID] = true
for _, userID := range snapshot.GetBanUserIds() {
restored.BanUsers[userID] = true
@ -496,6 +495,17 @@ func sortedSetIDs(values map[int64]bool) []int64 {
return ids
}
func sortedSetIDsExcept(values map[int64]bool, excluded int64) []int64 {
ids := make([]int64, 0, len(values))
for userID, enabled := range values {
if enabled && userID != excluded {
ids = append(ids, userID)
}
}
slices.Sort(ids)
return ids
}
func cloneStringMap(input map[string]string) map[string]string {
if input == nil {
// protobuf map 缺省按空 map 处理,调用方可以安全写入。

View File

@ -129,12 +129,24 @@ func (r *Repository) Migrate(ctx context.Context) error {
mic_session_id VARCHAR(128) NOT NULL DEFAULT '',
room_version BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
joined_at_ms BIGINT NOT NULL DEFAULT 0,
last_seen_at_ms BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, user_id),
KEY idx_room_user_presence_room (app_code, room_id, status),
KEY idx_room_user_presence_updated (app_code, status, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS room_user_gift_stats (
app_code VARCHAR(32) NOT NULL,
room_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
gift_value BIGINT NOT NULL DEFAULT 0,
last_gift_at_ms BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, room_id, user_id),
KEY idx_room_user_gift_stats_room_value (app_code, room_id, gift_value DESC, last_gift_at_ms DESC, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS room_snapshots (
app_code VARCHAR(32) NOT NULL,
room_id VARCHAR(64) NOT NULL,
@ -314,10 +326,26 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureRoomAdminCloseSchema(ctx); err != nil {
return err
}
if err := r.ensureRoomUserPresenceSchema(ctx); err != nil {
return err
}
return r.ensureOutboxRetrySchema(ctx)
}
func (r *Repository) ensureRoomUserPresenceSchema(ctx context.Context) error {
hasColumn, err := r.columnExists(ctx, "room_user_presence", "joined_at_ms")
if err != nil {
return err
}
if !hasColumn {
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_user_presence ADD COLUMN joined_at_ms BIGINT NOT NULL DEFAULT 0 AFTER status`); err != nil {
return err
}
}
return nil
}
func (r *Repository) ensureRoomPasswordSchema(ctx context.Context) error {
hasColumn, err := r.columnExists(ctx, "rooms", "room_password_hash")
if err != nil {
@ -712,6 +740,35 @@ func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.Mutati
}
}
}
for _, stat := range commit.RoomUserGiftStats {
if stat.UserID <= 0 || stat.GiftValue <= 0 {
continue
}
statAppCode := normalizedRecordAppCode(ctx, stat.AppCode)
statRoomID := strings.TrimSpace(stat.RoomID)
if statRoomID == "" {
statRoomID = commit.Command.RoomID
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO room_user_gift_stats (
app_code, room_id, user_id, gift_value, last_gift_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
gift_value = gift_value + VALUES(gift_value),
last_gift_at_ms = VALUES(last_gift_at_ms),
updated_at_ms = VALUES(updated_at_ms)`,
statAppCode,
statRoomID,
stat.UserID,
stat.GiftValue,
stat.LastGiftAtMS,
commit.Command.CreatedAtMS,
commit.Command.CreatedAtMS,
); err != nil {
_ = tx.Rollback()
return err
}
}
return tx.Commit()
}
@ -2121,8 +2178,8 @@ func (r *Repository) ProjectRoomPresence(ctx context.Context, snapshot roomservi
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO room_user_presence (
app_code, user_id, room_id, role, publish_state, mic_session_id, room_version, status, last_seen_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
app_code, user_id, room_id, role, publish_state, mic_session_id, room_version, status, joined_at_ms, last_seen_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
room_id = VALUES(room_id),
role = VALUES(role),
@ -2130,6 +2187,7 @@ func (r *Repository) ProjectRoomPresence(ctx context.Context, snapshot roomservi
mic_session_id = VALUES(mic_session_id),
room_version = VALUES(room_version),
status = VALUES(status),
joined_at_ms = VALUES(joined_at_ms),
last_seen_at_ms = VALUES(last_seen_at_ms),
updated_at_ms = VALUES(updated_at_ms)`,
appCode,
@ -2140,6 +2198,7 @@ func (r *Repository) ProjectRoomPresence(ctx context.Context, snapshot roomservi
user.MicSessionID,
snapshot.RoomVersion,
roomPresenceStatusActiveSQL,
user.JoinedAtMS,
user.LastSeenAtMS,
snapshot.UpdatedAtMS,
); err != nil {
@ -2154,7 +2213,7 @@ func (r *Repository) ProjectRoomPresence(ctx context.Context, snapshot roomservi
// GetCurrentRoomPresence 查询用户当前 active 房间,不扫描发现页列表。
func (r *Repository) GetCurrentRoomPresence(ctx context.Context, userID int64) (roomservice.RoomPresence, bool, error) {
row := r.db.QueryRowContext(ctx,
`SELECT app_code, user_id, room_id, role, publish_state, mic_session_id, room_version, status, last_seen_at_ms, updated_at_ms
`SELECT app_code, user_id, room_id, role, publish_state, mic_session_id, room_version, status, joined_at_ms, last_seen_at_ms, updated_at_ms
FROM room_user_presence
WHERE app_code = ? AND user_id = ? AND status = ?
LIMIT 1`,
@ -2173,6 +2232,7 @@ func (r *Repository) GetCurrentRoomPresence(ctx context.Context, userID int64) (
&presence.MicSessionID,
&presence.RoomVersion,
&presence.Status,
&presence.JoinedAtMS,
&presence.LastSeenAtMS,
&presence.UpdatedAtMS,
); err != nil {
@ -2210,11 +2270,19 @@ func (r *Repository) ListRoomOnlineUsers(ctx context.Context, query roomservice.
return roomservice.RoomOnlineUserPage{}, err
}
orderBy := "p.last_seen_at_ms DESC, p.user_id ASC"
if strings.EqualFold(strings.TrimSpace(query.Sort), "gift_value") {
orderBy = "COALESCE(g.gift_value, 0) DESC, p.last_seen_at_ms DESC, p.user_id ASC"
}
rows, err := r.db.QueryContext(ctx,
`SELECT app_code, user_id, room_id, role, publish_state, mic_session_id, room_version, status, last_seen_at_ms, updated_at_ms
FROM room_user_presence
WHERE app_code = ? AND room_id = ? AND status = ?
ORDER BY last_seen_at_ms DESC, user_id ASC
`SELECT p.app_code, p.user_id, p.room_id, p.role, p.publish_state, p.mic_session_id, p.room_version, p.status,
p.joined_at_ms, p.last_seen_at_ms, p.updated_at_ms, COALESCE(g.gift_value, 0)
FROM room_user_presence p
LEFT JOIN room_user_gift_stats g
ON g.app_code = p.app_code AND g.room_id = p.room_id AND g.user_id = p.user_id
WHERE p.app_code = ? AND p.room_id = ? AND p.status = ?
ORDER BY `+orderBy+`
LIMIT ? OFFSET ?`,
appCode,
query.RoomID,
@ -2239,8 +2307,10 @@ func (r *Repository) ListRoomOnlineUsers(ctx context.Context, query roomservice.
&presence.MicSessionID,
&presence.RoomVersion,
&presence.Status,
&presence.JoinedAtMS,
&presence.LastSeenAtMS,
&presence.UpdatedAtMS,
&presence.GiftValue,
); err != nil {
return roomservice.RoomOnlineUserPage{}, err
}