幸运礼物文档的逻辑和房间用户列表接口
This commit is contained in:
parent
b10f3e120b
commit
aa43bf65a9
File diff suppressed because it is too large
Load Diff
@ -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 建立当前用户对房间的关注关系。
|
||||
|
||||
@ -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`。
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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`。
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
本文档定义语音房幸运礼物的产品规则。幸运礼物使用金币赠送,奖励也返还金币
|
||||
|
||||
当前代码实现以 `docs/幸运礼物线上落地文档.md` 为准。已落地版本不是固定概率表模型,而是后台配置 `multiplier_ppms` 多个倍率,服务端按 RTP 窗口、三层奖池和风控容量在运行时自动生成候选权重;因此本文中“概率表/权重表”只保留为产品抽象,不代表后台可以直接配置固定命中概率。
|
||||
|
||||
## Positioning
|
||||
|
||||
| Object | Meaning |
|
||||
|
||||
@ -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 写 WalletBalanceChanged,notice-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` 或余额查询为准 |
|
||||
|
||||
745
docs/房内红包需求接口拆分.md
Normal file
745
docs/房内红包需求接口拆分.md
Normal 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` 重复领取只生成一条领取记录。
|
||||
420
docs/语音房房间用户列表前端接入文档.md
Normal file
420
docs/语音房房间用户列表前端接入文档.md
Normal 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` 去重。
|
||||
574
docs/语音房房间用户列表后端技术架构设计.md
Normal file
574
docs/语音房房间用户列表后端技术架构设计.md
Normal 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/...
|
||||
```
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 命令。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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++
|
||||
|
||||
@ -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 用户的一页结果。
|
||||
|
||||
@ -124,6 +124,8 @@ type mutationResult struct {
|
||||
walletDebitMS int64
|
||||
// roomGiftLeaderboard 是 SendGift 成功后写入跨房间金币榜的轻量增量。
|
||||
roomGiftLeaderboard *RoomGiftLeaderboardIncrement
|
||||
// roomUserGiftStats 是当前房间用户送礼价值统计,和命令日志同事务提交。
|
||||
roomUserGiftStats []RoomUserGiftStatIncrement
|
||||
}
|
||||
|
||||
// New 初始化 room-service 领域服务。
|
||||
|
||||
@ -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 处理,调用方可以安全写入。
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user