Compare commits
5 Commits
b10f3e120b
...
31f686bd68
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31f686bd68 | ||
|
|
9fa6991ffd | ||
|
|
e73e2f5ee8 | ||
|
|
30a25a9d4d | ||
|
|
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 建立当前用户对房间的关注关系。
|
||||
|
||||
164
deploy/README.md
164
deploy/README.md
@ -1,164 +0,0 @@
|
||||
# 多机部署与无感发布
|
||||
|
||||
本文定义不使用 Kubernetes 时的生产部署边界。目标是让 gateway 和内部微服务可以多机滚动发布,并让 `room-service` 在普通内网负载均衡命中非 owner 节点时自动转发到当前 Room Cell owner。
|
||||
|
||||
## 网络拓扑
|
||||
|
||||
推荐拓扑:
|
||||
|
||||
```text
|
||||
公网 CLB
|
||||
-> gateway-service 多实例
|
||||
-> room-service 内网入口
|
||||
-> user-service 内网入口
|
||||
-> wallet-service 内网入口
|
||||
-> activity-service 内网入口
|
||||
-> game-service 内网入口
|
||||
-> notice-service 内网入口
|
||||
|
||||
room-service 实例之间通过 Redis owner route 和 node registry 直连转发。
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 公网只暴露 `gateway-service`。
|
||||
- 内部 gRPC 服务只监听私网,安全组只允许服务网段访问 `13xxx` 端口。
|
||||
- `user-service`、`wallet-service`、`activity-service`、`game-service`、`notice-service` 可以直接挂内网 CLB 或 DNS。
|
||||
- `room-service` 可以挂内网 CLB/DNS 给 gateway 使用,但 owner 转发必须使用实例级 `advertise_addr`,不能使用 CLB 地址。
|
||||
- MySQL、Redis、MQ 使用腾讯云托管资源,不在业务 CVM 上部署;服务只通过内网 endpoint 访问这些托管依赖。
|
||||
|
||||
当前生产入口:
|
||||
|
||||
| 层级 | 地址 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 公网 API | `https://api.global-interaction.com` | HTTPS 443 由公网 CLB 转发到两台 `gateway-service:13000` |
|
||||
| 后台前端 | `http://1.14.164.2:7001` | 广州 CVM `172.16.0.10` 上 nginx 托管 `admin-platform` 静态资源,`/api` 反代内网后台服务 |
|
||||
| 后台 API | `172.16.0.6:13100` | 广州 CVM `ins-0x5mjwmc` 运行 `hyapp-admin-server`,通过 `wg-quick@hyapp-admin` 访问生产内网依赖 |
|
||||
| room/user/activity/game/notice 内网入口 | `10.2.1.16:13001/13005/13006/13008/13009` | `lb-epvnr4o0` 的 Go 服务监听器 |
|
||||
| wallet 内网入口 | `10.2.1.15:13004` | `lb-4f5xi6p0` 的 Go 服务监听器 |
|
||||
|
||||
当前生产镜像仓库前缀是 `10.2.1.3:18082/hyapp`。部署脚本和 systemd env 只允许使用 `hyapp` 项目路径。
|
||||
|
||||
## Room Owner 路由
|
||||
|
||||
`room-service` 使用 Redis 维护两个短租约:
|
||||
|
||||
```text
|
||||
room:route:{app_code + "\0" + room_id} -> owner node_id / lease_token / expires_at_ms
|
||||
room:node:{node_id} -> 实例 advertise_addr / expires_at_ms
|
||||
```
|
||||
|
||||
写命令、IM guard 和完整房间快照查询的处理顺序:
|
||||
|
||||
1. 请求先到任意 `room-service` 节点。
|
||||
2. 节点按 `app_code + "\0" + room_id` 查询 Redis owner route。
|
||||
3. 如果当前节点就是 owner,直接进入本地 Room Cell。
|
||||
4. 如果 owner 是其他节点,按 `room:node:{owner_node_id}` 找到实例私网地址并转发 gRPC。
|
||||
5. 如果 route 不存在或已过期,当前节点按原有 `EnsureOwner` 逻辑接管并从 MySQL snapshot + command log 恢复。
|
||||
|
||||
转发只做一跳。`advertise_addr` 写错成 CLB 地址时,请求会在一跳后停止递归,并暴露真实 owner 冲突或 unavailable。
|
||||
|
||||
## room-service 配置
|
||||
|
||||
每个实例必须有唯一配置:
|
||||
|
||||
```yaml
|
||||
node_id: "room-node-prod-1"
|
||||
grpc_addr: ":13001"
|
||||
advertise_addr: "10.0.1.12:13001"
|
||||
health_http_addr: ":13101"
|
||||
node_registry_ttl: "30s"
|
||||
node_registry_heartbeat_interval: "10s"
|
||||
owner_forward_timeout: "2s"
|
||||
lease_ttl: "10s"
|
||||
```
|
||||
|
||||
配置边界:
|
||||
|
||||
- `grpc_addr` 是本进程监听地址,可以是 `:13001`。
|
||||
- `advertise_addr` 是其他 room 节点能直连的实例私网地址,必须是单实例地址。
|
||||
- `node_id` 参与 room owner lease、节点注册和日志排障,不能在多个实例间复用。
|
||||
- `node_registry_ttl` 必须大于心跳周期;默认 `30s/10s`。
|
||||
- `lease_ttl` 是节点故障后的自动接管窗口;正常滚动发布会在 gRPC drain 后主动释放本节点已加载房间 lease,减少等待 TTL 的窗口。
|
||||
|
||||
## 无感发布步骤
|
||||
|
||||
单个服务滚动发布顺序:
|
||||
|
||||
1. 在新机器或新端口启动新版本实例。
|
||||
2. 等 `health_http_addr` 的 `/readyz` 或 gRPC health 通过。
|
||||
3. 把新实例加入对应 CLB 后端或内部 DNS。
|
||||
4. 观察错误率、延迟和日志。
|
||||
5. 从 CLB 摘除一个旧实例,等待 CLB 健康检查和连接排空周期。
|
||||
6. 对旧实例发送 `SIGTERM`。
|
||||
7. 旧实例进入 draining,gRPC 完成 in-flight 请求后释放已加载房间 lease,并删除 `room:node:{node_id}`。
|
||||
8. 新请求命中新节点后,如果 Redis route 已释放,会立即接管并从 MySQL 恢复;如果旧实例异常退出,则等待 `lease_ttl` 到期后接管。
|
||||
|
||||
发布约束:
|
||||
|
||||
- 不要先杀进程再摘 CLB。
|
||||
- 不要把 `room-service` 的 `advertise_addr` 写成内网 CLB。
|
||||
- 不要多个实例复用同一个 `node_id`。
|
||||
- 数据库变更要先执行兼容当前发布窗口的迁移,再滚动服务。
|
||||
|
||||
## 配置拆分
|
||||
|
||||
不用统一配置中心也可以上线。推荐发布系统渲染最终 `config.yaml`:
|
||||
|
||||
- 公共环境配置:腾讯云 MySQL/Redis/MQ 私网地址、日志等级、腾讯云 endpoint。
|
||||
- 服务配置:端口、worker 周期、批量大小、超时。
|
||||
- 实例配置:`node_id`、`advertise_addr`、`user-service.id_generator.node_id`。
|
||||
- Secret:MySQL/Redis 密码、JWT secret、腾讯 IM/RTC/COS secret、callback token。
|
||||
|
||||
只有当需要动态配置、灰度下发、审计回滚时,再引入 Consul/Nacos/Apollo。当前代码的启动配置仍以单服务 YAML 为边界。
|
||||
|
||||
线上字段契约以 `services/<service>/configs/config.tencent.example.yaml` 为准。开发时新增配置字段的动作必须同时更新三处:
|
||||
|
||||
1. `services/<service>/configs/config.yaml`:本地直跑默认配置。
|
||||
2. `services/<service>/configs/config.docker.yaml`:本地 Docker/Compose 配置。
|
||||
3. `services/<service>/configs/config.tencent.example.yaml`:线上渲染契约。
|
||||
|
||||
部署前运行:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/validate_rendered_configs.py \
|
||||
--inventory deploy/tencent-tat/inventory.prod.example.json \
|
||||
--rendered-dir /tmp/hyapp-rendered-prod
|
||||
```
|
||||
|
||||
这个检查只比较 YAML 字段路径,不比较也不输出配置值。只要本地新增字段没有进入线上契约,或者最终渲染 YAML 漏字段,部署前会直接失败。
|
||||
|
||||
托管依赖边界:
|
||||
|
||||
- 腾讯云 MySQL 是业务事实和恢复来源;DSN 必须保留 `loc=UTC`。
|
||||
- 腾讯云 Redis 保存 owner route、node registry、登录风控和短 TTL 缓存;Redis 不能作为业务恢复事实来源。
|
||||
- 腾讯云 MQ 只作为 outbox/event bus 的托管消息通道;创建 topic、consumer group 和权限变更走云产品流程,不混进服务滚动发布。
|
||||
|
||||
当前 RocketMQ 资源约定:
|
||||
|
||||
| Topic | Producer Group | Consumer Group | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-activity-room-outbox` | activity-service 消费房间事实,处理贵重礼物播报、宝箱播报、成长等活动逻辑 |
|
||||
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-notice-room-outbox` | notice-service 消费私有通知相关房间事实 |
|
||||
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-room-im-bridge` | room-service IM bridge 消费房间群系统消息和群成员控制事实 |
|
||||
| `hyapp_room_treasure_open` | `hyapp-room-treasure-open-producer` | `hyapp-room-treasure-open` | room-service 消费宝箱延迟开箱唤醒消息 |
|
||||
|
||||
线上推荐配置:
|
||||
|
||||
- `services/room-service/configs/config.tencent.example.yaml` 保持 `outbox_worker.publish_mode: "mq"`。
|
||||
- `rocketmq.room_outbox.enabled: true` 后,`room_outbox.status=delivered` 只表示事件已进入 RocketMQ;activity、notice、IM bridge 的成功状态由各自消费位点负责。
|
||||
- `rocketmq.treasure_open.enabled: true` 后,宝箱倒计时使用毫秒级 `open_at_ms` 投递延迟消息;消息到达后 room-service 仍要重新校验 Room Cell 状态,不能把 MQ 当成宝箱状态来源。
|
||||
- 本地 Docker 默认关闭 MQ,使用 direct publisher 维持无云依赖的可运行形态。
|
||||
|
||||
## 真实数据验证
|
||||
|
||||
本地真实 MySQL/Redis 验证:
|
||||
|
||||
```bash
|
||||
docker compose up -d mysql redis
|
||||
ROOM_SERVICE_MYSQL_TEST_DSN='hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC' \
|
||||
ROOM_SERVICE_REDIS_TEST_ADDR='127.0.0.1:13379' \
|
||||
go test -count=1 ./services/room-service/internal/transport/grpc -run TestOwnerForwardingUsesRedisRouteAndNodeRegistry
|
||||
```
|
||||
|
||||
该测试会启动两个真实 gRPC `room-service` 实例,共用真实 MySQL schema 和 Redis route/node registry:在 node A 创建房间,通过 node B 调 `JoinRoom`、`CheckSpeakPermission` 和 `GetRoomSnapshot`,验证请求会转发到当前 owner。
|
||||
@ -1 +0,0 @@
|
||||
# Package marker for deployment helper tests.
|
||||
@ -1,45 +0,0 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
-- 使用 utf8mb4 连接字符集,保证中文注释、中文名称和 emoji 在导入时不被降级。
|
||||
-- 本地 Docker 入口脚本会先创建 MYSQL_DATABASE,服务 DDL 随后创建各业务库。
|
||||
-- 这里统一修复新旧本地卷的默认字符集,保证中文文本、中文 COMMENT 和 emoji 不被旧默认字符集截断。
|
||||
ALTER DATABASE hyapp_room CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER DATABASE hyapp_user CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER DATABASE hyapp_wallet CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER DATABASE hyapp_activity CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_room;
|
||||
ALTER TABLE rooms CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE room_list_entries CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE room_snapshots CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE room_command_log CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE room_outbox CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_user;
|
||||
ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE countries CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE regions CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE region_countries CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE region_change_logs CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE user_region_rebuild_tasks CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE user_display_user_ids CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE pretty_display_user_id_leases CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE display_user_id_change_logs CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE user_country_change_logs CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE password_accounts CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE third_party_identities CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE auth_sessions CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE login_audit CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_wallet;
|
||||
ALTER TABLE wallet_accounts CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE wallet_transactions CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE wallet_entries CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE wallet_outbox CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE wallet_gift_prices CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_activity;
|
||||
ALTER TABLE activities CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE activity_event_consumption CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE activity_outbox CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@ -1,5 +0,0 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
-- 使用 utf8mb4 连接字符集,保证中文注释、中文名称和 emoji 在导入时不被降级。
|
||||
CREATE DATABASE IF NOT EXISTS hyapp_admin DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@ -1,57 +0,0 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 使用 utf8mb4 连接字符集,保证中文注释、中文名称和 emoji 在导入时不被降级。
|
||||
|
||||
USE hyapp_wallet;
|
||||
|
||||
DROP PROCEDURE IF EXISTS migrate_resource_group_wallet_asset_items;
|
||||
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE migrate_resource_group_wallet_asset_items()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_group_items' AND COLUMN_NAME = 'item_type'
|
||||
) THEN
|
||||
ALTER TABLE resource_group_items
|
||||
ADD COLUMN item_type VARCHAR(32) NOT NULL DEFAULT 'resource' COMMENT '明细类型' AFTER group_id;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_group_items' AND COLUMN_NAME = 'wallet_asset_type'
|
||||
) THEN
|
||||
ALTER TABLE resource_group_items
|
||||
ADD COLUMN wallet_asset_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '钱包资产类型' AFTER resource_id;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_group_items' AND COLUMN_NAME = 'wallet_asset_amount'
|
||||
) THEN
|
||||
ALTER TABLE resource_group_items
|
||||
ADD COLUMN wallet_asset_amount BIGINT NOT NULL DEFAULT 0 COMMENT '钱包资产数量' AFTER wallet_asset_type;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_group_items' AND INDEX_NAME = 'uk_resource_group_items_resource'
|
||||
) THEN
|
||||
ALTER TABLE resource_group_items DROP INDEX uk_resource_group_items_resource;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_group_items' AND INDEX_NAME = 'uk_resource_group_items_member'
|
||||
) THEN
|
||||
ALTER TABLE resource_group_items
|
||||
ADD UNIQUE KEY uk_resource_group_items_member (app_code, group_id, item_type, resource_id, wallet_asset_type);
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
CALL migrate_resource_group_wallet_asset_items();
|
||||
|
||||
DROP PROCEDURE migrate_resource_group_wallet_asset_items;
|
||||
@ -1,14 +0,0 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 使用 utf8mb4 连接字符集,保证中文注释、中文名称和 emoji 在导入时不被降级。
|
||||
|
||||
USE hyapp_user;
|
||||
|
||||
-- 对已经执行过旧国家种子的本地库做幂等修正,保证国家主数据与当前产品范围一致。
|
||||
DELETE FROM region_countries
|
||||
WHERE app_code = 'lalu'
|
||||
AND country_code = 'TW';
|
||||
|
||||
DELETE FROM countries
|
||||
WHERE app_code = 'lalu'
|
||||
AND country_code = 'TW';
|
||||
@ -1,17 +0,0 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
-- 使用 utf8mb4 连接字符集,保证中文注释、中文名称和 emoji 在导入时不被降级。
|
||||
CREATE USER IF NOT EXISTS 'hyapp'@'%' IDENTIFIED BY 'hyapp';
|
||||
|
||||
GRANT ALL PRIVILEGES ON hyapp_room.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_user.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_wallet.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_activity.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_admin.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_cron.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_game.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_notice.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_statistics.* TO 'hyapp'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
@ -1,9 +0,0 @@
|
||||
brokerClusterName = hyapp-local
|
||||
brokerName = broker-a
|
||||
brokerId = 0
|
||||
deleteWhen = 04
|
||||
fileReservedTime = 48
|
||||
brokerRole = ASYNC_MASTER
|
||||
flushDiskType = ASYNC_FLUSH
|
||||
autoCreateTopicEnable = true
|
||||
listenPort = 10911
|
||||
@ -1,101 +0,0 @@
|
||||
# Standalone CVM Deployment Templates
|
||||
|
||||
本目录提供不依赖 Kubernetes 的生产部署模板。目标形态是 systemd 托管 Docker 容器,公网 CLB 只接 gateway,内部服务通过内网 CLB/DNS 或固定私网地址访问。
|
||||
|
||||
生产环境 MySQL、Redis、MQ 使用腾讯云托管资源。本目录只管理本仓库 Go 服务容器,不在业务 CVM 上安装或重启 MySQL/Redis/MQ。
|
||||
|
||||
## Layout
|
||||
|
||||
- `systemd/*.service`: 每个后端服务一份 systemd unit,负责启动、停止和故障重启 Docker 容器。
|
||||
- `docker/*.env.example`: 每个服务一份 Docker 运行参数示例,只保存镜像名、容器名、配置路径和停止等待时间。
|
||||
- 服务业务配置仍使用 `services/<service>/configs/config.tencent.example.yaml` 作为线上模板,渲染后放到 `/etc/hyapp/<service>/config.yaml`。
|
||||
|
||||
## Install One Service
|
||||
|
||||
以 `gateway-service` 为例:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/hyapp/gateway-service
|
||||
sudo cp deploy/standalone/docker/gateway-service.env.example /etc/hyapp/gateway-service/docker.env
|
||||
sudo cp services/gateway-service/configs/config.tencent.example.yaml /etc/hyapp/gateway-service/config.yaml
|
||||
sudo vi /etc/hyapp/gateway-service/docker.env
|
||||
sudo vi /etc/hyapp/gateway-service/config.yaml
|
||||
|
||||
sudo cp deploy/standalone/systemd/hyapp-gateway-service.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now hyapp-gateway-service
|
||||
sudo systemctl status hyapp-gateway-service
|
||||
sudo docker inspect --format '{{json .State.Health}}' hyapp-gateway-service
|
||||
```
|
||||
|
||||
`docker.env` 不承载业务密钥;密钥仍在最终 `config.yaml` 中,或者由发布系统先从密钥系统渲染到 `config.yaml`。当前代码只读取单个 YAML,不会自动展开 `${VAR}`。
|
||||
|
||||
## Network
|
||||
|
||||
模板默认使用 `--network=host`,让容器直接监听项目约定的 `13xxx` 端口。这样 CLB、安全组和本机排障都以同一个端口工作:
|
||||
|
||||
- `13000`: gateway HTTP
|
||||
- `13001`: room gRPC
|
||||
- `13004`: wallet gRPC
|
||||
- `13005`: user gRPC
|
||||
- `13006`: activity gRPC
|
||||
- `13007`: cron gRPC
|
||||
- `13008`: game gRPC
|
||||
- `13009`: notice gRPC
|
||||
|
||||
同一台 CVM 上不要启动两个相同端口的同服务实例。做蓝绿发布时优先使用新 CVM 或新容器节点,健康通过后再挂载到 CLB。
|
||||
|
||||
## Rolling Update
|
||||
|
||||
普通服务的无感更新流程:
|
||||
|
||||
1. 在新 CVM 上准备 `/etc/hyapp/<service>/docker.env` 和 `/etc/hyapp/<service>/config.yaml`。
|
||||
2. `systemctl start hyapp-<service>`,等待 Docker health 进入 `healthy`。
|
||||
3. 将新实例加入对应 CLB 或内部服务入口。
|
||||
4. 观察错误率、延迟和服务日志。
|
||||
5. 从 CLB 摘除旧实例,等待连接保护或业务 drain 窗口结束。
|
||||
6. `systemctl stop hyapp-<service>`,Docker 会先发送 SIGTERM,服务内的 graceful shutdown 会进入 draining 并等待已进入请求结束。
|
||||
|
||||
`room-service` 已经具备 Redis owner route 和非 owner 自动转发能力,可以把多个 active 实例挂到内网 CLB 后面。每个实例仍必须配置唯一 `node_id`,且 `advertise_addr` 必须是实例私网地址,不能写 CLB 地址。
|
||||
|
||||
## Tencent TAT Rolling Deploy
|
||||
|
||||
当前腾讯云 CVM 生产部署使用 `deploy/tencent-tat`:
|
||||
|
||||
- `inventory.prod.example.json`: 记录 gateway/new-app/new-game/new-app-activity/pay 这批机器和服务映射。
|
||||
- `hyapp_tat_deploy.py`: 通过 TAT 在目标机执行 systemd 重启和镜像更新。
|
||||
- 发布时对配置了 CLB 的服务逐实例改权重到 `0`,健康恢复后再恢复原权重。
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py restart \
|
||||
--inventory deploy/tencent-tat/inventory.prod.json \
|
||||
--services gateway-service \
|
||||
--yes
|
||||
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy \
|
||||
--inventory deploy/tencent-tat/inventory.prod.json \
|
||||
--services gateway-service,room-service,user-service,wallet-service,activity-service,game-service,notice-service \
|
||||
--tag 20260513-1349 \
|
||||
--yes
|
||||
```
|
||||
|
||||
详细步骤和无感发布边界见 `deploy/tencent-tat/README.md`。
|
||||
|
||||
## Service Discovery
|
||||
|
||||
不使用 Kubernetes 时,第一阶段推荐用内网 CLB/DNS 提供稳定入口,例如:
|
||||
|
||||
```yaml
|
||||
user_service_addr: "user.service.internal:13005"
|
||||
wallet_service_addr: "wallet.service.internal:13004"
|
||||
activity_service_addr: "activity.service.internal:13006"
|
||||
room_service_addr: "room.service.internal:13001"
|
||||
game_service_addr: "game.service.internal:13008"
|
||||
notice_service_addr: "notice.service.internal:13009"
|
||||
```
|
||||
|
||||
如果后续引入 Consul/Nacos,仍建议让业务配置只保存稳定服务名,不把实例 IP 写进每个服务配置。
|
||||
|
||||
MySQL、Redis、MQ 不属于这里的服务发现对象。它们在 `config.yaml` 中写腾讯云托管资源的内网 endpoint,变更走对应云产品流程。
|
||||
@ -1,6 +0,0 @@
|
||||
# Image is immutable per release; publish scripts pull this tag before restarting systemd.
|
||||
IMAGE=10.2.1.3:18082/hyapp/activity-service:REPLACE_WITH_RELEASE_TAG
|
||||
CONTAINER_NAME=hyapp-activity-service
|
||||
CONFIG_PATH=/etc/hyapp/activity-service/config.yaml
|
||||
STOP_TIMEOUT_SEC=60
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
IMAGE=10.2.1.3:18082/hyapp/admin-platform:REPLACE_TAG
|
||||
CONTAINER_NAME=hyapp-admin-platform
|
||||
STOP_TIMEOUT_SEC=15
|
||||
@ -1,4 +0,0 @@
|
||||
IMAGE=10.2.1.3:18082/hyapp/admin-server:REPLACE_TAG
|
||||
CONTAINER_NAME=hyapp-admin-server
|
||||
CONFIG_PATH=/etc/hyapp/admin-server/config.yaml
|
||||
STOP_TIMEOUT_SEC=30
|
||||
@ -1,6 +0,0 @@
|
||||
# cron-service already uses MySQL task leases, but keep one replica until every task is verified under multi-node scheduling.
|
||||
IMAGE=10.2.1.3:18082/hyapp/cron-service:REPLACE_WITH_RELEASE_TAG
|
||||
CONTAINER_NAME=hyapp-cron-service
|
||||
CONFIG_PATH=/etc/hyapp/cron-service/config.yaml
|
||||
STOP_TIMEOUT_SEC=90
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
IMAGE=10.2.1.3:18082/hyapp/game-service:REPLACE_WITH_RELEASE_TAG
|
||||
CONTAINER_NAME=hyapp-game-service
|
||||
CONFIG_PATH=/etc/hyapp/game-service/config.yaml
|
||||
STOP_TIMEOUT_SEC=60
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
# gateway instances are stateless HTTP entries and should be attached behind the public CLB only after /healthz/ready passes.
|
||||
IMAGE=10.2.1.3:18082/hyapp/gateway-service:REPLACE_WITH_RELEASE_TAG
|
||||
CONTAINER_NAME=hyapp-gateway-service
|
||||
CONFIG_PATH=/etc/hyapp/gateway-service/config.yaml
|
||||
STOP_TIMEOUT_SEC=60
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
# Image is immutable per release; publish scripts pull this tag before restarting systemd.
|
||||
IMAGE=10.2.1.3:18082/hyapp/notice-service:REPLACE_WITH_RELEASE_TAG
|
||||
CONTAINER_NAME=hyapp-notice-service
|
||||
CONFIG_PATH=/etc/hyapp/notice-service/config.yaml
|
||||
STOP_TIMEOUT_SEC=60
|
||||
@ -1,6 +0,0 @@
|
||||
# Keep room-service single-active until owner routing or non-owner forwarding is implemented.
|
||||
IMAGE=10.2.1.3:18082/hyapp/room-service:REPLACE_WITH_RELEASE_TAG
|
||||
CONTAINER_NAME=hyapp-room-service
|
||||
CONFIG_PATH=/etc/hyapp/room-service/config.yaml
|
||||
STOP_TIMEOUT_SEC=90
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
IMAGE=10.2.1.3:18082/hyapp/statistics-service:RELEASE_TAG
|
||||
CONTAINER_NAME=hyapp-statistics-service
|
||||
CONFIG_PATH=/etc/hyapp/statistics-service/config.yaml
|
||||
STOP_TIMEOUT_SEC=60
|
||||
@ -1,6 +0,0 @@
|
||||
# id_generator.node_id in config.yaml must be unique for every active user-service instance.
|
||||
IMAGE=10.2.1.3:18082/hyapp/user-service:REPLACE_WITH_RELEASE_TAG
|
||||
CONTAINER_NAME=hyapp-user-service
|
||||
CONFIG_PATH=/etc/hyapp/user-service/config.yaml
|
||||
STOP_TIMEOUT_SEC=60
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
IMAGE=10.2.1.3:18082/hyapp/wallet-service:REPLACE_WITH_RELEASE_TAG
|
||||
CONTAINER_NAME=hyapp-wallet-service
|
||||
CONFIG_PATH=/etc/hyapp/wallet-service/config.yaml
|
||||
STOP_TIMEOUT_SEC=60
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
[Unit]
|
||||
Description=Hyapp activity-service container
|
||||
Requires=docker.service
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/activity-service/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13006 -service=activity-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStartSec=60
|
||||
TimeoutStopSec=90
|
||||
KillMode=none
|
||||
LimitNOFILE=1048576
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
[Unit]
|
||||
Description=HYApp admin platform frontend
|
||||
After=docker.service network-online.target hyapp-admin-server.service
|
||||
Wants=network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/admin-platform/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop -t ${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -1,17 +0,0 @@
|
||||
[Unit]
|
||||
Description=HYApp admin-server
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/admin-server/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --mount=type=bind,src=/var/lib/hyapp/admin-server/storage,dst=/app/storage ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop -t ${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -1,24 +0,0 @@
|
||||
[Unit]
|
||||
Description=Hyapp cron-service container
|
||||
Requires=docker.service
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/cron-service/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13007 -service=cron-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStartSec=60
|
||||
TimeoutStopSec=120
|
||||
KillMode=none
|
||||
LimitNOFILE=1048576
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
[Unit]
|
||||
Description=Hyapp game-service container
|
||||
Requires=docker.service
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/game-service/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13008 -service=game-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStartSec=60
|
||||
TimeoutStopSec=90
|
||||
KillMode=none
|
||||
LimitNOFILE=1048576
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
[Unit]
|
||||
Description=Hyapp gateway-service container
|
||||
Requires=docker.service
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/gateway-service/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="wget -q -O - http://127.0.0.1:13000/healthz/ready >/dev/null" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStartSec=60
|
||||
TimeoutStopSec=90
|
||||
KillMode=none
|
||||
LimitNOFILE=1048576
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
[Unit]
|
||||
Description=Hyapp notice-service container
|
||||
Requires=docker.service
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/notice-service/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13009 -service=notice-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStartSec=60
|
||||
TimeoutStopSec=90
|
||||
KillMode=none
|
||||
LimitNOFILE=1048576
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -1,24 +0,0 @@
|
||||
[Unit]
|
||||
Description=Hyapp room-service container
|
||||
Requires=docker.service
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/room-service/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13001 -service=room-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStartSec=60
|
||||
TimeoutStopSec=120
|
||||
KillMode=none
|
||||
LimitNOFILE=1048576
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
[Unit]
|
||||
Description=HYApp statistics-service
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/statistics-service/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop -t ${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -1,24 +0,0 @@
|
||||
[Unit]
|
||||
Description=Hyapp user-service container
|
||||
Requires=docker.service
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/user-service/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13005 -service=user-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStartSec=60
|
||||
TimeoutStopSec=90
|
||||
KillMode=none
|
||||
LimitNOFILE=1048576
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
[Unit]
|
||||
Description=Hyapp wallet-service container
|
||||
Requires=docker.service
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/wallet-service/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --mount=type=bind,src=/etc/hyapp/secrets,dst=/etc/hyapp/secrets,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13004 -service=wallet-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStartSec=60
|
||||
TimeoutStopSec=90
|
||||
KillMode=none
|
||||
LimitNOFILE=1048576
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -1,307 +0,0 @@
|
||||
# 腾讯云 TAT 滚动部署方案
|
||||
|
||||
本方案只部署本仓库 Go 微服务,不接管同机已有 Java 项目。远端连接使用腾讯云 TAT,发布时只操作 `hyapp-*` systemd unit、`hyapp-*` Docker 容器和 `/etc/hyapp/<service>/docker.env`。
|
||||
|
||||
MySQL、Redis、MQ 都按腾讯云托管资源处理。本方案不会在这些 CVM 上部署、重启或迁移本地 MySQL/Redis/MQ;服务配置只需要指向腾讯云托管实例的内网地址。
|
||||
|
||||
## 机器拓扑
|
||||
|
||||
`deploy` 机器是发布控制面,不承载业务流量:
|
||||
|
||||
| 机器 | 实例 ID | 公网 IP | 私网 IP | 角色 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `deploy` | `ns-ntmpcd3a` | `43.164.75.199` | `10.2.1.3` | 构建镜像、推送镜像、调用 TAT/CLB API 调度发布 |
|
||||
|
||||
当前机器按截图落到以下服务:
|
||||
|
||||
| 机器 | 实例 ID | 私网 IP | 本仓库服务 |
|
||||
| --- | --- | --- | --- |
|
||||
| `gateway-1` | `ins-aeyaoj40` | `10.2.1.8` | `gateway-service` |
|
||||
| `gateway-2` | `ins-irvi5b7u` | `10.2.2.17` | `gateway-service` |
|
||||
| `new-app-1` | `ins-fi5zufpk` | `10.2.1.4` | `room-service`, `user-service` |
|
||||
| `new-app-2` | `ins-hwhaoe8c` | `10.2.1.13` | `room-service`, `user-service` |
|
||||
| `new-game-1` | `ins-ir229jtw` | `10.2.1.10` | `game-service` |
|
||||
| `new-game-2` | `ins-460rw7gu` | `10.2.1.6` | `game-service` |
|
||||
| `new-app-activity-1` | `ins-n8wut6n8` | `10.2.1.11` | `activity-service`, `notice-service` |
|
||||
| `new-app-activity-2` | `ins-d1n303sa` | `10.2.1.7` | `activity-service`, `notice-service` |
|
||||
| `pay-1` | `ins-ceqfcxd2` | `10.2.11.14` | `wallet-service` |
|
||||
| `pay-2` | `ins-awhb74q6` | `10.2.12.5` | `wallet-service` |
|
||||
|
||||
如果 `new-app-*` 只部署 `room-service` 或只部署 `user-service`,直接删掉 `inventory` 中对应服务的 host 绑定,不要让脚本猜。
|
||||
|
||||
## 部署模式
|
||||
|
||||
采用“deploy 服务器调度 + 业务机只运行镜像”的模式:
|
||||
|
||||
1. `deploy` 服务器拉取本仓库代码,构建 Docker 镜像,推送到 CCR/Harbor。
|
||||
2. `deploy` 服务器调用腾讯云 CLB API 对目标实例摘流。
|
||||
3. `deploy` 服务器通过 TAT 在目标业务机执行 `docker pull` 和 `systemctl restart hyapp-<service>`。
|
||||
4. 目标业务机只保存 `/etc/hyapp/<service>/config.yaml` 和 `docker.env`,不拉代码、不编译、不保存 Git 凭据。
|
||||
|
||||
不要让每台业务服务器自己 `git pull && build`。那会把代码权限、构建环境、版本一致性和回滚都扩散到业务机,且多服务发布时无法统一做 CLB 摘流。
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. 每台目标机已安装腾讯云 TAT agent,控制台能执行命令。
|
||||
2. 每个 Go 服务已按 `deploy/standalone/systemd` 安装对应 systemd unit。
|
||||
3. 每个 Go 服务已有 `/etc/hyapp/<service>/config.yaml` 和 `/etc/hyapp/<service>/docker.env`。
|
||||
4. 每个服务至少有两台实例,CLB 后端都挂实例端口。
|
||||
5. `room-service` 每台机器必须有唯一 `node_id`,`advertise_addr` 必须是当前机器私网地址,例如 `10.2.1.4:13001`,不能写内网 CLB。
|
||||
6. 每个 `config.yaml` 的 `mysql_dsn`、`redis_addr`、MQ endpoint/topic/group 指向腾讯云托管资源的私网地址。
|
||||
7. 发布机安装腾讯云 Python SDK 和 YAML 解析依赖:
|
||||
|
||||
```bash
|
||||
python3 -m pip install tencentcloud-sdk-python PyYAML
|
||||
```
|
||||
|
||||
凭据通过环境变量提供:
|
||||
|
||||
```bash
|
||||
export TENCENTCLOUD_SECRET_ID="..."
|
||||
export TENCENTCLOUD_SECRET_KEY="..."
|
||||
export TENCENTCLOUD_REGION="me-saudi-arabia"
|
||||
```
|
||||
|
||||
如果继续沿用 `hy-app-monitor`,可以把这些变量放在 deploy 机器的 `/opt/hy-app-monitor/.env`,运行脚本时传 `--env-file /opt/hy-app-monitor/.env`。本地开发机路径 `/Users/hy/Documents/hy/hy-app-monitor/.env` 只用于本机调试。
|
||||
|
||||
## Deploy 服务器初始化
|
||||
|
||||
在 `deploy / 10.2.1.3` 上安装基础工具:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/deploy/sources /opt/deploy/inventory
|
||||
sudo yum install -y git docker python3 python3-pip || sudo apt-get update && sudo apt-get install -y git docker.io python3 python3-pip
|
||||
sudo systemctl enable --now docker
|
||||
python3 -m pip install --user tencentcloud-sdk-python PyYAML
|
||||
```
|
||||
|
||||
拉取本仓库:
|
||||
|
||||
```bash
|
||||
cd /opt/deploy/sources
|
||||
git clone <YOUR_GIT_REPO_URL> hyapp-server
|
||||
cd hyapp-server
|
||||
```
|
||||
|
||||
准备 inventory:
|
||||
|
||||
```bash
|
||||
cp deploy/tencent-tat/inventory.prod.example.json /opt/deploy/inventory/hyapp.prod.json
|
||||
vi /opt/deploy/inventory/hyapp.prod.json
|
||||
```
|
||||
|
||||
`inventory` 里的 `registry` 必须和实际镜像仓库一致。如果使用 `hy-app-monitor` 里的 Harbor 配置,通常是:
|
||||
|
||||
```text
|
||||
registry = ${HARBOR_REGISTRY}/${HARBOR_PROJECT}
|
||||
```
|
||||
|
||||
业务配置放到各目标机 `/etc/hyapp/<service>/config.yaml`。腾讯云 MySQL、Redis、MQ、JWT、IM/RTC/COS 等密钥不要提交到 Git。
|
||||
|
||||
配置字段契约以 `services/<service>/configs/config.tencent.example.yaml` 为准。开发时如果在本地 `config.yaml` 或 `config.docker.yaml` 新增字段,必须同时补到 `config.tencent.example.yaml`;生产渲染前运行校验,避免线上漏字段:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/validate_rendered_configs.py \
|
||||
--inventory deploy/tencent-tat/inventory.prod.example.json
|
||||
```
|
||||
|
||||
如果已经渲染了生产最终配置,还要校验每台机器的最终 YAML:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/validate_rendered_configs.py \
|
||||
--inventory deploy/tencent-tat/inventory.prod.example.json \
|
||||
--rendered-dir /tmp/hyapp-rendered-prod
|
||||
```
|
||||
|
||||
该脚本只比较 YAML key,不打印配置值,避免把生产密钥带到日志里。
|
||||
|
||||
## 构建并推送镜像
|
||||
|
||||
在 `deploy` 服务器上执行。2 核 2GiB 机器可以作为调度器;如果全量构建 OOM 或太慢,把构建放到 CI,deploy 服务器只负责调度发布。
|
||||
|
||||
```bash
|
||||
cd /opt/deploy/sources/hyapp-server
|
||||
TAG="$(date -u +%Y%m%d%H%M%S)-$(git rev-parse --short HEAD)"
|
||||
REGISTRY="10.2.1.3:18082/hyapp"
|
||||
|
||||
docker build -t "$REGISTRY/gateway-service:$TAG" -f services/gateway-service/Dockerfile .
|
||||
docker build -t "$REGISTRY/room-service:$TAG" -f services/room-service/Dockerfile .
|
||||
docker build -t "$REGISTRY/user-service:$TAG" -f services/user-service/Dockerfile .
|
||||
docker build -t "$REGISTRY/wallet-service:$TAG" -f services/wallet-service/Dockerfile .
|
||||
docker build -t "$REGISTRY/activity-service:$TAG" -f services/activity-service/Dockerfile .
|
||||
docker build -t "$REGISTRY/game-service:$TAG" -f services/game-service/Dockerfile .
|
||||
docker build -t "$REGISTRY/notice-service:$TAG" -f services/notice-service/Dockerfile .
|
||||
|
||||
docker push "$REGISTRY/gateway-service:$TAG"
|
||||
docker push "$REGISTRY/room-service:$TAG"
|
||||
docker push "$REGISTRY/user-service:$TAG"
|
||||
docker push "$REGISTRY/wallet-service:$TAG"
|
||||
docker push "$REGISTRY/activity-service:$TAG"
|
||||
docker push "$REGISTRY/game-service:$TAG"
|
||||
docker push "$REGISTRY/notice-service:$TAG"
|
||||
```
|
||||
|
||||
如果实际用 Harbor,把 `REGISTRY` 换成 `${HARBOR_REGISTRY}/${HARBOR_PROJECT}`,并先 `docker login`。
|
||||
|
||||
## 配置 inventory
|
||||
|
||||
复制模板后填真实 CLB:
|
||||
|
||||
```bash
|
||||
cp deploy/tencent-tat/inventory.prod.example.json deploy/tencent-tat/inventory.prod.json
|
||||
vi deploy/tencent-tat/inventory.prod.json
|
||||
```
|
||||
|
||||
必须替换:
|
||||
|
||||
- `gateway-service.clb.load_balancer_id`:公网 gateway CLB。
|
||||
- `gateway-service.clb.listener_ids`:公网 HTTP/HTTPS 监听器。
|
||||
- 内部服务各自的 `load_balancer_id` 和 `listener_ids`:room/user/wallet/activity/game 内网 CLB。
|
||||
|
||||
脚本通过实例 ID、私网 IP、端口三者匹配 CLB target。发布前会把当前实例在相关 listener 下的权重改成 `0`,服务健康后恢复原权重。
|
||||
|
||||
`managed_dependencies` 只记录部署边界:腾讯云 MySQL/Redis/MQ 是外部托管依赖,不纳入 TAT 发布动作。生产变更这些资源时单独走云数据库、Redis 或 MQ 的变更流程。
|
||||
|
||||
当前生产 inventory 已写入真实绑定:
|
||||
|
||||
| 服务 | CLB | 监听器 | 对外/内网入口 |
|
||||
| --- | --- | --- | --- |
|
||||
| `gateway-service` | `lb-cbiabr1q` | `lbl-ktvz8m6k` / rule `loc-pwedmsmm` | `https://api.global-interaction.com` -> `13000` |
|
||||
| `room-service` | `lb-epvnr4o0` | `lbl-d2urlzba` | `10.2.1.16:13001` |
|
||||
| `user-service` | `lb-epvnr4o0` | `lbl-honi8z3a` | `10.2.1.16:13005` |
|
||||
| `activity-service` | `lb-epvnr4o0` | `lbl-j7oqtfhq` | `10.2.1.16:13006` |
|
||||
| `game-service` | `lb-epvnr4o0` | `lbl-ku138b4y` | `10.2.1.16:13008` |
|
||||
| `notice-service` | `lb-epvnr4o0` | `lbl-aux9xsjq` | `10.2.1.16:13009` |
|
||||
| `wallet-service` | `lb-4f5xi6p0` | `lbl-9wi5mvu4` | `10.2.1.15:13004` |
|
||||
|
||||
Harbor 项目使用 `hyapp`,当前生产镜像前缀是 `10.2.1.3:18082/hyapp`。不要使用其他业务项目名前缀。
|
||||
|
||||
## 线上服务配置边界
|
||||
|
||||
这些机器上只部署 Go 服务进程:
|
||||
|
||||
- `gateway-service`:`room_service_addr/user_service_addr/wallet_service_addr/activity_service_addr/game_service_addr` 指向各服务内网 CLB/DNS。
|
||||
- `room-service`:`mysql_dsn` 指向腾讯云 MySQL,`redis_addr` 指向腾讯云 Redis;`advertise_addr` 指向本机私网 IP。
|
||||
- `user-service`:`mysql_dsn`、worker 里引用的 wallet/room MySQL DSN、登录风控 Redis 都指向腾讯云托管实例。
|
||||
- `wallet-service`、`activity-service`、`game-service`:MySQL 和下游 gRPC 地址都使用腾讯云托管资源或内网 CLB/DNS。
|
||||
- `notice-service`:`mysql_dsn` 指向 `hyapp_notice`,`wallet_database` 指向 `hyapp_wallet`;它只读取 wallet outbox 并写自己的投递位点,不修改钱包事实表。
|
||||
- MQ 当前只作为 outbox/event bus 方向的外部托管依赖;本脚本不会创建 topic、consumer group 或本地 broker。
|
||||
|
||||
腾讯云 MySQL DSN 必须保留 `loc=UTC`。Redis 使用同一托管实例时,用 `redis_db` 或 key prefix 区分业务域,不要把 Redis 当恢复事实来源。
|
||||
|
||||
## 常用命令
|
||||
|
||||
先看计划,不执行远端动作:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py plan \
|
||||
--inventory deploy/tencent-tat/inventory.prod.json \
|
||||
--services gateway-service,room-service
|
||||
```
|
||||
|
||||
用 TAT 连接目标机并自动发现真实部署状态和 CLB 绑定:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py discover \
|
||||
--env-file /opt/hy-app-monitor/.env \
|
||||
--inventory /opt/deploy/inventory/hyapp.prod.json \
|
||||
--services gateway-service,room-service,user-service,wallet-service,activity-service,game-service,notice-service
|
||||
```
|
||||
|
||||
`discover` 会读取每台目标机的 `hyapp-*` systemd unit、`/etc/hyapp/*/docker.env`、`hyapp-*` 容器和监听端口,并通过 CLB API 按后端私网 IP/端口反查负载均衡绑定。
|
||||
|
||||
单服务重启:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py restart \
|
||||
--env-file /opt/hy-app-monitor/.env \
|
||||
--inventory /opt/deploy/inventory/hyapp.prod.json \
|
||||
--services wallet-service \
|
||||
--yes
|
||||
```
|
||||
|
||||
多服务重启:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py restart \
|
||||
--env-file /opt/hy-app-monitor/.env \
|
||||
--inventory /opt/deploy/inventory/hyapp.prod.json \
|
||||
--services room-service,user-service,wallet-service,activity-service,game-service,notice-service \
|
||||
--yes
|
||||
```
|
||||
|
||||
单服务更新部署:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy \
|
||||
--env-file /opt/hy-app-monitor/.env \
|
||||
--inventory /opt/deploy/inventory/hyapp.prod.json \
|
||||
--services gateway-service \
|
||||
--tag 20260513-1349 \
|
||||
--yes
|
||||
```
|
||||
|
||||
多服务更新部署:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy \
|
||||
--env-file /opt/hy-app-monitor/.env \
|
||||
--inventory /opt/deploy/inventory/hyapp.prod.json \
|
||||
--services gateway-service,room-service,user-service,wallet-service,activity-service,game-service,notice-service \
|
||||
--tag 20260513-1349 \
|
||||
--yes
|
||||
```
|
||||
|
||||
如果某个服务镜像 tag 不同,可以显式传镜像:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy \
|
||||
--env-file /opt/hy-app-monitor/.env \
|
||||
--inventory /opt/deploy/inventory/hyapp.prod.json \
|
||||
--services gateway-service,wallet-service \
|
||||
--image gateway-service=10.2.1.3:18082/hyapp/gateway-service:gw-20260513 \
|
||||
--image wallet-service=10.2.1.3:18082/hyapp/wallet-service:pay-20260513 \
|
||||
--yes
|
||||
```
|
||||
|
||||
只维护某一台机器时使用 `--hosts`,例如只重启 `gateway-1`:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py restart \
|
||||
--env-file /opt/hy-app-monitor/.env \
|
||||
--inventory /opt/deploy/inventory/hyapp.prod.json \
|
||||
--services gateway-service \
|
||||
--hosts gateway-1 \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 无感更新顺序
|
||||
|
||||
对每个服务实例,脚本固定执行:
|
||||
|
||||
1. 读取该实例当前 CLB 绑定和原权重。
|
||||
2. 将该实例在所有匹配 listener/rule 下的权重改为 `0`。
|
||||
3. 等待 `drain_seconds`,让 CLB 停止分配新请求。
|
||||
4. 通过 TAT 在目标机更新 `docker.env` 的 `IMAGE`,执行 `docker pull`。
|
||||
5. `systemctl restart hyapp-<service>`,Docker 发送 `SIGTERM`,服务进入 graceful shutdown。
|
||||
6. 等待 systemd active 和 Docker health 恢复。
|
||||
7. 恢复该实例原 CLB 权重。
|
||||
8. 等待 `stabilize_seconds` 后进入下一台实例。
|
||||
|
||||
如果重启或健康检查失败,脚本会停止后续步骤;已经摘流的故障实例保持权重 `0`,避免坏实例重新接流,需要人工确认后再恢复。
|
||||
|
||||
## 用户无感边界
|
||||
|
||||
- 客户端长连接、房间公屏、单聊仍由腾讯云 IM 承接,gateway 重启不会断开 IM。
|
||||
- gateway 是 HTTP 入口,摘流后只影响当前实例的新 HTTP 请求;另一台 gateway 继续接流。
|
||||
- 内部服务通过各自内网 CLB 摘流滚动;gateway 的 gRPC client 已配置短重试,只覆盖瞬时 `UNAVAILABLE`。
|
||||
- `room-service` 已有 owner route/node registry。旧 owner 停机时会释放 lease;异常退出时最多等 `lease_ttl` 到期后其他节点接管。
|
||||
- 正在执行的单个 HTTP/gRPC 请求可能返回失败,客户端必须按现有接口幂等键重试;“无感”指发布期间整体入口不断流、房间 IM 不断连。
|
||||
|
||||
## 禁止项
|
||||
|
||||
- 不要使用 `--no-clb` 做正常发布;它只适合孤立排障。
|
||||
- 不要多个 `room-service` 共用同一个 `node_id`。
|
||||
- 不要把 `room-service.advertise_addr` 写成 CLB 地址。
|
||||
- 不要先 `systemctl restart` 再摘 CLB。
|
||||
- 不要在同机 Java compose 目录里执行本仓库部署命令。
|
||||
@ -1,703 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def load_env_file(path: str) -> None:
|
||||
# hy-app-monitor 现有 .env 可直接复用;这里只注入缺失变量,不覆盖调用方显式 export 的值。
|
||||
if not path:
|
||||
return
|
||||
env_path = Path(path).expanduser().resolve()
|
||||
if not env_path.exists():
|
||||
raise RuntimeError(f"env file not found: {env_path}")
|
||||
for raw_line in env_path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def load_inventory(path: str) -> dict[str, Any]:
|
||||
inventory_path = Path(path).expanduser().resolve()
|
||||
with inventory_path.open("r", encoding="utf-8") as handle:
|
||||
inventory = json.load(handle)
|
||||
validate_inventory(inventory)
|
||||
return inventory
|
||||
|
||||
|
||||
def validate_inventory(inventory: dict[str, Any]) -> None:
|
||||
# 发布脚本只认明确的 host/service 映射,避免误操作同机已有 Java 项目。
|
||||
if not isinstance(inventory.get("hosts"), dict) or not inventory["hosts"]:
|
||||
raise RuntimeError("inventory.hosts is required")
|
||||
if not isinstance(inventory.get("services"), dict) or not inventory["services"]:
|
||||
raise RuntimeError("inventory.services is required")
|
||||
for host_name, host in inventory["hosts"].items():
|
||||
if not str(host.get("instance_id") or "").strip():
|
||||
raise RuntimeError(f"hosts.{host_name}.instance_id is required")
|
||||
if not str(host.get("private_ip") or "").strip():
|
||||
raise RuntimeError(f"hosts.{host_name}.private_ip is required")
|
||||
for service_name, service in inventory["services"].items():
|
||||
for key in ("unit", "container", "env_file", "target_port", "hosts"):
|
||||
if key not in service:
|
||||
raise RuntimeError(f"services.{service_name}.{key} is required")
|
||||
for host_name in service["hosts"]:
|
||||
if host_name not in inventory["hosts"]:
|
||||
raise RuntimeError(f"services.{service_name}.hosts contains unknown host: {host_name}")
|
||||
|
||||
|
||||
def csv_values(text: str) -> list[str]:
|
||||
return [item.strip() for item in str(text or "").split(",") if item.strip()]
|
||||
|
||||
|
||||
def selected_service_names(inventory: dict[str, Any], raw_services: str) -> list[str]:
|
||||
names = csv_values(raw_services)
|
||||
if not names:
|
||||
raise RuntimeError("--services is required")
|
||||
unknown = [name for name in names if name not in inventory["services"]]
|
||||
if unknown:
|
||||
raise RuntimeError(f"unsupported services: {', '.join(unknown)}")
|
||||
return list(dict.fromkeys(names))
|
||||
|
||||
|
||||
def selected_host_names(service: dict[str, Any], raw_hosts: str) -> list[str]:
|
||||
hosts = list(dict.fromkeys(str(item) for item in service.get("hosts") or []))
|
||||
requested = csv_values(raw_hosts)
|
||||
if not requested:
|
||||
return hosts
|
||||
missing = [host for host in requested if host not in hosts]
|
||||
if missing:
|
||||
raise RuntimeError(f"selected hosts are not assigned to this service: {', '.join(missing)}")
|
||||
return requested
|
||||
|
||||
|
||||
def image_overrides(items: list[str]) -> dict[str, str]:
|
||||
overrides: dict[str, str] = {}
|
||||
for item in items:
|
||||
if "=" not in item:
|
||||
raise RuntimeError("--image must use service=image")
|
||||
service_name, image = item.split("=", 1)
|
||||
service_name = service_name.strip()
|
||||
image = image.strip()
|
||||
if not service_name or not image:
|
||||
raise RuntimeError("--image must use non-empty service=image")
|
||||
overrides[service_name] = image
|
||||
return overrides
|
||||
|
||||
|
||||
def render_image(inventory: dict[str, Any], service_name: str, tag: str, overrides: dict[str, str]) -> str:
|
||||
if service_name in overrides:
|
||||
return overrides[service_name]
|
||||
if not tag:
|
||||
raise RuntimeError(f"--tag or --image {service_name}=... is required for deploy")
|
||||
service = inventory["services"][service_name]
|
||||
registry = str(inventory.get("registry") or "").rstrip("/")
|
||||
template = str(service.get("image_template") or "${REGISTRY}/" + service_name + ":${TAG}")
|
||||
return template.replace("${REGISTRY}", registry).replace("${TAG}", tag)
|
||||
|
||||
|
||||
def build_plan(
|
||||
inventory: dict[str, Any],
|
||||
*,
|
||||
action: str,
|
||||
service_names: list[str],
|
||||
raw_hosts: str,
|
||||
tag: str,
|
||||
overrides: dict[str, str],
|
||||
no_clb: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
# 顺序是服务优先、实例串行;多服务发布不会同时摘掉同一组双机实例。
|
||||
plan: list[dict[str, Any]] = []
|
||||
for service_name in service_names:
|
||||
service = inventory["services"][service_name]
|
||||
image = render_image(inventory, service_name, tag, overrides) if action == "deploy" else ""
|
||||
for host_name in selected_host_names(service, raw_hosts):
|
||||
host = inventory["hosts"][host_name]
|
||||
clb = service.get("clb") or {}
|
||||
plan.append(
|
||||
{
|
||||
"action": action,
|
||||
"service": service_name,
|
||||
"host": host_name,
|
||||
"instance_id": host["instance_id"],
|
||||
"private_ip": host["private_ip"],
|
||||
"target_port": int(service["target_port"]),
|
||||
"unit": service["unit"],
|
||||
"container": service["container"],
|
||||
"image": image,
|
||||
"clb_enabled": bool(clb.get("enabled")) and not no_clb,
|
||||
"clb_load_balancer_id": str(clb.get("load_balancer_id") or ""),
|
||||
"clb_listener_ids": list(clb.get("listener_ids") or []),
|
||||
"drain_seconds": int(clb.get("drain_seconds") or 0),
|
||||
}
|
||||
)
|
||||
return plan
|
||||
|
||||
|
||||
def secret_id() -> str:
|
||||
return os.environ.get("TENCENTCLOUD_SECRET_ID") or os.environ.get("TENCENT_SECRET_ID") or ""
|
||||
|
||||
|
||||
def secret_key() -> str:
|
||||
return os.environ.get("TENCENTCLOUD_SECRET_KEY") or os.environ.get("TENCENT_SECRET_KEY") or ""
|
||||
|
||||
|
||||
def region(inventory: dict[str, Any]) -> str:
|
||||
return os.environ.get("TENCENTCLOUD_REGION") or os.environ.get("DEPLOY_REGION") or str(inventory.get("region") or "")
|
||||
|
||||
|
||||
def require_tencent_credentials(inventory: dict[str, Any]) -> tuple[str, str, str]:
|
||||
sid = secret_id()
|
||||
skey = secret_key()
|
||||
reg = region(inventory)
|
||||
missing = []
|
||||
if not sid:
|
||||
missing.append("TENCENTCLOUD_SECRET_ID")
|
||||
if not skey:
|
||||
missing.append("TENCENTCLOUD_SECRET_KEY")
|
||||
if not reg:
|
||||
missing.append("TENCENTCLOUD_REGION")
|
||||
if missing:
|
||||
raise RuntimeError("missing Tencent Cloud credentials: " + ", ".join(missing))
|
||||
return sid, skey, reg
|
||||
|
||||
|
||||
def build_tat_client(inventory: dict[str, Any]) -> Any:
|
||||
sid, skey, reg = require_tencent_credentials(inventory)
|
||||
try:
|
||||
from tencentcloud.common import credential
|
||||
from tencentcloud.common.profile.client_profile import ClientProfile
|
||||
from tencentcloud.common.profile.http_profile import HttpProfile
|
||||
from tencentcloud.tat.v20201028 import tat_client
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise RuntimeError(f"tencentcloud sdk unavailable: {exc}") from exc
|
||||
return tat_client.TatClient(
|
||||
credential.Credential(sid, skey),
|
||||
reg,
|
||||
ClientProfile(httpProfile=HttpProfile(endpoint="tat.tencentcloudapi.com")),
|
||||
)
|
||||
|
||||
|
||||
def build_clb_client(inventory: dict[str, Any]) -> Any:
|
||||
sid, skey, reg = require_tencent_credentials(inventory)
|
||||
try:
|
||||
from tencentcloud.clb.v20180317 import clb_client
|
||||
from tencentcloud.common import credential
|
||||
from tencentcloud.common.profile.client_profile import ClientProfile
|
||||
from tencentcloud.common.profile.http_profile import HttpProfile
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise RuntimeError(f"tencentcloud sdk unavailable: {exc}") from exc
|
||||
return clb_client.ClbClient(
|
||||
credential.Credential(sid, skey),
|
||||
reg,
|
||||
ClientProfile(httpProfile=HttpProfile(endpoint="clb.tencentcloudapi.com")),
|
||||
)
|
||||
|
||||
|
||||
def invoke_tencent(action: Any, label: str, retries: int = 3) -> Any:
|
||||
try:
|
||||
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
|
||||
except Exception: # noqa: BLE001
|
||||
TencentCloudSDKException = Exception # type: ignore[assignment]
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
return action()
|
||||
except TencentCloudSDKException as exc: # type: ignore[misc]
|
||||
last_error = exc
|
||||
if attempt == retries:
|
||||
break
|
||||
time.sleep(2 * attempt)
|
||||
raise RuntimeError(f"{label} failed: {last_error}") from last_error
|
||||
|
||||
|
||||
def decode_tat_output(output: str) -> str:
|
||||
if not output:
|
||||
return ""
|
||||
return base64.b64decode(output).decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def run_tat_shell(
|
||||
client: Any,
|
||||
*,
|
||||
instance_id: str,
|
||||
script: str,
|
||||
command_name: str,
|
||||
timeout_seconds: int,
|
||||
poll_seconds: int,
|
||||
) -> dict[str, Any]:
|
||||
# TAT 是本方案唯一远端入口;脚本不会要求目标机开放 SSH。
|
||||
from tencentcloud.tat.v20201028 import models as tat_models
|
||||
|
||||
request = tat_models.RunCommandRequest()
|
||||
request.from_json_string(
|
||||
json.dumps(
|
||||
{
|
||||
"CommandName": command_name,
|
||||
"CommandType": "SHELL",
|
||||
"Content": base64.b64encode(script.encode("utf-8")).decode("ascii"),
|
||||
"InstanceIds": [instance_id],
|
||||
"Timeout": timeout_seconds,
|
||||
"WorkingDirectory": "/root",
|
||||
}
|
||||
)
|
||||
)
|
||||
response = json.loads(invoke_tencent(lambda: client.RunCommand(request), "RunCommand").to_json_string())
|
||||
invocation_id = response["InvocationId"]
|
||||
deadline = time.time() + timeout_seconds + 60
|
||||
while time.time() < deadline:
|
||||
time.sleep(max(poll_seconds, 1))
|
||||
query = tat_models.DescribeInvocationTasksRequest()
|
||||
query.from_json_string(
|
||||
json.dumps(
|
||||
{
|
||||
"HideOutput": False,
|
||||
"Limit": 50,
|
||||
"Filters": [{"Name": "invocation-id", "Values": [invocation_id]}],
|
||||
}
|
||||
)
|
||||
)
|
||||
payload = json.loads(invoke_tencent(lambda: client.DescribeInvocationTasks(query), "DescribeInvocationTasks").to_json_string())
|
||||
for task in payload.get("InvocationTaskSet") or []:
|
||||
if str(task.get("InstanceId") or "") != instance_id:
|
||||
continue
|
||||
status = str(task.get("TaskStatus") or "")
|
||||
if status in {"SUCCESS", "FAILED", "TIMEOUT", "CANCELLED", "START_FAILED"}:
|
||||
return {
|
||||
"status": status,
|
||||
"output": decode_tat_output(str((task.get("TaskResult") or {}).get("Output") or "")),
|
||||
}
|
||||
return {"status": "TIMEOUT", "output": ""}
|
||||
|
||||
|
||||
def call_clb(client: Any, method_name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
from tencentcloud.clb.v20180317 import models as clb_models
|
||||
|
||||
request_class = getattr(clb_models, f"{method_name}Request")
|
||||
request = request_class()
|
||||
request.from_json_string(json.dumps(payload))
|
||||
response = invoke_tencent(lambda: getattr(client, method_name)(request), method_name)
|
||||
decoded = json.loads(response.to_json_string())
|
||||
if isinstance(decoded.get("Response"), dict):
|
||||
return dict(decoded["Response"])
|
||||
return dict(decoded)
|
||||
|
||||
|
||||
def wait_clb_task(client: Any, task_id: str, *, timeout_seconds: int, poll_seconds: int) -> None:
|
||||
# ModifyTargetWeight 是异步任务,必须轮询到成功后才能重启实例。
|
||||
deadline = time.time() + max(timeout_seconds, 1)
|
||||
while time.time() < deadline:
|
||||
payload = call_clb(client, "DescribeTaskStatus", {"TaskId": task_id})
|
||||
status = int(payload.get("Status") if payload.get("Status") is not None else 2)
|
||||
if status == 0:
|
||||
return
|
||||
if status == 1:
|
||||
raise RuntimeError(f"CLB task failed: {payload.get('Message') or task_id}")
|
||||
time.sleep(max(poll_seconds, 1))
|
||||
raise RuntimeError(f"CLB task timed out: {task_id}")
|
||||
|
||||
|
||||
def target_matches(step: dict[str, Any], target: dict[str, Any]) -> bool:
|
||||
instance_id = str(target.get("InstanceId") or target.get("TargetId") or "").strip()
|
||||
if instance_id and instance_id != step["instance_id"]:
|
||||
return False
|
||||
target_port = int(target.get("Port") or 0)
|
||||
if target_port and target_port != int(step["target_port"]):
|
||||
return False
|
||||
ips = [str(item or "").strip() for item in (target.get("PrivateIpAddresses") or [target.get("IP")]) if str(item or "").strip()]
|
||||
if ips and step["private_ip"] not in ips:
|
||||
return False
|
||||
return bool(instance_id or ips)
|
||||
|
||||
|
||||
def binding_key(binding: dict[str, Any]) -> str:
|
||||
return "|".join(
|
||||
[
|
||||
str(binding.get("listener_id") or ""),
|
||||
str(binding.get("location_id") or ""),
|
||||
str(binding.get("instance_id") or ""),
|
||||
str(int(binding.get("port") or 0)),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def collect_bindings(step: dict[str, Any], listeners: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
bindings: dict[str, dict[str, Any]] = {}
|
||||
for listener in listeners:
|
||||
listener_id = str(listener.get("ListenerId") or "").strip()
|
||||
for target in listener.get("Targets") or []:
|
||||
if target_matches(step, target):
|
||||
binding = make_binding(step, listener_id, "", target)
|
||||
bindings[binding_key(binding)] = binding
|
||||
for rule in listener.get("Rules") or []:
|
||||
location_id = str(rule.get("LocationId") or "").strip()
|
||||
for target in rule.get("Targets") or []:
|
||||
if target_matches(step, target):
|
||||
binding = make_binding(step, listener_id, location_id, target)
|
||||
bindings[binding_key(binding)] = binding
|
||||
return list(bindings.values())
|
||||
|
||||
|
||||
def make_binding(step: dict[str, Any], listener_id: str, location_id: str, target: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"listener_id": listener_id,
|
||||
"location_id": location_id,
|
||||
"instance_id": str(target.get("InstanceId") or target.get("TargetId") or step["instance_id"]).strip(),
|
||||
"port": int(target.get("Port") or step["target_port"]),
|
||||
"weight": int(target.get("Weight") or 0),
|
||||
}
|
||||
|
||||
|
||||
def prepare_clb_snapshot(client: Any, step: dict[str, Any]) -> dict[str, Any]:
|
||||
if should_auto_discover_clb(step):
|
||||
return discover_clb_snapshot(client, step)
|
||||
payload = call_clb(
|
||||
client,
|
||||
"DescribeTargets",
|
||||
{
|
||||
"LoadBalancerId": step["clb_load_balancer_id"],
|
||||
"ListenerIds": step["clb_listener_ids"],
|
||||
},
|
||||
)
|
||||
bindings = collect_bindings(step, list(payload.get("Listeners") or []))
|
||||
if not bindings:
|
||||
return discover_clb_snapshot(client, step)
|
||||
return {"step": step, "bindings": bindings}
|
||||
|
||||
|
||||
def should_auto_discover_clb(step: dict[str, Any]) -> bool:
|
||||
load_balancer_id = str(step.get("clb_load_balancer_id") or "").strip()
|
||||
listener_ids = [str(item or "").strip() for item in step.get("clb_listener_ids") or []]
|
||||
if not load_balancer_id or "REPLACE" in load_balancer_id or load_balancer_id.upper() == "AUTO":
|
||||
return True
|
||||
return any((not item) or "REPLACE" in item or item.upper() == "AUTO" for item in listener_ids)
|
||||
|
||||
|
||||
def discover_load_balancers_for_backend(client: Any, step: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
# 腾讯云 CLB 支持按后端私网 IP 反查负载均衡,避免手填内网 CLB ID。
|
||||
offset = 0
|
||||
found: list[dict[str, Any]] = []
|
||||
while True:
|
||||
payload = call_clb(
|
||||
client,
|
||||
"DescribeLoadBalancers",
|
||||
{
|
||||
"BackendPrivateIps": [step["private_ip"]],
|
||||
"WithRs": 1,
|
||||
"Limit": 100,
|
||||
"Offset": offset,
|
||||
},
|
||||
)
|
||||
items = list(payload.get("LoadBalancerSet") or [])
|
||||
found.extend(items)
|
||||
total = int(payload.get("TotalCount") or len(found))
|
||||
if len(found) >= total or not items:
|
||||
break
|
||||
offset += len(items)
|
||||
return found
|
||||
|
||||
|
||||
def discover_clb_snapshot(client: Any, step: dict[str, Any]) -> dict[str, Any]:
|
||||
load_balancers = discover_load_balancers_for_backend(client, step)
|
||||
matched: list[dict[str, Any]] = []
|
||||
for load_balancer in load_balancers:
|
||||
load_balancer_id = str(load_balancer.get("LoadBalancerId") or "").strip()
|
||||
if not load_balancer_id:
|
||||
continue
|
||||
payload = call_clb(client, "DescribeTargets", {"LoadBalancerId": load_balancer_id})
|
||||
bindings = collect_bindings(step, list(payload.get("Listeners") or []))
|
||||
if bindings:
|
||||
matched.append({"load_balancer_id": load_balancer_id, "bindings": bindings})
|
||||
|
||||
if not matched:
|
||||
raise RuntimeError(f"CLB target not found for {step['service']} on {step['host']} {step['private_ip']}:{step['target_port']}")
|
||||
if len(matched) > 1:
|
||||
summary = ", ".join(item["load_balancer_id"] for item in matched)
|
||||
raise RuntimeError(f"multiple CLB bindings discovered for {step['service']} on {step['host']}: {summary}")
|
||||
|
||||
discovered = matched[0]
|
||||
step["clb_load_balancer_id"] = discovered["load_balancer_id"]
|
||||
step["clb_listener_ids"] = sorted({binding["listener_id"] for binding in discovered["bindings"] if binding["listener_id"]})
|
||||
return {"step": step, "bindings": discovered["bindings"], "discovered": True}
|
||||
|
||||
|
||||
def modify_binding_weight(client: Any, inventory: dict[str, Any], binding: dict[str, Any], weight: int, load_balancer_id: str) -> None:
|
||||
payload: dict[str, Any] = {
|
||||
"LoadBalancerId": load_balancer_id,
|
||||
"ListenerId": binding["listener_id"],
|
||||
"Targets": [{"InstanceId": binding["instance_id"], "Port": int(binding["port"])}],
|
||||
"Weight": int(weight),
|
||||
}
|
||||
if binding.get("location_id"):
|
||||
payload["LocationId"] = binding["location_id"]
|
||||
response = call_clb(client, "ModifyTargetWeight", payload)
|
||||
wait_clb_task(
|
||||
client,
|
||||
str(response.get("RequestId") or "").strip(),
|
||||
timeout_seconds=int(inventory.get("clb_task_timeout_seconds") or 180),
|
||||
poll_seconds=int(inventory.get("clb_poll_seconds") or 2),
|
||||
)
|
||||
|
||||
|
||||
def set_clb_snapshot_weight(client: Any, inventory: dict[str, Any], snapshot: dict[str, Any], weight: int | None) -> None:
|
||||
step = snapshot["step"]
|
||||
for binding in snapshot["bindings"]:
|
||||
target_weight = int(binding["weight"]) if weight is None else int(weight)
|
||||
modify_binding_weight(client, inventory, binding, target_weight, step["clb_load_balancer_id"])
|
||||
|
||||
|
||||
def remote_restart_script(service: dict[str, Any], *, image: str, health_timeout_seconds: int) -> str:
|
||||
# 远端只改当前 Go 服务的 docker.env 和 systemd unit,避免碰同机 Java compose。
|
||||
lines = [
|
||||
"set -euo pipefail",
|
||||
f"UNIT={shlex.quote(str(service['unit']))}",
|
||||
f"CONTAINER={shlex.quote(str(service['container']))}",
|
||||
f"ENV_FILE={shlex.quote(str(service['env_file']))}",
|
||||
f"HEALTH_TIMEOUT={int(health_timeout_seconds)}",
|
||||
]
|
||||
if image:
|
||||
lines.extend(
|
||||
[
|
||||
f"IMAGE={shlex.quote(image)}",
|
||||
'test -f "$ENV_FILE"',
|
||||
'TMP_FILE="$(mktemp)"',
|
||||
'awk -v image="$IMAGE" \'BEGIN{done=0} /^IMAGE=/{print "IMAGE=" image; done=1; next} {print} END{if(!done) print "IMAGE=" image}\' "$ENV_FILE" > "$TMP_FILE"',
|
||||
'install -m 0640 "$TMP_FILE" "$ENV_FILE"',
|
||||
'rm -f "$TMP_FILE"',
|
||||
'docker pull "$IMAGE"',
|
||||
]
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
'systemctl restart "$UNIT"',
|
||||
'deadline=$((SECONDS + HEALTH_TIMEOUT))',
|
||||
'while ! systemctl is-active --quiet "$UNIT"; do',
|
||||
' if [ "$SECONDS" -ge "$deadline" ]; then',
|
||||
' systemctl status "$UNIT" --no-pager || true',
|
||||
' exit 20',
|
||||
" fi",
|
||||
" sleep 2",
|
||||
"done",
|
||||
'while [ "$SECONDS" -lt "$deadline" ]; do',
|
||||
' state="$(docker inspect --format \'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}\' "$CONTAINER" 2>/dev/null || true)"',
|
||||
' if [ "$state" = "healthy" ] || [ "$state" = "running" ]; then',
|
||||
' docker ps --filter "name=$CONTAINER" --format "{{.Names}} {{.Status}}"',
|
||||
" exit 0",
|
||||
" fi",
|
||||
" sleep 2",
|
||||
"done",
|
||||
'docker inspect "$CONTAINER" 2>/dev/null || true',
|
||||
'journalctl -u "$UNIT" -n 80 --no-pager || true',
|
||||
"exit 21",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def remote_discover_script() -> str:
|
||||
# 只读取 hyapp 命名空间内的 unit/env/container,不扫描或修改 Java 项目。
|
||||
return r"""set -euo pipefail
|
||||
echo "== hyapp units =="
|
||||
systemctl list-units 'hyapp-*.service' --no-pager --all || true
|
||||
echo "== hyapp env files =="
|
||||
if [ -d /etc/hyapp ]; then
|
||||
find /etc/hyapp -maxdepth 2 -name docker.env -print 2>/dev/null | sort | while read -r env_file; do
|
||||
echo "-- $env_file"
|
||||
sed -n 's/^\(IMAGE\|CONTAINER_NAME\|CONFIG_PATH\|STOP_TIMEOUT_SEC\)=/\1=/p' "$env_file" || true
|
||||
done
|
||||
else
|
||||
echo "/etc/hyapp missing"
|
||||
fi
|
||||
echo "== hyapp containers =="
|
||||
docker ps --filter 'name=hyapp-' --format '{{.Names}} {{.Image}} {{.Status}}' || true
|
||||
echo "== listening ports =="
|
||||
ss -lntp 2>/dev/null | awk 'NR==1 || /:13000|:13001|:13004|:13005|:13006|:13008|:13009|:13010|:13100|:13101|:13104|:13105|:13106|:13108|:13109|:13110/' || true
|
||||
"""
|
||||
|
||||
|
||||
def execute_discovery(inventory: dict[str, Any], plan: list[dict[str, Any]], *, dry_run: bool) -> dict[str, Any]:
|
||||
if dry_run:
|
||||
return {"ok": True, "dry_run": True, "steps": plan}
|
||||
tat_client = build_tat_client(inventory)
|
||||
clb_client = build_clb_client(inventory)
|
||||
seen_hosts = list(dict.fromkeys(step["host"] for step in plan))
|
||||
by_host = {name: next(step for step in plan if step["host"] == name) for name in seen_hosts}
|
||||
hosts: list[dict[str, Any]] = []
|
||||
for host_name, seed in by_host.items():
|
||||
try:
|
||||
result = run_tat_shell(
|
||||
tat_client,
|
||||
instance_id=seed["instance_id"],
|
||||
script=remote_discover_script(),
|
||||
command_name=f"hyapp-discover-{host_name}",
|
||||
timeout_seconds=int(inventory.get("tat_timeout_seconds") or 900),
|
||||
poll_seconds=int(inventory.get("tat_poll_seconds") or 2),
|
||||
)
|
||||
hosts.append(
|
||||
{
|
||||
"host": host_name,
|
||||
"instance_id": seed["instance_id"],
|
||||
"private_ip": seed["private_ip"],
|
||||
"status": result["status"],
|
||||
"output": str(result.get("output") or "")[-5000:],
|
||||
}
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
hosts.append(
|
||||
{
|
||||
"host": host_name,
|
||||
"instance_id": seed["instance_id"],
|
||||
"private_ip": seed["private_ip"],
|
||||
"status": "ERROR",
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
|
||||
clb_bindings: list[dict[str, Any]] = []
|
||||
for step in plan:
|
||||
if not step["clb_enabled"]:
|
||||
continue
|
||||
try:
|
||||
snapshot = prepare_clb_snapshot(clb_client, dict(step))
|
||||
clb_bindings.append(
|
||||
{
|
||||
"service": step["service"],
|
||||
"host": step["host"],
|
||||
"load_balancer_id": snapshot["step"]["clb_load_balancer_id"],
|
||||
"listener_ids": snapshot["step"]["clb_listener_ids"],
|
||||
"bindings": snapshot["bindings"],
|
||||
"discovered": bool(snapshot.get("discovered")),
|
||||
}
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
clb_bindings.append(
|
||||
{
|
||||
"service": step["service"],
|
||||
"host": step["host"],
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
return {"ok": True, "hosts": hosts, "clb": clb_bindings}
|
||||
|
||||
|
||||
def execute_plan(inventory: dict[str, Any], plan: list[dict[str, Any]], *, dry_run: bool, assume_yes: bool) -> dict[str, Any]:
|
||||
if dry_run:
|
||||
return {"ok": True, "dry_run": True, "steps": plan}
|
||||
if not assume_yes:
|
||||
raise RuntimeError("real execution requires --yes")
|
||||
|
||||
tat_client = build_tat_client(inventory)
|
||||
clb_client = build_clb_client(inventory) if any(step["clb_enabled"] for step in plan) else None
|
||||
results: list[dict[str, Any]] = []
|
||||
for step in plan:
|
||||
started = time.time()
|
||||
record = dict(step)
|
||||
snapshot: dict[str, Any] | None = None
|
||||
try:
|
||||
if step["clb_enabled"]:
|
||||
snapshot = prepare_clb_snapshot(clb_client, step)
|
||||
set_clb_snapshot_weight(clb_client, inventory, snapshot, 0)
|
||||
if step["drain_seconds"] > 0:
|
||||
time.sleep(step["drain_seconds"])
|
||||
record["clb_drained"] = True
|
||||
|
||||
service_cfg = inventory["services"][step["service"]]
|
||||
script = remote_restart_script(
|
||||
service_cfg,
|
||||
image=step["image"] if step["action"] == "deploy" else "",
|
||||
health_timeout_seconds=int(inventory.get("service_health_timeout_seconds") or 150),
|
||||
)
|
||||
remote = run_tat_shell(
|
||||
tat_client,
|
||||
instance_id=step["instance_id"],
|
||||
script=script,
|
||||
command_name=f"hyapp-{step['action']}-{step['service']}-{step['host']}",
|
||||
timeout_seconds=int(inventory.get("tat_timeout_seconds") or 900),
|
||||
poll_seconds=int(inventory.get("tat_poll_seconds") or 2),
|
||||
)
|
||||
record["remote_status"] = remote["status"]
|
||||
record["remote_output"] = str(remote.get("output") or "")[-2000:]
|
||||
if remote["status"] != "SUCCESS":
|
||||
raise RuntimeError(f"TAT command failed: {remote['status']}")
|
||||
|
||||
if snapshot is not None:
|
||||
# CLB 后端健康由 CLB 自己持续探测;服务本地健康通过后恢复原权重。
|
||||
set_clb_snapshot_weight(clb_client, inventory, snapshot, None)
|
||||
record["clb_restored"] = True
|
||||
|
||||
stabilize = int(inventory.get("stabilize_seconds") or 0)
|
||||
if stabilize > 0:
|
||||
time.sleep(stabilize)
|
||||
record["status"] = "success"
|
||||
record["duration_seconds"] = round(time.time() - started, 2)
|
||||
results.append(record)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
record["status"] = "failed"
|
||||
record["error"] = str(exc)
|
||||
record["duration_seconds"] = round(time.time() - started, 2)
|
||||
results.append(record)
|
||||
return {"ok": False, "steps": results, "error": str(exc)}
|
||||
return {"ok": True, "steps": results}
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Deploy hyapp services through Tencent TAT with optional CLB drain.")
|
||||
parser.add_argument("action", choices=["plan", "discover", "restart", "deploy"])
|
||||
parser.add_argument("--inventory", default="deploy/tencent-tat/inventory.prod.example.json")
|
||||
parser.add_argument("--env-file", default="", help="Optional .env file with Tencent Cloud credentials.")
|
||||
parser.add_argument("--services", required=True, help="Comma separated service names.")
|
||||
parser.add_argument("--hosts", default="", help="Optional comma separated host names for one-node maintenance.")
|
||||
parser.add_argument("--tag", default="", help="Release tag used by image_template during deploy.")
|
||||
parser.add_argument("--image", action="append", default=[], help="Override one image: service=image. Can be repeated.")
|
||||
parser.add_argument("--no-clb", action="store_true", help="Skip CLB drain. Only use for isolated emergency maintenance.")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print the execution plan without calling Tencent APIs.")
|
||||
parser.add_argument("--yes", action="store_true", help="Required for real restart/deploy execution.")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
try:
|
||||
args = parse_args(argv)
|
||||
load_env_file(args.env_file)
|
||||
inventory = load_inventory(args.inventory)
|
||||
service_names = selected_service_names(inventory, args.services)
|
||||
overrides = image_overrides(args.image)
|
||||
action = "restart" if args.action == "plan" else args.action
|
||||
plan = build_plan(
|
||||
inventory,
|
||||
action=action,
|
||||
service_names=service_names,
|
||||
raw_hosts=args.hosts,
|
||||
tag=args.tag,
|
||||
overrides=overrides,
|
||||
no_clb=args.no_clb,
|
||||
)
|
||||
if args.action == "plan":
|
||||
print(json.dumps({"ok": True, "steps": plan}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if args.action == "discover":
|
||||
result = execute_discovery(inventory, plan, dry_run=args.dry_run)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0 if result.get("ok") else 1
|
||||
result = execute_plan(inventory, plan, dry_run=args.dry_run, assume_yes=args.yes)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0 if result.get("ok") else 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@ -1,218 +0,0 @@
|
||||
{
|
||||
"region": "me-saudi-arabia",
|
||||
"registry": "10.2.1.3:18082/hyapp",
|
||||
"tat_timeout_seconds": 900,
|
||||
"tat_poll_seconds": 2,
|
||||
"clb_task_timeout_seconds": 180,
|
||||
"clb_poll_seconds": 2,
|
||||
"service_health_timeout_seconds": 150,
|
||||
"stabilize_seconds": 5,
|
||||
"managed_dependencies": {
|
||||
"mysql": "TencentDB for MySQL private endpoint; this deployment never starts local MySQL.",
|
||||
"redis": "TencentDB for Redis private endpoint; room owner route and login/risk cache use this managed Redis.",
|
||||
"mq": "Tencent Cloud managed MQ; future consumers only receive connection/topic config and are not deployed as local infra."
|
||||
},
|
||||
"deploy_server": {
|
||||
"name": "deploy",
|
||||
"instance_id": "ins-ntmpcd3a",
|
||||
"public_ip": "43.164.75.199",
|
||||
"private_ip": "10.2.1.3",
|
||||
"role": "control plane only: build/push images and call Tencent TAT/CLB APIs; do not attach to business CLB."
|
||||
},
|
||||
"hosts": {
|
||||
"gateway-1": {
|
||||
"instance_id": "ins-aeyaoj40",
|
||||
"private_ip": "10.2.1.8"
|
||||
},
|
||||
"gateway-2": {
|
||||
"instance_id": "ins-irvi5b7u",
|
||||
"private_ip": "10.2.2.17"
|
||||
},
|
||||
"new-app-1": {
|
||||
"instance_id": "ins-fi5zufpk",
|
||||
"private_ip": "10.2.1.4"
|
||||
},
|
||||
"new-app-2": {
|
||||
"instance_id": "ins-hwhaoe8c",
|
||||
"private_ip": "10.2.1.13"
|
||||
},
|
||||
"new-game-1": {
|
||||
"instance_id": "ins-ir229jtw",
|
||||
"private_ip": "10.2.1.10"
|
||||
},
|
||||
"new-game-2": {
|
||||
"instance_id": "ins-460rw7gu",
|
||||
"private_ip": "10.2.1.6"
|
||||
},
|
||||
"new-app-activity-1": {
|
||||
"instance_id": "ins-n8wut6n8",
|
||||
"private_ip": "10.2.1.11"
|
||||
},
|
||||
"new-app-activity-2": {
|
||||
"instance_id": "ins-d1n303sa",
|
||||
"private_ip": "10.2.1.7"
|
||||
},
|
||||
"pay-1": {
|
||||
"instance_id": "ins-ceqfcxd2",
|
||||
"private_ip": "10.2.11.14"
|
||||
},
|
||||
"pay-2": {
|
||||
"instance_id": "ins-awhb74q6",
|
||||
"private_ip": "10.2.12.5"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"gateway-service": {
|
||||
"unit": "hyapp-gateway-service",
|
||||
"container": "hyapp-gateway-service",
|
||||
"env_file": "/etc/hyapp/gateway-service/docker.env",
|
||||
"image_template": "${REGISTRY}/gateway-service:${TAG}",
|
||||
"target_port": 13000,
|
||||
"hosts": [
|
||||
"gateway-1",
|
||||
"gateway-2"
|
||||
],
|
||||
"clb": {
|
||||
"enabled": true,
|
||||
"load_balancer_id": "lb-cbiabr1q",
|
||||
"listener_ids": [
|
||||
"lbl-ktvz8m6k"
|
||||
],
|
||||
"drain_seconds": 10
|
||||
}
|
||||
},
|
||||
"room-service": {
|
||||
"unit": "hyapp-room-service",
|
||||
"container": "hyapp-room-service",
|
||||
"env_file": "/etc/hyapp/room-service/docker.env",
|
||||
"image_template": "${REGISTRY}/room-service:${TAG}",
|
||||
"target_port": 13001,
|
||||
"hosts": [
|
||||
"new-app-1",
|
||||
"new-app-2"
|
||||
],
|
||||
"clb": {
|
||||
"enabled": true,
|
||||
"load_balancer_id": "lb-epvnr4o0",
|
||||
"listener_ids": [
|
||||
"lbl-d2urlzba"
|
||||
],
|
||||
"drain_seconds": 5
|
||||
}
|
||||
},
|
||||
"user-service": {
|
||||
"unit": "hyapp-user-service",
|
||||
"container": "hyapp-user-service",
|
||||
"env_file": "/etc/hyapp/user-service/docker.env",
|
||||
"image_template": "${REGISTRY}/user-service:${TAG}",
|
||||
"target_port": 13005,
|
||||
"hosts": [
|
||||
"new-app-1",
|
||||
"new-app-2"
|
||||
],
|
||||
"clb": {
|
||||
"enabled": true,
|
||||
"load_balancer_id": "lb-epvnr4o0",
|
||||
"listener_ids": [
|
||||
"lbl-honi8z3a"
|
||||
],
|
||||
"drain_seconds": 5
|
||||
}
|
||||
},
|
||||
"wallet-service": {
|
||||
"unit": "hyapp-wallet-service",
|
||||
"container": "hyapp-wallet-service",
|
||||
"env_file": "/etc/hyapp/wallet-service/docker.env",
|
||||
"image_template": "${REGISTRY}/wallet-service:${TAG}",
|
||||
"target_port": 13004,
|
||||
"hosts": [
|
||||
"pay-1",
|
||||
"pay-2"
|
||||
],
|
||||
"clb": {
|
||||
"enabled": true,
|
||||
"load_balancer_id": "lb-4f5xi6p0",
|
||||
"listener_ids": [
|
||||
"lbl-9wi5mvu4"
|
||||
],
|
||||
"drain_seconds": 5
|
||||
}
|
||||
},
|
||||
"activity-service": {
|
||||
"unit": "hyapp-activity-service",
|
||||
"container": "hyapp-activity-service",
|
||||
"env_file": "/etc/hyapp/activity-service/docker.env",
|
||||
"image_template": "${REGISTRY}/activity-service:${TAG}",
|
||||
"target_port": 13006,
|
||||
"hosts": [
|
||||
"new-app-activity-1",
|
||||
"new-app-activity-2"
|
||||
],
|
||||
"clb": {
|
||||
"enabled": true,
|
||||
"load_balancer_id": "lb-epvnr4o0",
|
||||
"listener_ids": [
|
||||
"lbl-j7oqtfhq"
|
||||
],
|
||||
"drain_seconds": 5
|
||||
}
|
||||
},
|
||||
"notice-service": {
|
||||
"unit": "hyapp-notice-service",
|
||||
"container": "hyapp-notice-service",
|
||||
"env_file": "/etc/hyapp/notice-service/docker.env",
|
||||
"image_template": "${REGISTRY}/notice-service:${TAG}",
|
||||
"target_port": 13009,
|
||||
"hosts": [
|
||||
"new-app-activity-1",
|
||||
"new-app-activity-2"
|
||||
],
|
||||
"clb": {
|
||||
"enabled": true,
|
||||
"load_balancer_id": "lb-epvnr4o0",
|
||||
"listener_ids": [
|
||||
"lbl-aux9xsjq"
|
||||
],
|
||||
"drain_seconds": 5
|
||||
}
|
||||
},
|
||||
"game-service": {
|
||||
"unit": "hyapp-game-service",
|
||||
"container": "hyapp-game-service",
|
||||
"env_file": "/etc/hyapp/game-service/docker.env",
|
||||
"image_template": "${REGISTRY}/game-service:${TAG}",
|
||||
"target_port": 13008,
|
||||
"hosts": [
|
||||
"new-game-1",
|
||||
"new-game-2"
|
||||
],
|
||||
"clb": {
|
||||
"enabled": true,
|
||||
"load_balancer_id": "lb-epvnr4o0",
|
||||
"listener_ids": [
|
||||
"lbl-ku138b4y"
|
||||
],
|
||||
"drain_seconds": 5
|
||||
}
|
||||
},
|
||||
"statistics-service": {
|
||||
"unit": "hyapp-statistics-service",
|
||||
"container": "hyapp-statistics-service",
|
||||
"env_file": "/etc/hyapp/statistics-service/docker.env",
|
||||
"image_template": "${REGISTRY}/statistics-service:${TAG}",
|
||||
"target_port": 13010,
|
||||
"hosts": [
|
||||
"new-app-1",
|
||||
"new-app-2"
|
||||
],
|
||||
"clb": {
|
||||
"enabled": true,
|
||||
"load_balancer_id": "lb-epvnr4o0",
|
||||
"listener_ids": [
|
||||
"lbl-a0myhr6q"
|
||||
],
|
||||
"drain_seconds": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,112 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from deploy.tencent_tat import hyapp_tat_deploy as deploy
|
||||
|
||||
|
||||
class TatDeployPlanTests(unittest.TestCase):
|
||||
def load_inventory(self) -> dict:
|
||||
return deploy.load_inventory("deploy/tencent-tat/inventory.prod.example.json")
|
||||
|
||||
def test_inventory_contains_current_cvm_topology(self) -> None:
|
||||
inventory = self.load_inventory()
|
||||
self.assertEqual(inventory["hosts"]["gateway-1"]["instance_id"], "ins-aeyaoj40")
|
||||
self.assertEqual(inventory["hosts"]["gateway-2"]["private_ip"], "10.2.2.17")
|
||||
self.assertEqual(inventory["services"]["wallet-service"]["hosts"], ["pay-1", "pay-2"])
|
||||
self.assertEqual(inventory["services"]["game-service"]["target_port"], 13008)
|
||||
self.assertEqual(inventory["services"]["notice-service"]["hosts"], ["new-app-activity-1", "new-app-activity-2"])
|
||||
self.assertEqual(inventory["services"]["notice-service"]["target_port"], 13009)
|
||||
self.assertIn("TencentDB for MySQL", inventory["managed_dependencies"]["mysql"])
|
||||
|
||||
def test_plan_is_service_first_and_keeps_clb_enabled(self) -> None:
|
||||
inventory = self.load_inventory()
|
||||
plan = deploy.build_plan(
|
||||
inventory,
|
||||
action="deploy",
|
||||
service_names=["gateway-service", "wallet-service"],
|
||||
raw_hosts="",
|
||||
tag="20260513",
|
||||
overrides={},
|
||||
no_clb=False,
|
||||
)
|
||||
self.assertEqual(
|
||||
[(step["service"], step["host"]) for step in plan],
|
||||
[
|
||||
("gateway-service", "gateway-1"),
|
||||
("gateway-service", "gateway-2"),
|
||||
("wallet-service", "pay-1"),
|
||||
("wallet-service", "pay-2"),
|
||||
],
|
||||
)
|
||||
self.assertTrue(all(step["clb_enabled"] for step in plan))
|
||||
self.assertEqual(plan[0]["image"], "10.2.1.3:18082/hyapp/gateway-service:20260513")
|
||||
|
||||
def test_host_filter_must_belong_to_service(self) -> None:
|
||||
inventory = self.load_inventory()
|
||||
with self.assertRaisesRegex(RuntimeError, "not assigned"):
|
||||
deploy.build_plan(
|
||||
inventory,
|
||||
action="restart",
|
||||
service_names=["gateway-service"],
|
||||
raw_hosts="pay-1",
|
||||
tag="",
|
||||
overrides={},
|
||||
no_clb=False,
|
||||
)
|
||||
|
||||
def test_remote_deploy_script_only_touches_hyapp_unit_and_env(self) -> None:
|
||||
inventory = self.load_inventory()
|
||||
service = inventory["services"]["gateway-service"]
|
||||
script = deploy.remote_restart_script(service, image="registry/gateway:tag", health_timeout_seconds=30)
|
||||
self.assertIn("systemctl restart \"$UNIT\"", script)
|
||||
self.assertIn("ENV_FILE=/etc/hyapp/gateway-service/docker.env", script)
|
||||
self.assertIn("docker pull \"$IMAGE\"", script)
|
||||
self.assertNotIn("docker compose", script)
|
||||
self.assertNotIn("java", script.lower())
|
||||
|
||||
def test_discover_plan_does_not_require_image_tag(self) -> None:
|
||||
inventory = self.load_inventory()
|
||||
plan = deploy.build_plan(
|
||||
inventory,
|
||||
action="discover",
|
||||
service_names=["gateway-service"],
|
||||
raw_hosts="",
|
||||
tag="",
|
||||
overrides={},
|
||||
no_clb=False,
|
||||
)
|
||||
self.assertEqual([step["action"] for step in plan], ["discover", "discover"])
|
||||
self.assertEqual([step["image"] for step in plan], ["", ""])
|
||||
|
||||
def test_real_clb_ids_disable_auto_discovery_but_placeholders_enable_it(self) -> None:
|
||||
inventory = self.load_inventory()
|
||||
step = deploy.build_plan(
|
||||
inventory,
|
||||
action="restart",
|
||||
service_names=["gateway-service"],
|
||||
raw_hosts="gateway-1",
|
||||
tag="",
|
||||
overrides={},
|
||||
no_clb=False,
|
||||
)[0]
|
||||
self.assertFalse(deploy.should_auto_discover_clb(step))
|
||||
|
||||
step["clb_load_balancer_id"] = "lb-REPLACE_PUBLIC_GATEWAY"
|
||||
self.assertTrue(deploy.should_auto_discover_clb(step))
|
||||
|
||||
def test_validate_inventory_rejects_unknown_service_host(self) -> None:
|
||||
inventory = self.load_inventory()
|
||||
inventory["services"]["gateway-service"]["hosts"] = ["missing-host"]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "inventory.json"
|
||||
path.write_text(json.dumps(inventory), encoding="utf-8")
|
||||
with self.assertRaisesRegex(RuntimeError, "unknown host"):
|
||||
deploy.load_inventory(str(path))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -1,124 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
CONFIG_FILENAMES = ("config.yaml", "config.docker.yaml", "config.tencent.example.yaml")
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
raise RuntimeError(f"config file missing: {path}")
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError(f"config file must be a mapping: {path}")
|
||||
return data
|
||||
|
||||
|
||||
def load_inventory(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
raise RuntimeError(f"inventory missing: {path}")
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data.get("services"), dict):
|
||||
raise RuntimeError("inventory.services is required")
|
||||
return data
|
||||
|
||||
|
||||
def mapping_paths(value: Any, prefix: str = "") -> set[str]:
|
||||
# 只比较 YAML object key,不比较值;密钥和环境差异不会被打印或误判。
|
||||
if not isinstance(value, dict):
|
||||
return set()
|
||||
paths: set[str] = set()
|
||||
for key, child in value.items():
|
||||
current = f"{prefix}.{key}" if prefix else str(key)
|
||||
paths.add(current)
|
||||
paths.update(mapping_paths(child, current))
|
||||
return paths
|
||||
|
||||
|
||||
def selected_service_names(inventory: dict[str, Any], raw_services: str) -> list[str]:
|
||||
names = [item.strip() for item in raw_services.split(",") if item.strip()]
|
||||
if not names:
|
||||
return sorted(str(name) for name in inventory["services"].keys())
|
||||
unknown = [name for name in names if name not in inventory["services"]]
|
||||
if unknown:
|
||||
raise RuntimeError("unknown services: " + ", ".join(unknown))
|
||||
return list(dict.fromkeys(names))
|
||||
|
||||
|
||||
def selected_hosts(service: dict[str, Any], raw_hosts: str) -> list[str]:
|
||||
hosts = [str(item) for item in service.get("hosts") or []]
|
||||
requested = [item.strip() for item in raw_hosts.split(",") if item.strip()]
|
||||
if not requested:
|
||||
return hosts
|
||||
invalid = [host for host in requested if host not in hosts]
|
||||
if invalid:
|
||||
raise RuntimeError("selected hosts are not assigned to this service: " + ", ".join(invalid))
|
||||
return requested
|
||||
|
||||
|
||||
def validate_service_templates(service_name: str) -> list[str]:
|
||||
errors: list[str] = []
|
||||
config_dir = Path("services") / service_name / "configs"
|
||||
configs = {name: load_yaml(config_dir / name) for name in CONFIG_FILENAMES}
|
||||
contract_paths = mapping_paths(configs["config.tencent.example.yaml"])
|
||||
for filename in ("config.yaml", "config.docker.yaml"):
|
||||
missing = sorted(mapping_paths(configs[filename]) - contract_paths)
|
||||
if missing:
|
||||
errors.append(
|
||||
f"{service_name}: {filename} contains keys missing from config.tencent.example.yaml: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def validate_rendered_config(service_name: str, host_name: str, rendered_dir: Path) -> list[str]:
|
||||
contract = load_yaml(Path("services") / service_name / "configs" / "config.tencent.example.yaml")
|
||||
rendered_path = rendered_dir / host_name / service_name / "config.yaml"
|
||||
rendered = load_yaml(rendered_path)
|
||||
missing = sorted(mapping_paths(contract) - mapping_paths(rendered))
|
||||
if not missing:
|
||||
return []
|
||||
return [
|
||||
f"{service_name}@{host_name}: rendered config missing keys required by config.tencent.example.yaml: "
|
||||
+ ", ".join(missing)
|
||||
]
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate service config contracts before TAT deployment.")
|
||||
parser.add_argument("--inventory", default="deploy/tencent-tat/inventory.prod.example.json")
|
||||
parser.add_argument("--rendered-dir", default="", help="Directory with <host>/<service>/config.yaml files.")
|
||||
parser.add_argument("--services", default="", help="Comma separated service names. Defaults to all inventory services.")
|
||||
parser.add_argument("--hosts", default="", help="Optional comma separated hosts assigned to the selected service(s).")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
inventory = load_inventory(Path(args.inventory))
|
||||
rendered_dir = Path(args.rendered_dir).expanduser().resolve() if args.rendered_dir else None
|
||||
errors: list[str] = []
|
||||
for service_name in selected_service_names(inventory, args.services):
|
||||
errors.extend(validate_service_templates(service_name))
|
||||
if rendered_dir is None:
|
||||
continue
|
||||
for host_name in selected_hosts(inventory["services"][service_name], args.hosts):
|
||||
errors.extend(validate_rendered_config(service_name, host_name, rendered_dir))
|
||||
if errors:
|
||||
print(json.dumps({"ok": False, "errors": errors}, ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps({"ok": True}, ensure_ascii=False))
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@ -1 +0,0 @@
|
||||
# Package marker for Tencent TAT deployment helper tests.
|
||||
@ -1,16 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_SOURCE = Path(__file__).resolve().parents[1] / "tencent-tat" / "hyapp_tat_deploy.py"
|
||||
_SPEC = importlib.util.spec_from_file_location("_hyapp_tat_deploy_impl", _SOURCE)
|
||||
if _SPEC is None or _SPEC.loader is None:
|
||||
raise ImportError(f"cannot load deployment helper: {_SOURCE}")
|
||||
_MODULE = importlib.util.module_from_spec(_SPEC)
|
||||
_SPEC.loader.exec_module(_MODULE)
|
||||
|
||||
for _name, _value in vars(_MODULE).items():
|
||||
if not _name.startswith("__"):
|
||||
globals()[_name] = _value
|
||||
@ -26,7 +26,7 @@ services:
|
||||
- "19009:10909"
|
||||
- "19011:10911"
|
||||
volumes:
|
||||
- ./deploy/rocketmq/broker.conf:/home/rocketmq/broker.conf:ro
|
||||
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/rocketmq/broker.conf:/home/rocketmq/broker.conf:ro
|
||||
- rocketmq-store:/home/rocketmq/store
|
||||
depends_on:
|
||||
- rocketmq-namesrv
|
||||
@ -284,15 +284,15 @@ services:
|
||||
- ./services/user-service/deploy/mysql/initdb/001_user_service.sql:/docker-entrypoint-initdb.d/002_user_service.sql:ro
|
||||
- ./services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql:/docker-entrypoint-initdb.d/003_wallet_service.sql:ro
|
||||
- ./services/activity-service/deploy/mysql/initdb/001_activity_service.sql:/docker-entrypoint-initdb.d/004_activity_service.sql:ro
|
||||
- ./deploy/mysql/initdb/005_utf8mb4_chinese_support.sql:/docker-entrypoint-initdb.d/005_utf8mb4_chinese_support.sql:ro
|
||||
- ./deploy/mysql/initdb/006_admin_database.sql:/docker-entrypoint-initdb.d/006_admin_database.sql:ro
|
||||
- ./deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql:/docker-entrypoint-initdb.d/007_resource_group_wallet_asset_items.sql:ro
|
||||
- ./deploy/mysql/initdb/008_remove_taiwan_country.sql:/docker-entrypoint-initdb.d/008_remove_taiwan_country.sql:ro
|
||||
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/005_utf8mb4_chinese_support.sql:/docker-entrypoint-initdb.d/005_utf8mb4_chinese_support.sql:ro
|
||||
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/006_admin_database.sql:/docker-entrypoint-initdb.d/006_admin_database.sql:ro
|
||||
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql:/docker-entrypoint-initdb.d/007_resource_group_wallet_asset_items.sql:ro
|
||||
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/008_remove_taiwan_country.sql:/docker-entrypoint-initdb.d/008_remove_taiwan_country.sql:ro
|
||||
- ./services/cron-service/deploy/mysql/initdb/001_cron_service.sql:/docker-entrypoint-initdb.d/009_cron_service.sql:ro
|
||||
- ./services/game-service/deploy/mysql/initdb/001_game_service.sql:/docker-entrypoint-initdb.d/010_game_service.sql:ro
|
||||
- ./services/notice-service/deploy/mysql/initdb/001_notice_service.sql:/docker-entrypoint-initdb.d/011_notice_service.sql:ro
|
||||
- ./services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql:/docker-entrypoint-initdb.d/012_statistics_service.sql:ro
|
||||
- ./deploy/mysql/initdb/999_local_grants.sql:/docker-entrypoint-initdb.d/999_local_grants.sql:ro
|
||||
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/999_local_grants.sql:/docker-entrypoint-initdb.d/999_local_grants.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"]
|
||||
interval: 5s
|
||||
|
||||
@ -18,7 +18,7 @@ docs/房间Outbox补偿开发.md
|
||||
| Capability | Current Owner | Current Rule |
|
||||
| --- | --- | --- |
|
||||
| IM UserSig | `gateway-service` | `/api/v1/im/usersig` 只签发 `sdk_app_id/user_id/user_sig/expire_at_ms` |
|
||||
| 房间群建群 | `room-service` | `CreateRoom` 写 `RoomCreated` outbox,worker direct 投递或 IM bridge consumer 从 MQ 消费后调用腾讯 IM REST `create_group`,`GroupID = room_id` |
|
||||
| 房间群建群 | `room-service` | `CreateRoom` 返回前同步调用腾讯 IM REST `create_group`,`GroupID = room_id`;`RoomCreated` outbox/IM bridge 后续只做幂等补偿 |
|
||||
| 房间系统消息 | `room-service` | Room Cell 命令成功后写 `room_outbox`,worker direct 投递或 IM bridge consumer 从 MQ 消费后向 `GroupID = room_id` 发 `room_*` 自定义消息 |
|
||||
| IM 入群/发言回调 | `gateway-service` | 当前把所有 `GroupId` 都当作 `room_id`,再回查 `room-service` guard |
|
||||
| 系统/活动 inbox | `activity-service` | backend inbox/fanout,非实时 IM 群 |
|
||||
|
||||
404
docs/flutter对接/创建房间与IM入群Flutter对接.md
Normal file
404
docs/flutter对接/创建房间与IM入群Flutter对接.md
Normal file
@ -0,0 +1,404 @@
|
||||
# 创建房间与 IM 入群 Flutter 对接
|
||||
|
||||
本文描述 Flutter App 从创建语音房到加入腾讯云 IM 房间群的调用顺序。当前服务端已改为:`CreateRoom` 返回前同步创建腾讯 IM 群;创建成功后,`data.room.im_group_id` 对应的腾讯 IM 群已经存在。
|
||||
|
||||
## 接口地址
|
||||
涉及接口:
|
||||
|
||||
| 方法 | 接口 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/im/usersig` | 获取腾讯 IM SDK 登录票据。 |
|
||||
| `POST` | `/api/v1/rooms/create` | 创建房间;成功前同步创建腾讯 IM 群。 |
|
||||
| `POST` | `/api/v1/rooms/join` | 建立 room-service 业务 presence;成功后才调用腾讯 IM `joinGroup`。 |
|
||||
|
||||
## 请求头
|
||||
|
||||
| Header | 必填 | 示例 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Authorization` | 是 | `Bearer <access_token>` | 登录 access token;服务端从 token 读取当前用户 ID。 |
|
||||
| `X-App-Code` | 否 | `lalu` | App 编码;登录态接口最终以 token 内的 `app_code` 为准。 |
|
||||
| `Content-Type` | POST 必填 | `application/json` | JSON 请求体。 |
|
||||
|
||||
## 正确调用顺序
|
||||
|
||||
```text
|
||||
1. App 登录后获取 access_token
|
||||
2. GET /api/v1/im/usersig
|
||||
3. 腾讯 IM SDK login(user_id, user_sig)
|
||||
4. POST /api/v1/rooms/create
|
||||
5. POST /api/v1/rooms/join
|
||||
6. 腾讯 IM SDK joinGroup(join_response.data.im.group_id)
|
||||
7. 成功后进入房间页并启动 heartbeat / RTC
|
||||
```
|
||||
|
||||
关键规则:
|
||||
|
||||
| 规则 | 说明 |
|
||||
| --- | --- |
|
||||
| `CreateRoom` 成功代表 IM 群已创建 | 服务端同步调用腾讯 IM `create_group`,失败时创建接口直接失败,不落房间。 |
|
||||
| `CreateRoom` 后不要直接 `joinGroup` | 腾讯 IM 回调守卫依赖 room-service presence;必须先 `JoinRoom`。 |
|
||||
| `im_group_id` 当前等于 `room_id` | 客户端仍然必须使用接口返回字段,不能硬编码映射规则。 |
|
||||
| `joinGroup` 成功或“已在群内”都按成功处理 | 页面重进、SDK 重连或重复 join 时可能出现已在群内。 |
|
||||
| `command_id` 同一次用户动作重试必须复用 | 创建房间和进房分别使用不同 `command_id`。 |
|
||||
|
||||
## 获取 IM UserSig
|
||||
|
||||
请求:
|
||||
|
||||
```http
|
||||
GET /api/v1/im/usersig
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
```
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_abc",
|
||||
"data": {
|
||||
"sdk_app_id": 20036101,
|
||||
"user_id": "10001",
|
||||
"user_sig": "xxx",
|
||||
"expire_at_ms": 1778007200000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
客户端行为:
|
||||
|
||||
- `user_id` 是腾讯 IM identifier,直接传给腾讯 IM SDK。
|
||||
- `user_sig` 按 `expire_at_ms` 缓存;过期前可以复用。
|
||||
- `UNAUTHORIZED` 或 `PROFILE_REQUIRED` 时不进入房间流程。
|
||||
|
||||
## 创建房间
|
||||
|
||||
请求:
|
||||
|
||||
```http
|
||||
POST /api/v1/rooms/create
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"command_id": "cmd_create_room_10001_1778000000000",
|
||||
"seat_count": 10,
|
||||
"mode": "voice",
|
||||
"room_name": "Live Room",
|
||||
"room_avatar": "https://cdn.example/room.png",
|
||||
"room_description": "welcome"
|
||||
}
|
||||
```
|
||||
|
||||
请求字段:
|
||||
|
||||
| 字段 | 必填 | 类型 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `command_id` | 是 | string | 创建房间动作幂等键;同一次点击超时重试必须复用。 |
|
||||
| `seat_count` | 否 | int | 不传或传 `0` 使用后台默认麦位数;正数必须命中后台启用座位数。 |
|
||||
| `mode` | 是 | string | 当前传 `voice`。 |
|
||||
| `room_name` | 是 | string | 房间标题,最长 128 个字符。 |
|
||||
| `room_avatar` | 否 | string | 房间头像 URL;为空时服务端写默认头像。 |
|
||||
| `room_description` | 否 | string | 房间简介,最长 512 个字符。 |
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_abc",
|
||||
"data": {
|
||||
"result": {
|
||||
"applied": true,
|
||||
"room_version": 1,
|
||||
"server_time_ms": 1778000000123
|
||||
},
|
||||
"room": {
|
||||
"room_id": "lalu_abc123",
|
||||
"im_group_id": "lalu_abc123",
|
||||
"room_short_id": "10001",
|
||||
"title": "Live Room",
|
||||
"cover_url": "https://cdn.example/room.png",
|
||||
"description": "welcome",
|
||||
"owner_user_id": "10001",
|
||||
"mode": "voice",
|
||||
"status": "active",
|
||||
"chat_enabled": true,
|
||||
"locked": false,
|
||||
"heat": 0,
|
||||
"online_count": 1,
|
||||
"seat_count": 10,
|
||||
"occupied_seat_count": 0,
|
||||
"visible_region_id": 1001,
|
||||
"version": 1
|
||||
},
|
||||
"seats": [
|
||||
{
|
||||
"seat_no": 1,
|
||||
"locked": false
|
||||
}
|
||||
],
|
||||
"im": {
|
||||
"group_id": "lalu_abc123",
|
||||
"need_join_group": true
|
||||
},
|
||||
"server_time_ms": 1778000000123
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `data.room.room_id` | 房间 ID;后续 JoinRoom、heartbeat、RTC 都使用它。 |
|
||||
| `data.room.im_group_id` | 腾讯 IM 房间群 ID;创建成功时群已存在。 |
|
||||
| `data.im.group_id` | 同一个 IM 群 ID;客户端最终以 JoinRoom 响应里的 `data.im.group_id` 入群。 |
|
||||
| `data.result.applied` | `true` 表示本次创建写入;同一 `command_id` 重试可能为 `false`。 |
|
||||
|
||||
创建失败处理:
|
||||
|
||||
| code | HTTP | 客户端处理 |
|
||||
| --- | --- | --- |
|
||||
| `INVALID_ARGUMENT` | 400 | 检查必填字段、座位数、标题长度。 |
|
||||
| `UNAUTHORIZED` | 401 | 回登录。 |
|
||||
| `PROFILE_REQUIRED` | 403 | 引导补全资料。 |
|
||||
| `CONFLICT` | 409 | 当前用户已经有房间;调用 `/api/v1/rooms/me` 恢复已有房间。 |
|
||||
| `UPSTREAM_ERROR` | 502 | 可能是腾讯 IM 建群失败或 room-service 不可用;不要进入房间页,可提示稍后重试。 |
|
||||
|
||||
## JoinRoom 后加入 IM 群
|
||||
|
||||
创建房间成功后,房主仍然要调用 `JoinRoom`。原因是 IM 入群回调会检查 room-service presence;只创建房间不能替代进房动作。
|
||||
|
||||
请求:
|
||||
|
||||
```http
|
||||
POST /api/v1/rooms/join
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"room_id": "lalu_abc123",
|
||||
"command_id": "cmd_join_room_lalu_abc123_10001_1778000001000",
|
||||
"role": "audience"
|
||||
}
|
||||
```
|
||||
|
||||
成功响应只展示 IM 相关字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_def",
|
||||
"data": {
|
||||
"room": {
|
||||
"room_id": "lalu_abc123",
|
||||
"im_group_id": "lalu_abc123",
|
||||
"version": 2
|
||||
},
|
||||
"viewer": {
|
||||
"user_id": "10001",
|
||||
"role": "owner",
|
||||
"joined_at_ms": 1778000001000,
|
||||
"last_seen_at_ms": 1778000001000
|
||||
},
|
||||
"im": {
|
||||
"group_id": "lalu_abc123",
|
||||
"need_join_group": true
|
||||
},
|
||||
"rtc": {
|
||||
"need_token": true,
|
||||
"available": true
|
||||
},
|
||||
"server_time_ms": 1778000001000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
客户端行为:
|
||||
|
||||
| 条件 | 行为 |
|
||||
| --- | --- |
|
||||
| `data.im.need_join_group=true` | 调腾讯 IM SDK `joinGroup(data.im.group_id)`。 |
|
||||
| `joinGroup` 成功 | 开始监听房间群 `TIMCustomElem`,继续 RTC 进房。 |
|
||||
| `joinGroup` 返回已在群内 | 按成功处理。 |
|
||||
| `joinGroup` 返回群不存在 | 视为异常;当前服务端 CreateRoom 已同步建群,记录 `request_id` 和 `group_id` 上报。 |
|
||||
| `joinGroup` 返回被拒绝/无权限 | 重新调用 `JoinRoom`;仍失败则退出房间页。 |
|
||||
|
||||
## Flutter 示例
|
||||
|
||||
下面示例只展示调用边界,项目内可替换为现有 Dio 封装、错误码枚举和腾讯 IM SDK 适配层。
|
||||
|
||||
```dart
|
||||
class VoiceRoomApi {
|
||||
VoiceRoomApi(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
Future<TencentIMUserSig> fetchIMUserSig() async {
|
||||
final resp = await _dio.get('/api/v1/im/usersig');
|
||||
final data = resp.data['data'] as Map<String, dynamic>;
|
||||
return TencentIMUserSig.fromJson(data);
|
||||
}
|
||||
|
||||
Future<CreateRoomResult> createRoom({
|
||||
required String commandId,
|
||||
required String roomName,
|
||||
String mode = 'voice',
|
||||
int seatCount = 0,
|
||||
String roomAvatar = '',
|
||||
String roomDescription = '',
|
||||
}) async {
|
||||
final resp = await _dio.post(
|
||||
'/api/v1/rooms/create',
|
||||
data: {
|
||||
'command_id': commandId,
|
||||
'seat_count': seatCount,
|
||||
'mode': mode,
|
||||
'room_name': roomName,
|
||||
'room_avatar': roomAvatar,
|
||||
'room_description': roomDescription,
|
||||
},
|
||||
);
|
||||
return CreateRoomResult.fromJson(resp.data['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<JoinRoomResult> joinRoom({
|
||||
required String roomId,
|
||||
required String commandId,
|
||||
String role = 'audience',
|
||||
String password = '',
|
||||
}) async {
|
||||
final resp = await _dio.post(
|
||||
'/api/v1/rooms/join',
|
||||
data: {
|
||||
'room_id': roomId,
|
||||
'command_id': commandId,
|
||||
'role': role,
|
||||
if (password.isNotEmpty) 'password': password,
|
||||
},
|
||||
);
|
||||
return JoinRoomResult.fromJson(resp.data['data'] as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
IM 登录和入群:
|
||||
|
||||
```dart
|
||||
class VoiceRoomIMBridge {
|
||||
Future<void> ensureLoggedIn(TencentIMUserSig sig) async {
|
||||
// 根据项目使用的腾讯云 IM Flutter SDK 版本适配。
|
||||
// 常见入参是 sdkAppID、userID、userSig。
|
||||
await TencentIMSDK.login(
|
||||
sdkAppId: sig.sdkAppId,
|
||||
userId: sig.userId,
|
||||
userSig: sig.userSig,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> joinRoomGroup(String groupId) async {
|
||||
try {
|
||||
await TencentIMSDK.joinGroup(groupId: groupId);
|
||||
} on TencentIMException catch (error) {
|
||||
if (_isAlreadyInGroup(error)) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isAlreadyInGroup(TencentIMException error) {
|
||||
// 按项目实际 SDK 错误码收敛,例如已在群内、重复入群等。
|
||||
return error.code == TencentIMErrors.alreadyInGroup;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
创建并进入自己房间:
|
||||
|
||||
```dart
|
||||
Future<void> createAndEnterRoom({
|
||||
required VoiceRoomApi api,
|
||||
required VoiceRoomIMBridge im,
|
||||
required String roomName,
|
||||
}) async {
|
||||
final sig = await api.fetchIMUserSig();
|
||||
await im.ensureLoggedIn(sig);
|
||||
|
||||
final create = await api.createRoom(
|
||||
commandId: newCommandId('create_room'),
|
||||
roomName: roomName,
|
||||
);
|
||||
|
||||
final roomId = create.room.roomId;
|
||||
|
||||
final join = await api.joinRoom(
|
||||
roomId: roomId,
|
||||
commandId: newCommandId('join_room_$roomId'),
|
||||
);
|
||||
|
||||
if (join.im.needJoinGroup) {
|
||||
await im.joinRoomGroup(join.im.groupId);
|
||||
}
|
||||
|
||||
// join.rtc.available=true 时继续 enterRoom;无论是否进 RTC,业务 presence 已经建立。
|
||||
openVoiceRoomPage(join);
|
||||
}
|
||||
```
|
||||
|
||||
`command_id` 生成建议:
|
||||
|
||||
```dart
|
||||
String newCommandId(String action) {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final nonce = randomBase32(8);
|
||||
return 'cmd_${action}_${now}_$nonce';
|
||||
}
|
||||
```
|
||||
|
||||
同一次点击因网络超时重试时,必须复用原 `command_id`;用户重新点击创建或进房时再生成新的 `command_id`。
|
||||
|
||||
## 状态机建议
|
||||
|
||||
| 状态 | 入口 | 成功下一步 | 失败处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| `imLogin` | `/im/usersig` + IM `login` | `createRoom` | 回登录或提示 IM 初始化失败。 |
|
||||
| `createRoom` | `/rooms/create` | `joinRoom` | 不进入房间页;`CONFLICT` 时查询我的房间。 |
|
||||
| `joinRoom` | `/rooms/join` | `joinIMGroup` | 不调用 `joinGroup`;展示进房失败。 |
|
||||
| `joinIMGroup` | IM SDK `joinGroup` | `enterRTC` / 打开房间页 | 已在群内按成功;其他错误退出房间页或重试 JoinRoom。 |
|
||||
| `inRoom` | 房间页 active | heartbeat loop | heartbeat 失败按重连策略处理。 |
|
||||
|
||||
## IM 消息监听
|
||||
|
||||
房间系统消息通过腾讯 IM 群 `TIMCustomElem` 下发:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `MsgType` | `TIMCustomElem` |
|
||||
| `Ext` | `room_system_message` |
|
||||
| `Data` | JSON 字符串,包含 `event_id`、`room_id`、`event_type`、`room_version` 等。 |
|
||||
| `CloudCustomData` | 与 `Data` 相同,便于漫游和排障。 |
|
||||
|
||||
客户端处理规则:
|
||||
|
||||
- 按 `event_id` 去重。
|
||||
- 按 `room_version` 丢弃旧事件。
|
||||
- 收到无法解析或版本断层时,调用 `GET /api/v1/rooms/snapshot?room_id={room_id}` 拉最新快照。
|
||||
|
||||
## 常见错误处理
|
||||
|
||||
| 场景 | 客户端动作 |
|
||||
| --- | --- |
|
||||
| `/rooms/create` 返回 `UPSTREAM_ERROR` | 不调用 JoinRoom,不进入房间页;提示稍后重试。 |
|
||||
| `/rooms/create` 返回 `CONFLICT` | 用户已有房间,调用 `/api/v1/rooms/me` 恢复。 |
|
||||
| `/rooms/join` 返回 `PERMISSION_DENIED` | 可能被 ban 或锁房密码错误;不调用 `joinGroup`。 |
|
||||
| IM `joinGroup` 群不存在 | 当前不应出现;记录 `request_id`、`room_id`、`group_id` 上报。 |
|
||||
| IM 登录票据过期 | 重新调用 `/api/v1/im/usersig` 并 `login`。 |
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
|
||||
房间 IM 被拆成两种通道:
|
||||
|
||||
- 房间群通道:`RoomCreated` 建群、`RoomUserKicked` 移除群成员、`room_user_kicked` 群系统消息由 room-service outbox worker direct 投递,或由 room-service 的 `hyapp-room-im-bridge` consumer group 从 `hyapp_room_outbox` 消费后投递。它依赖房间群生命周期和群成员控制,不进入 notice-service。
|
||||
- 房间群通道:`CreateRoom` 主链路同步建群,`RoomCreated` outbox 只做幂等补偿;`RoomUserKicked` 移除群成员、`room_user_kicked` 群系统消息由 room-service outbox worker direct 投递,或由 room-service 的 `hyapp-room-im-bridge` consumer group 从 `hyapp_room_outbox` 消费后投递。它依赖房间群生命周期和群成员控制,不进入 notice-service。
|
||||
- 用户私有通道:被踢本人可能已经先被移出 IM 群,不能保证收到群消息;因此 notice-service 监听同一条 `RoomUserKicked` 事实,发送 C2C `room_notice` 给 `target_user_id`。
|
||||
|
||||
## 当前目录结构
|
||||
|
||||
@ -1135,6 +1135,7 @@ paths:
|
||||
description: |
|
||||
gateway 从 access token 取当前 `user_id` 写入 room-service `RequestMeta.actor_user_id`,room-service 由该 actor 确定 owner。
|
||||
请求体不接收 `room_id` 或 `owner_user_id`;`command_id` 由客户端按创建动作生成,gateway 生成内部 room_id。
|
||||
room-service 会在返回前同步确保腾讯 IM 房间群存在;创建成功后 `data.room.im_group_id` 可直接用于 JoinRoom 成功后的腾讯 IM SDK joinGroup。
|
||||
同一个登录用户只能作为 owner 创建一个房间;已有房间时返回 409 Conflict。
|
||||
security:
|
||||
- BearerAuth: []
|
||||
@ -1146,7 +1147,7 @@ paths:
|
||||
$ref: "#/definitions/CreateRoomRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 创建成功,`data` 返回房间快照。
|
||||
description: 创建成功,`data` 返回房间首屏基础数据和已存在的腾讯 IM 群 ID。
|
||||
schema:
|
||||
$ref: "#/definitions/CreateRoomEnvelope"
|
||||
"400":
|
||||
@ -4628,11 +4629,26 @@ definitions:
|
||||
format: int64
|
||||
CreateRoomResponse:
|
||||
type: object
|
||||
required:
|
||||
- result
|
||||
- room
|
||||
- seats
|
||||
- im
|
||||
- server_time_ms
|
||||
properties:
|
||||
result:
|
||||
$ref: "#/definitions/CommandResult"
|
||||
room:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
$ref: "#/definitions/RoomInitialData"
|
||||
seats:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/definitions/RoomSeatData"
|
||||
im:
|
||||
$ref: "#/definitions/RoomIMData"
|
||||
server_time_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
JoinRoomResponse:
|
||||
type: object
|
||||
required:
|
||||
|
||||
@ -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` 重复领取只生成一条领取记录。
|
||||
@ -141,7 +141,7 @@ outbox_worker:
|
||||
| Publisher / Consumer | 行为 |
|
||||
| --- | --- |
|
||||
| RocketMQ room outbox publisher | 把 protobuf outbox 包装成统一 `room_outbox_event`,发布到 `hyapp_room_outbox` |
|
||||
| Tencent IM direct publisher | `RoomCreated` 确保群存在;房间系统事件发送腾讯云 IM 群自定义消息 |
|
||||
| Tencent IM direct publisher | `RoomCreated` 对已同步创建的房间群执行幂等 EnsureGroup;房间系统事件发送腾讯云 IM 群自定义消息 |
|
||||
| Activity direct publisher | 本地或未接 MQ 时直接调用 activity-service room event 消费入口 |
|
||||
| Treasure open scheduler | 在 `RoomTreasureCountdownStarted` 后发布到点延迟消息,唤醒 room-service 开箱 |
|
||||
| Activity MQ consumer | 独立 consumer group 消费 `hyapp_room_outbox`,按 `event_id` 幂等推进活动/播报逻辑 |
|
||||
@ -178,7 +178,7 @@ RocketMQ consumer 失败时不修改 `room_outbox`。consumer 应依赖 RocketMQ
|
||||
| `snapshot_ms` | 快照持久化耗时;未触发为 0 |
|
||||
| `projection_ms` | 列表和 presence 投影耗时 |
|
||||
| `activity_ms` | 主链路 activity 同步耗时;当前固定为 0 |
|
||||
| `tencent_im_ms` | 主链路腾讯 IM 同步耗时;当前固定为 0 |
|
||||
| `tencent_im_ms` | 主链路腾讯 IM 同步耗时;CreateRoom 记录同步建群耗时,其余命令当前为 0 |
|
||||
| `activity_async_outbox` | 本命令是否产生异步 activity/outbox 投递 |
|
||||
| `tencent_im_async_outbox` | 本命令是否产生异步 IM/outbox 投递 |
|
||||
|
||||
|
||||
@ -503,7 +503,7 @@ services/user-service/deploy/mysql/initdb/001_user_service.sql
|
||||
services/room-service/deploy/mysql/initdb/001_room_service.sql
|
||||
services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql
|
||||
services/activity-service/deploy/mysql/initdb/001_activity_service.sql
|
||||
deploy/mysql/initdb/006_admin_database.sql
|
||||
../deploy-platform/deploy/mysql/initdb/006_admin_database.sql
|
||||
```
|
||||
|
||||
本地开发库可以整体清空后重放 initdb;线上 schema 迁移必须用专门 migration,不允许依赖容器 init hook。
|
||||
|
||||
@ -55,7 +55,7 @@
|
||||
|
||||
| Feature | Status | Current Evidence | Remaining Work |
|
||||
| --- | --- | --- | --- |
|
||||
| 创建房间 | `DONE` | `CreateRoom` 校验 room_id、actor、mode;`seat_count=0` 使用后台默认值,正数必须命中后台启用配置;写 meta/command/outbox/snapshot 后返回 | 真实 IM 建群由 outbox worker 异步完成,客户端 IM joinGroup 需要容忍短暂 group-not-ready 并重试 |
|
||||
| 创建房间 | `DONE` | `CreateRoom` 校验 room_id、actor、mode;`seat_count=0` 使用后台默认值,正数必须命中后台启用配置;同步确保腾讯 IM 房间群存在后再写 meta/command/outbox/snapshot 并返回 `im_group_id` | 腾讯云真实错误码覆盖和已存在群语义验证 |
|
||||
| 修改房间资料和座位数 | `DONE` | `UpdateRoomProfile` 由房间内 owner 执行;资料和座位数变更经过 Room Cell,缩容要求被删除麦位空闲且未锁 | App 编辑页和真实 IM `room_profile_updated` 展示联调 |
|
||||
| owner/host 收敛 | `DONE` | room-service 从 `actor_user_id` 默认 owner/host,拒绝外部冒用 | 后续转主持需独立命令 |
|
||||
| 进房 presence | `DONE` | `JoinRoom` 写 `OnlineUsers`,gateway 返回首屏房间 DTO、`im_group_id` 和 RTC token | 客户端断线重连自动化测试 |
|
||||
@ -85,9 +85,9 @@
|
||||
| Feature | Status | Current Evidence | Remaining Work |
|
||||
| --- | --- | --- | --- |
|
||||
| 客户端 IM UserSig | `DONE` | gateway 签发,identifier 使用内部 `user_id` 十进制字符串 | 线上密钥管理和过期刷新体验 |
|
||||
| room-service 建群 | `DONE` | `RoomCreated` outbox 由 worker 调腾讯 REST create_group | 腾讯云真实错误码覆盖和已存在群语义验证 |
|
||||
| room-service 建群 | `DONE` | `CreateRoom` 主链路同步调用腾讯 REST `create_group`;`RoomCreated` outbox 仍可幂等补偿 EnsureGroup | 腾讯云真实错误码覆盖和已存在群语义验证 |
|
||||
| 房间系统消息 | `DONE` | `PublishRoomEvent` 发送 TIMCustomElem,事件包含 room_version | 客户端消息解析和版本去重 |
|
||||
| 主链路不阻塞 IM | `DONE` | 命令提交 command log/outbox 后返回;腾讯 IM 只由 outbox worker 异步投递 | 需要监控 pending/retryable/failed 积压 |
|
||||
| 房间系统消息不阻塞命令 | `DONE` | 除 CreateRoom 同步建群外,房间系统消息在命令提交 command log/outbox 后由 outbox worker 异步投递 | 需要监控 pending/retryable/failed 积压 |
|
||||
| outbox 投递 IM | `DONE` | outbox worker publish 成功后标记 `delivered`,失败指数退避,超限进 `failed` | 多消费者场景需要更细粒度 sink 状态 |
|
||||
| 入群前守卫 | `DONE` | `Group.CallbackBeforeApplyJoinGroup` -> `VerifyRoomPresence` | 腾讯云控制台配置和公网回调验收 |
|
||||
| 发言前守卫 | `DONE` | `Group.CallbackBeforeSendMsg` -> `CheckSpeakPermission` | 公屏消息内容审核、频控未做 |
|
||||
|
||||
@ -85,10 +85,11 @@ sequenceDiagram
|
||||
|
||||
C->>G: POST /api/v1/rooms/create(room_name, room_avatar?, room_description?)
|
||||
G->>R: CreateRoom(actor_user_id, room profile)
|
||||
R->>T: create_group(room_id)
|
||||
T-->>R: group ready
|
||||
R->>R: command log + snapshot + outbox
|
||||
R-->>G: RoomSnapshot
|
||||
G-->>C: room envelope
|
||||
R-->>T: outbox worker create_group(room_id)
|
||||
R-->>G: room DTO with im_group_id
|
||||
G-->>C: room envelope with im_group_id
|
||||
|
||||
C->>G: GET /api/v1/im/usersig
|
||||
G-->>C: SDKAppID + user_id + UserSig
|
||||
@ -177,7 +178,7 @@ gateway 不保存房间状态,只做回调鉴权、字段解析、调用 room
|
||||
|
||||
### 1. CreateRoom
|
||||
|
||||
`CreateRoom` 不等待腾讯云 IM 建群。它只提交房间权威状态和 `RoomCreated` outbox;建群由 outbox worker 异步完成。
|
||||
`CreateRoom` 会在返回前同步确保腾讯云 IM 房间群存在。创建房间是低频动作,主链路宁可因为腾讯 IM 建群失败返回错误,也不能让 App 在 `JoinRoom` 成功后遇到 group-not-ready。
|
||||
同一个 `actor_user_id` 只能作为 owner 创建一个房间;加入、主持或管理别人的房间不受该 owner 限制影响。
|
||||
|
||||
主链路顺序:
|
||||
@ -187,17 +188,18 @@ validate request
|
||||
acquire Redis lease
|
||||
check room meta
|
||||
check owner has no room
|
||||
ensure Tencent IM room group
|
||||
initialize RoomState
|
||||
write room meta + command log + snapshot + outbox
|
||||
install Room Cell
|
||||
return snapshot
|
||||
return room DTO with im_group_id
|
||||
```
|
||||
|
||||
如果后续 `EnsureRoomGroup` 失败:
|
||||
如果同步 `EnsureRoomGroup` 失败:
|
||||
|
||||
- `CreateRoom` 已经成功,不回滚房间状态。
|
||||
- outbox worker 按指数退避重试,达到上限进入 `failed`。
|
||||
- 客户端 IM SDK 加群可能短暂遇到 group-not-ready,需要按产品可接受窗口重试。
|
||||
- `CreateRoom` 返回失败,不写房间 meta、command log、snapshot 或 presence 投影。
|
||||
- App 不进入房间页,不调用 `JoinRoom` 或腾讯 IM SDK `joinGroup`。
|
||||
- 如果腾讯 IM 已建群但后续本地持久化失败,后续同 GroupID 建群按幂等成功处理。
|
||||
- 失败原因不应该泄露腾讯云密钥、UserSig 或完整公网响应。
|
||||
|
||||
### 2. JoinRoom
|
||||
@ -331,7 +333,7 @@ room-service 发给腾讯云 IM 群的房间系统消息使用 `TIMCustomElem`
|
||||
| --- | --- |
|
||||
| UserSig 签发 | 已登录用户拿到 `sdk_app_id/user_id/user_sig`,匿名用户失败 |
|
||||
| UserSig 配置缺失 | 返回统一错误,不返回假票据 |
|
||||
| CreateRoom 后建群失败 | 房间状态已提交,outbox 重试;超限后进入 `failed` |
|
||||
| CreateRoom 建群失败 | 返回失败,MySQL 不写 room meta/snapshot/command/outbox |
|
||||
| CreateRoom 成功 | MySQL meta/snapshot/command/outbox 都存在 |
|
||||
| JoinRoom 成功 | presence 增加,返回 RoomSnapshot |
|
||||
| 重复 JoinRoom | 刷新 `last_seen_at_ms`,不重复创建 presence |
|
||||
|
||||
448
docs/语音房房间用户列表前端接入文档.md
Normal file
448
docs/语音房房间用户列表前端接入文档.md
Normal file
@ -0,0 +1,448 @@
|
||||
# 语音房房间用户列表前端接入文档
|
||||
|
||||
## 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": {
|
||||
"user_id": "10001",
|
||||
"username": "Alice",
|
||||
"avatar": "https://example.com/a.png",
|
||||
"display_user_id": "163001",
|
||||
"gender": "female",
|
||||
"age": 26,
|
||||
"country": "US",
|
||||
"country_name": "United States",
|
||||
"country_display_name": "United States",
|
||||
"country_flag": "🇺🇸"
|
||||
}
|
||||
},
|
||||
{
|
||||
"user_id": "10002",
|
||||
"role": "audience",
|
||||
"is_owner": false,
|
||||
"room_role": "admin",
|
||||
"gift_value": 0,
|
||||
"joined_at_ms": 1760000001000,
|
||||
"last_seen_at_ms": 1760000004000,
|
||||
"profile": {
|
||||
"user_id": "10002",
|
||||
"username": "Bob",
|
||||
"avatar": "https://example.com/b.png",
|
||||
"display_user_id": "163002",
|
||||
"gender": "male",
|
||||
"age": 24,
|
||||
"country": "SG",
|
||||
"country_name": "Singapore",
|
||||
"country_display_name": "Singapore",
|
||||
"country_flag": "🇸🇬"
|
||||
}
|
||||
}
|
||||
],
|
||||
"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.user_id` | string | 用户 ID |
|
||||
| `items[].profile.username` | string | 用户昵称 |
|
||||
| `items[].profile.avatar` | string | 用户头像 |
|
||||
| `items[].profile.display_user_id` | string | 展示 ID |
|
||||
| `items[].profile.gender` | string | 用户性别,来自 user-service |
|
||||
| `items[].profile.age` | int | 用户年龄,由后端按生日计算;生日为空或非法时返回 `0` |
|
||||
| `items[].profile.country` | string | 用户国家码,ISO Alpha-2,例如 `US` |
|
||||
| `items[].profile.country_name` | string | 国家名称,来自国家主数据 |
|
||||
| `items[].profile.country_display_name` | string | 国家展示名称,来自国家主数据 |
|
||||
| `items[].profile.country_flag` | string | 国家旗帜 emoji,由国家码生成;国家码为空或非法时返回空字符串 |
|
||||
| `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` 就把管理员标识点亮。
|
||||
- 在线列表只返回 `gender` 和 `age`,不会返回用户生日 `birth`。
|
||||
- `age = 0` 表示用户未填写生日或生日格式异常,前端可隐藏年龄。
|
||||
- 国家展示优先使用 `country_display_name`,没有时可降级使用 `country_name` 或 `country`。
|
||||
- `country_flag` 是 emoji 字符串,前端无需自行按国家码换算。
|
||||
- 用户列表弹窗打开时,可以先拉第一页;滚动到底再拉下一页。
|
||||
- 收到相关 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` 去重。
|
||||
593
docs/语音房房间用户列表后端技术架构设计.md
Normal file
593
docs/语音房房间用户列表后端技术架构设计.md
Normal file
@ -0,0 +1,593 @@
|
||||
# 语音房房间用户列表后端技术架构设计
|
||||
|
||||
## 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 通知客户端刷新。
|
||||
7. 在线用户列表聚合用户性别 `gender` 和年龄 `age`,但不暴露生日 `birth`。
|
||||
|
||||
## 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.user_id` | user-service `BatchGetUsers` |
|
||||
| `profile.username` | user-service `BatchGetUsers` |
|
||||
| `profile.avatar` | user-service `BatchGetUsers` |
|
||||
| `profile.display_user_id` | user-service `BatchGetUsers` |
|
||||
| `profile.gender` | user-service `BatchGetUsers` 的 `gender` |
|
||||
| `profile.age` | gateway 根据 user-service `birth` 按 `yyyy-mm-dd` 计算 |
|
||||
| `profile.country` | user-service `BatchGetUsers` 的 `country` |
|
||||
| `profile.country_name` | user-service `BatchGetUsers` 的 `country_name` |
|
||||
| `profile.country_display_name` | user-service `BatchGetUsers` 的 `country_display_name` |
|
||||
| `profile.country_flag` | gateway 根据 ISO Alpha-2 国家码生成 |
|
||||
|
||||
用户资料边界:
|
||||
|
||||
- `gender`、`birth` 属于 user-service,room-service 不存储、不缓存。
|
||||
- `country`、`country_name`、`country_display_name` 属于 user-service 国家主数据聚合结果,room-service 不存储、不缓存。
|
||||
- HTTP 只返回 `age`,不返回 `birth`。
|
||||
- `birth` 为空、格式非法、未来日期或超过合理年龄上限时,`age` 返回 `0`。
|
||||
- 年龄计算以 gateway 当前 UTC 日期为准,避免客户端时区差异导致展示不一致。
|
||||
- `country_flag` 不依赖 user proto 新字段,由 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/...
|
||||
```
|
||||
@ -7,6 +7,7 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-root}"
|
||||
DEPLOY_PLATFORM_ROOT="${DEPLOY_PLATFORM_ROOT:-${PROJECT_ROOT}/../deploy-platform}"
|
||||
|
||||
cd "${PROJECT_ROOT}"
|
||||
|
||||
@ -15,15 +16,15 @@ SQL_FILES=(
|
||||
"services/user-service/deploy/mysql/initdb/001_user_service.sql"
|
||||
"services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql"
|
||||
"services/activity-service/deploy/mysql/initdb/001_activity_service.sql"
|
||||
"deploy/mysql/initdb/005_utf8mb4_chinese_support.sql"
|
||||
"deploy/mysql/initdb/006_admin_database.sql"
|
||||
"deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql"
|
||||
"deploy/mysql/initdb/008_remove_taiwan_country.sql"
|
||||
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/005_utf8mb4_chinese_support.sql"
|
||||
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/006_admin_database.sql"
|
||||
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql"
|
||||
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/008_remove_taiwan_country.sql"
|
||||
"services/cron-service/deploy/mysql/initdb/001_cron_service.sql"
|
||||
"services/game-service/deploy/mysql/initdb/001_game_service.sql"
|
||||
"services/notice-service/deploy/mysql/initdb/001_notice_service.sql"
|
||||
"services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql"
|
||||
"deploy/mysql/initdb/999_local_grants.sql"
|
||||
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/999_local_grants.sql"
|
||||
)
|
||||
|
||||
# Keep MySQL as the only required dependency for this script. Business services
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -862,6 +862,53 @@ func TestHandleZeeOneAccessTokenIgnoresStaleLaunchSessionGameID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleZeeOneAllowsDebitAndCreditWithSameProviderOrderID(t *testing.T) {
|
||||
secret := "zeeone-test-secret"
|
||||
token := leaderccTestAccessToken(t, "lalu", 123456, "123456", 1700003600)
|
||||
repo := &fakeRepository{
|
||||
platform: gamedomain.Platform{
|
||||
PlatformCode: "zeeone",
|
||||
AdapterType: gamedomain.AdapterZeeOneV1,
|
||||
CallbackSecretCiphertext: secret,
|
||||
AdapterConfigJSON: `{"merchant_id":307715,"platform_id":2032,"uid_mode":"display_user_id"}`,
|
||||
},
|
||||
}
|
||||
wallet := &fakeWallet{balanceAfter: 900}
|
||||
svc := New(Config{}, repo, wallet, &fakeUser{})
|
||||
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
|
||||
|
||||
base := `{"appId":"M307715_P2032","userId":"123456","sessionId":"` + token + `","diff_msg":"settle","game_id":1021,"room_id":"6","change_time_at":1700000000,"order_id":"zee_round_order_1"`
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
diff string
|
||||
wantOp string
|
||||
wantID string
|
||||
wantCoins int64
|
||||
}{
|
||||
{name: "debit", diff: "-500", wantOp: "debit", wantID: "game:zeeone:zee_round_order_1:debit", wantCoins: 500},
|
||||
{name: "credit", diff: "750", wantOp: "credit", wantID: "game:zeeone:zee_round_order_1:credit", wantCoins: 750},
|
||||
} {
|
||||
raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
|
||||
Meta: &gamev1.RequestMeta{RequestId: "req-zeeone-" + item.name, AppCode: "lalu"},
|
||||
PlatformCode: "zeeone",
|
||||
Operation: "change_balance",
|
||||
RawBody: zeeoneTestEnvelope(t, secret, base+`,"currency_diff":`+item.diff+`}`, 1700000000000),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleCallback %s failed: %v", item.name, err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"code":0`) {
|
||||
t.Fatalf("zeeone %s response mismatch: %s", item.name, raw)
|
||||
}
|
||||
if wallet.lastApply.GetCommandId() != item.wantID || wallet.lastApply.GetOpType() != item.wantOp || wallet.lastApply.GetCoinAmount() != item.wantCoins {
|
||||
t.Fatalf("zeeone %s wallet command mismatch: %+v", item.name, wallet.lastApply)
|
||||
}
|
||||
}
|
||||
if wallet.applyCount != 2 {
|
||||
t.Fatalf("debit and credit should both reach wallet, got %d applies", wallet.applyCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBaishunSSTokenUserInfoUpdateAndChangeBalance(t *testing.T) {
|
||||
secret := "baishun-test-app-key"
|
||||
token := "app_access_token_baishun"
|
||||
@ -1058,6 +1105,7 @@ type fakeRepository struct {
|
||||
platform gamedomain.Platform
|
||||
session gamedomain.LaunchSession
|
||||
order gamedomain.GameOrder
|
||||
orders []gamedomain.GameOrder
|
||||
repair gamedomain.RepairOrder
|
||||
levelEvents []gamedomain.LevelEventOutbox
|
||||
deliveredEvents []string
|
||||
@ -1086,10 +1134,20 @@ func (f *fakeRepository) GetLaunchSessionByToken(_ context.Context, _ string, to
|
||||
}
|
||||
func (f *fakeRepository) InsertCallbackLog(context.Context, gamedomain.CallbackLog) error { return nil }
|
||||
func (f *fakeRepository) CreateGameOrder(_ context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) {
|
||||
if f.order.OrderID != "" {
|
||||
return f.order, true, nil
|
||||
for _, existing := range f.orders {
|
||||
if existing.OrderID == order.OrderID {
|
||||
f.order = existing
|
||||
return existing, true, nil
|
||||
}
|
||||
}
|
||||
if f.order.OrderID != "" && len(f.orders) == 0 {
|
||||
if f.order.OrderID == order.OrderID {
|
||||
return f.order, true, nil
|
||||
}
|
||||
f.orders = append(f.orders, f.order)
|
||||
}
|
||||
f.order = order
|
||||
f.orders = append(f.orders, order)
|
||||
return order, false, nil
|
||||
}
|
||||
func (f *fakeRepository) MarkOrderSucceeded(_ context.Context, _ string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error {
|
||||
@ -1098,6 +1156,12 @@ func (f *fakeRepository) MarkOrderSucceeded(_ context.Context, _ string, orderID
|
||||
f.order.WalletTransactionID = walletTransactionID
|
||||
f.order.WalletBalanceAfter = balanceAfter
|
||||
f.order.UpdatedAtMS = nowMs
|
||||
for index := range f.orders {
|
||||
if f.orders[index].OrderID == orderID {
|
||||
f.orders[index] = f.order
|
||||
break
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(f.order.OpType, "debit") && f.order.CoinAmount > 0 && len(f.levelEvents) == 0 {
|
||||
f.levelEvents = append(f.levelEvents, gamedomain.LevelEventOutbox{
|
||||
AppCode: f.order.AppCode,
|
||||
@ -1122,6 +1186,12 @@ func (f *fakeRepository) MarkOrderFailed(_ context.Context, _ string, orderID st
|
||||
f.order.FailureCode = code
|
||||
f.order.FailureMessage = message
|
||||
f.order.UpdatedAtMS = nowMs
|
||||
for index := range f.orders {
|
||||
if f.orders[index].OrderID == orderID {
|
||||
f.orders[index] = f.order
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRepository) CreateRepairOrder(_ context.Context, order gamedomain.RepairOrder) (gamedomain.RepairOrder, bool, error) {
|
||||
@ -1165,11 +1235,13 @@ func (f *fakeRepository) DeleteCatalog(context.Context, string, string) error {
|
||||
|
||||
type fakeWallet struct {
|
||||
lastApply *walletv1.ApplyGameCoinChangeRequest
|
||||
applyCount int
|
||||
balanceAfter int64
|
||||
}
|
||||
|
||||
func (f *fakeWallet) ApplyGameCoinChange(_ context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) {
|
||||
f.lastApply = req
|
||||
f.applyCount++
|
||||
return &walletv1.ApplyGameCoinChangeResponse{WalletTransactionId: "wtx-game-1", BalanceAfter: f.balanceAfter}, nil
|
||||
}
|
||||
|
||||
|
||||
@ -276,7 +276,8 @@ func (s *Service) handleZeeOneChangeBalance(ctx context.Context, app string, con
|
||||
session.GameID = session.ProviderGameID
|
||||
}
|
||||
opType, amount := zeeoneOpType(body.CurrencyDiff)
|
||||
result, err := s.applyYomiCoinChange(ctx, app, req, session, body.OrderID, strconv.FormatInt(body.ChangeTimeAt, 10), opType, amount, body.RoomID, requestHash)
|
||||
providerOrderID := zeeoneCoinChangeProviderOrderID(body.OrderID, opType)
|
||||
result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, strconv.FormatInt(body.ChangeTimeAt, 10), opType, amount, body.RoomID, requestHash)
|
||||
if err != nil {
|
||||
code := zeeoneCodeFromError(err)
|
||||
balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
|
||||
@ -359,6 +360,15 @@ func zeeoneOpType(diff int64) (string, int64) {
|
||||
return "credit", diff
|
||||
}
|
||||
|
||||
func zeeoneCoinChangeProviderOrderID(orderID string, opType string) string {
|
||||
orderID = strings.TrimSpace(orderID)
|
||||
opType = strings.ToLower(strings.TrimSpace(opType))
|
||||
if orderID == "" || opType == "" {
|
||||
return orderID
|
||||
}
|
||||
return orderID + ":" + opType
|
||||
}
|
||||
|
||||
func zeeoneProviderRequestID(reqBody string) string {
|
||||
var body struct {
|
||||
OrderID string `json:"order_id"`
|
||||
|
||||
@ -580,14 +580,17 @@ func (f *fakeUserProfileClient) BatchGetUsers(_ context.Context, req *userv1.Bat
|
||||
users := make(map[int64]*userv1.User, len(req.GetUserIds()))
|
||||
for _, userID := range req.GetUserIds() {
|
||||
users[userID] = &userv1.User{
|
||||
UserId: userID,
|
||||
DisplayUserId: strconv.FormatInt(100000+userID, 10),
|
||||
Username: "user-" + strconv.FormatInt(userID, 10),
|
||||
Gender: "female",
|
||||
Country: "US",
|
||||
Avatar: "https://cdn.example/avatar.png",
|
||||
RegionId: 1001,
|
||||
AppCode: req.GetMeta().GetAppCode(),
|
||||
UserId: userID,
|
||||
DisplayUserId: strconv.FormatInt(100000+userID, 10),
|
||||
Username: "user-" + strconv.FormatInt(userID, 10),
|
||||
Gender: "female",
|
||||
Birth: "2000-01-02",
|
||||
Country: "US",
|
||||
CountryName: "United States",
|
||||
CountryDisplayName: "United States",
|
||||
Avatar: "https://cdn.example/avatar.png",
|
||||
RegionId: 1001,
|
||||
AppCode: req.GetMeta().GetAppCode(),
|
||||
}
|
||||
}
|
||||
return &userv1.BatchGetUsersResponse{Users: users}, nil
|
||||
@ -2228,6 +2231,65 @@ func TestGetRoomDetailIncludesFollowState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRoomOnlineUsersIncludesProfileGenderAndAge(t *testing.T) {
|
||||
queryClient := &fakeRoomQueryClient{onlineResp: &roomv1.ListRoomOnlineUsersResponse{
|
||||
Items: []*roomv1.RoomOnlineUser{
|
||||
{
|
||||
UserId: 42,
|
||||
Role: "audience",
|
||||
RoomRole: "admin",
|
||||
GiftValue: 188800,
|
||||
JoinedAtMs: 1000,
|
||||
LastSeenAtMs: 2000,
|
||||
},
|
||||
},
|
||||
Total: 1,
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
ServerTimeMs: 3000,
|
||||
}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
handler.SetRoomQueryClient(queryClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-online/online-users?page=1&page_size=20&sort=gift_value", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-room-online-users")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if queryClient.lastOnline == nil || queryClient.lastOnline.GetRoomId() != "room-online" || queryClient.lastOnline.GetViewerUserId() != 42 || queryClient.lastOnline.GetSort() != "gift_value" {
|
||||
t.Fatalf("online list request mismatch: %+v", queryClient.lastOnline)
|
||||
}
|
||||
var response httpkit.ResponseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode online users response failed: %v", err)
|
||||
}
|
||||
data, ok := response.Data.(map[string]any)
|
||||
items, itemsOK := data["items"].([]any)
|
||||
if response.Code != httpkit.CodeOK || !ok || !itemsOK || len(items) != 1 {
|
||||
t.Fatalf("online users response mismatch: %+v", response)
|
||||
}
|
||||
first, ok := items[0].(map[string]any)
|
||||
if !ok || first["user_id"] != "42" || first["room_role"] != "admin" || first["gift_value"] != float64(188800) {
|
||||
t.Fatalf("online user item mismatch: %+v", first)
|
||||
}
|
||||
profile, ok := first["profile"].(map[string]any)
|
||||
age, ageOK := profile["age"].(float64)
|
||||
if !ok || profile["gender"] != "female" || !ageOK || age <= 0 {
|
||||
t.Fatalf("online user profile must expose gender and positive age: %+v", first["profile"])
|
||||
}
|
||||
if profile["country"] != "US" || profile["country_name"] != "United States" || profile["country_display_name"] != "United States" || profile["country_flag"] != "🇺🇸" {
|
||||
t.Fatalf("online user profile must expose country code, name, display name, and flag: %+v", profile)
|
||||
}
|
||||
if _, exists := profile["birth"]; exists {
|
||||
t.Fatalf("online user profile must not expose birth: %+v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRoomSnapshotRejectsInvalidRoomIDBeforeGRPC(t *testing.T) {
|
||||
queryClient := &fakeRoomQueryClient{}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/roomid"
|
||||
@ -23,6 +24,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 +456,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)))
|
||||
}
|
||||
|
||||
@ -629,7 +664,11 @@ func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request)
|
||||
RoomDescription: body.RoomDescription,
|
||||
RoomShortId: roomShortID,
|
||||
})
|
||||
httpkit.Write(writer, request, resp, err)
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, createRoomDataFromProto(resp))
|
||||
}
|
||||
|
||||
// updateRoomProfile 把 App 房间资料编辑请求转换为 room-service UpdateRoomProfile 命令。
|
||||
@ -992,10 +1031,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 +1043,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 命令。
|
||||
@ -1181,21 +1233,67 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64)
|
||||
continue
|
||||
}
|
||||
profiles[userID] = roomDisplayProfileData{
|
||||
UserID: formatOptionalUserID(user.GetUserId()),
|
||||
Username: user.GetUsername(),
|
||||
Avatar: user.GetAvatar(),
|
||||
DisplayUserID: user.GetDisplayUserId(),
|
||||
VIP: map[string]any{},
|
||||
Level: map[string]any{},
|
||||
Badges: []map[string]any{},
|
||||
AvatarFrame: map[string]any{},
|
||||
Vehicle: map[string]any{},
|
||||
Charm: 0,
|
||||
UserID: formatOptionalUserID(user.GetUserId()),
|
||||
Username: user.GetUsername(),
|
||||
Avatar: user.GetAvatar(),
|
||||
DisplayUserID: user.GetDisplayUserId(),
|
||||
Gender: strings.TrimSpace(user.GetGender()),
|
||||
Age: roomProfileAgeFromBirth(user.GetBirth(), time.Now().UTC()),
|
||||
Country: strings.ToUpper(strings.TrimSpace(user.GetCountry())),
|
||||
CountryName: strings.TrimSpace(user.GetCountryName()),
|
||||
CountryDisplayName: strings.TrimSpace(user.GetCountryDisplayName()),
|
||||
CountryFlag: roomProfileCountryFlag(user.GetCountry()),
|
||||
VIP: map[string]any{},
|
||||
Level: map[string]any{},
|
||||
Badges: []map[string]any{},
|
||||
AvatarFrame: map[string]any{},
|
||||
Vehicle: map[string]any{},
|
||||
Charm: 0,
|
||||
}
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
func roomProfileAgeFromBirth(birth string, now time.Time) int32 {
|
||||
birth = strings.TrimSpace(birth)
|
||||
if birth == "" {
|
||||
return 0
|
||||
}
|
||||
born, err := time.Parse("2006-01-02", birth)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
now = now.UTC()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
birthdayThisYear := time.Date(now.Year(), born.Month(), born.Day(), 0, 0, 0, 0, time.UTC)
|
||||
age := now.Year() - born.Year()
|
||||
if today.Before(birthdayThisYear) {
|
||||
age--
|
||||
}
|
||||
if age < 0 || age > 150 {
|
||||
return 0
|
||||
}
|
||||
return int32(age)
|
||||
}
|
||||
|
||||
func roomProfileCountryFlag(country string) string {
|
||||
country = strings.ToUpper(strings.TrimSpace(country))
|
||||
if len(country) != 2 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(country)
|
||||
for _, value := range runes {
|
||||
if value < 'A' || value > 'Z' {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
const regionalIndicatorA = 0x1F1E6
|
||||
return string([]rune{
|
||||
regionalIndicatorA + (runes[0] - 'A'),
|
||||
regionalIndicatorA + (runes[1] - 'A'),
|
||||
})
|
||||
}
|
||||
|
||||
func roomGiftRecipientUserIDs(snapshot *roomv1.RoomSnapshot) []int64 {
|
||||
userIDs := make([]int64, 0, len(snapshot.GetMicSeats())+1)
|
||||
for _, seat := range snapshot.GetMicSeats() {
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
package roomapi
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
)
|
||||
|
||||
func TestRoomGiftTabsUsesTypeCodeAsKeyAndTabKeyAsLabel(t *testing.T) {
|
||||
tabs := roomGiftTabs(
|
||||
@ -26,3 +31,75 @@ func TestRoomGiftTabsUsesTypeCodeAsKeyAndTabKeyAsLabel(t *testing.T) {
|
||||
t.Fatalf("lucky tab mismatch: %+v", tabs[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRoomDataIncludesExplicitIMGroupID(t *testing.T) {
|
||||
data := createRoomDataFromProto(&roomv1.CreateRoomResponse{
|
||||
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 1, ServerTimeMs: 1700000000000},
|
||||
Room: &roomv1.RoomSnapshot{
|
||||
RoomId: "room-create-im",
|
||||
OwnerUserId: 1001,
|
||||
Mode: "voice",
|
||||
Status: "active",
|
||||
ChatEnabled: true,
|
||||
Version: 1,
|
||||
RoomShortId: "1001",
|
||||
OnlineUsers: []*roomv1.RoomUser{{UserId: 1001, Role: "owner"}},
|
||||
RoomExt: map[string]string{"title": "Create IM", "cover_url": "cover"},
|
||||
VisibleRegionId: 10,
|
||||
},
|
||||
})
|
||||
|
||||
if data.Room.RoomID != "room-create-im" || data.Room.IMGroupID != "room-create-im" {
|
||||
t.Fatalf("create response room must expose room_id and im_group_id: %+v", data.Room)
|
||||
}
|
||||
if data.IM.GroupID != "room-create-im" || !data.IM.NeedJoinGroup {
|
||||
t.Fatalf("create response must expose joinable im group: %+v", data.IM)
|
||||
}
|
||||
if !data.Result.Applied || data.ServerTimeMS != 1700000000000 {
|
||||
t.Fatalf("create response result mismatch: %+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomProfileAgeFromBirth(t *testing.T) {
|
||||
now := time.Date(2026, time.May, 27, 12, 0, 0, 0, time.UTC)
|
||||
tests := []struct {
|
||||
name string
|
||||
birth string
|
||||
want int32
|
||||
}{
|
||||
{name: "birthday passed", birth: "2000-01-02", want: 26},
|
||||
{name: "birthday pending", birth: "2000-12-31", want: 25},
|
||||
{name: "today birthday", birth: "2000-05-27", want: 26},
|
||||
{name: "invalid", birth: "2000/01/02", want: 0},
|
||||
{name: "future", birth: "2030-01-01", want: 0},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := roomProfileAgeFromBirth(test.birth, now); got != test.want {
|
||||
t.Fatalf("age mismatch: got %d want %d", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomProfileCountryFlag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
country string
|
||||
want string
|
||||
}{
|
||||
{name: "upper code", country: "US", want: "🇺🇸"},
|
||||
{name: "lower code", country: "sg", want: "🇸🇬"},
|
||||
{name: "invalid length", country: "USA", want: ""},
|
||||
{name: "invalid character", country: "U1", want: ""},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := roomProfileCountryFlag(test.country); got != test.want {
|
||||
t.Fatalf("flag mismatch: got %q want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,6 +39,14 @@ type roomListItemData struct {
|
||||
RoomShortID string `json:"room_short_id,omitempty"`
|
||||
}
|
||||
|
||||
type createRoomData struct {
|
||||
Result roomCommandResultData `json:"result"`
|
||||
Room roomInitialData `json:"room"`
|
||||
Seats []roomSeatData `json:"seats"`
|
||||
IM roomIMData `json:"im"`
|
||||
ServerTimeMS int64 `json:"server_time_ms"`
|
||||
}
|
||||
|
||||
type joinRoomData struct {
|
||||
Result roomCommandResultData `json:"result"`
|
||||
Room roomInitialData `json:"room"`
|
||||
@ -76,10 +84,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"`
|
||||
@ -95,16 +114,22 @@ type roomPermissionsData struct {
|
||||
}
|
||||
|
||||
type roomDisplayProfileData struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
VIP map[string]any `json:"vip"`
|
||||
Level map[string]any `json:"level"`
|
||||
Badges []map[string]any `json:"badges"`
|
||||
AvatarFrame map[string]any `json:"avatar_frame"`
|
||||
Vehicle map[string]any `json:"vehicle"`
|
||||
Charm int64 `json:"charm"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
Gender string `json:"gender"`
|
||||
Age int32 `json:"age"`
|
||||
Country string `json:"country"`
|
||||
CountryName string `json:"country_name"`
|
||||
CountryDisplayName string `json:"country_display_name"`
|
||||
CountryFlag string `json:"country_flag"`
|
||||
VIP map[string]any `json:"vip"`
|
||||
Level map[string]any `json:"level"`
|
||||
Badges []map[string]any `json:"badges"`
|
||||
AvatarFrame map[string]any `json:"avatar_frame"`
|
||||
Vehicle map[string]any `json:"vehicle"`
|
||||
Charm int64 `json:"charm"`
|
||||
}
|
||||
|
||||
type roomGiftPanelData struct {
|
||||
@ -303,6 +328,21 @@ func myRoomDataFromProto(resp *roomv1.GetMyRoomResponse) myRoomData {
|
||||
return data
|
||||
}
|
||||
|
||||
func createRoomDataFromProto(resp *roomv1.CreateRoomResponse) createRoomData {
|
||||
if resp == nil {
|
||||
return createRoomData{}
|
||||
}
|
||||
snapshot := resp.GetRoom()
|
||||
roomID := snapshot.GetRoomId()
|
||||
return createRoomData{
|
||||
Result: commandResultDataFromProto(resp.GetResult()),
|
||||
Room: roomInitialRoomDataFromSnapshot(snapshot, roomID),
|
||||
Seats: roomSeatDataFromSnapshot(snapshot),
|
||||
IM: roomIMData{GroupID: roomIMGroupID(roomID), NeedJoinGroup: true},
|
||||
ServerTimeMS: resp.GetResult().GetServerTimeMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func roomTreasureDataFromProto(resp *roomv1.GetRoomTreasureResponse) roomTreasureData {
|
||||
if resp == nil || resp.GetTreasure() == nil {
|
||||
return roomTreasureData{}
|
||||
@ -567,7 +607,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 +672,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 +702,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
|
||||
|
||||
@ -63,7 +63,7 @@ rocketmq:
|
||||
enabled: true
|
||||
topic: "hyapp_room_outbox"
|
||||
producer_group: "hyapp-room-outbox-producer"
|
||||
tencent_im_consumer_enabled: false
|
||||
tencent_im_consumer_enabled: true
|
||||
tencent_im_consumer_group: "hyapp-room-im-bridge"
|
||||
consumer_max_reconsume_times: 16
|
||||
treasure_open:
|
||||
@ -73,7 +73,7 @@ rocketmq:
|
||||
consumer_group: "hyapp-room-treasure-open"
|
||||
consumer_max_reconsume_times: 16
|
||||
outbox_worker:
|
||||
# Docker 本地走 MQ,避免本地统计链路验证时混入真实 IM 直投副作用。
|
||||
# Docker 和 testbox 走 MQ;room-service 同进程启动 IM bridge consumer 补偿房间群消息。
|
||||
enabled: true
|
||||
publish_mode: "mq"
|
||||
poll_interval: "1s"
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -75,6 +75,26 @@ func TestLoadTencentExample(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDockerConfigStartsRoomIMBridgeWhenOutboxUsesMQ(t *testing.T) {
|
||||
cfg, err := Load("../../configs/config.docker.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("Load docker config failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ {
|
||||
t.Fatalf("docker config should publish room outbox through MQ, got %q", cfg.OutboxWorker.PublishMode)
|
||||
}
|
||||
if !cfg.RocketMQ.RoomOutbox.Enabled {
|
||||
t.Fatalf("docker config must enable room outbox MQ")
|
||||
}
|
||||
if !cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled {
|
||||
t.Fatalf("docker mq room outbox must start Tencent IM bridge consumer")
|
||||
}
|
||||
if !cfg.TencentIM.Enabled {
|
||||
t.Fatalf("Tencent IM bridge consumer requires tencent_im.enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRejectsUnknownOutboxRetryStrategy(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.OutboxWorker.RetryStrategy = "exponential"
|
||||
|
||||
@ -53,6 +53,7 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
||||
var snapshotMS int64
|
||||
var projectionMS int64
|
||||
var saveRoomMetaMS int64
|
||||
var tencentIMMS int64
|
||||
if req.GetMeta() == nil || req.GetMeta().GetRoomId() == "" {
|
||||
// 创建房间必须指定 room_id,后续 lease、meta、snapshot 都依赖它。
|
||||
return nil, xerr.New(xerr.InvalidArgument, "room_id is required")
|
||||
@ -150,6 +151,17 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
||||
return nil, xerr.New(xerr.Conflict, fmt.Sprintf("owner already has room: %s", existing.RoomID))
|
||||
}
|
||||
|
||||
if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.syncPublisher != nil {
|
||||
tencentIMStartedAt := time.Now()
|
||||
if err := s.syncPublisher.EnsureRoomGroup(ctx, cmd.RoomID(), cmd.OwnerUserID); err != nil {
|
||||
// 创建房间是低频动作;房间群必须在返回前存在,避免 App JoinRoom 成功后遇到腾讯 IM 群未 ready。
|
||||
return nil, xerr.New(xerr.Unavailable, fmt.Sprintf("ensure room im group failed: %v", err))
|
||||
}
|
||||
tencentIMMS = elapsedMS(tencentIMStartedAt)
|
||||
}
|
||||
if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -236,7 +248,7 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
||||
|
||||
// 持久化成功后再安装内存 Cell,避免内存有房间但恢复来源不完整。
|
||||
s.installCell(ctx, cmd.RoomID(), cell.New(roomState.Clone(), rank.FromState(roomState.GiftRank, s.rankLimit)))
|
||||
logRoomCommandTiming(ctx, cmd.RoomID(), cmd.ID(), cmd.Type(), true, elapsedMS(startedAt), idempotencyMS, leaseMS, saveMutationMS, snapshotMS, projectionMS, 0, 0, 0, true, true,
|
||||
logRoomCommandTiming(ctx, cmd.RoomID(), cmd.ID(), cmd.Type(), true, elapsedMS(startedAt), idempotencyMS, leaseMS, saveMutationMS, snapshotMS, projectionMS, 0, 0, tencentIMMS, true, true,
|
||||
slog.Int64("save_room_meta_ms", saveRoomMetaMS),
|
||||
)
|
||||
|
||||
|
||||
@ -0,0 +1,109 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
"hyapp/pkg/tencentim"
|
||||
"hyapp/services/room-service/internal/integration"
|
||||
roomservice "hyapp/services/room-service/internal/room/service"
|
||||
"hyapp/services/room-service/internal/router"
|
||||
"hyapp/services/room-service/internal/testutil/mysqltest"
|
||||
)
|
||||
|
||||
func TestRealCreateRoomEnsuresTencentIMGroup(t *testing.T) {
|
||||
if os.Getenv("ROOM_CREATE_REAL_IM_TEST") != "1" {
|
||||
t.Skip("set ROOM_CREATE_REAL_IM_TEST=1, ROOM_SERVICE_MYSQL_TEST_DSN, and Tencent IM env vars to run the real create-room smoke test")
|
||||
}
|
||||
|
||||
imClient, err := tencentim.NewRESTClient(realRoomCreateIMConfig(t))
|
||||
if err != nil {
|
||||
t.Fatalf("new real Tencent IM client failed: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
repository := mysqltest.NewRepository(t)
|
||||
roomID := "room-real-im-" + strconv.FormatInt(time.Now().UTC().UnixMilli(), 10)
|
||||
ownerID := time.Now().UTC().UnixMilli() % 9_000_000_000
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cleanupCancel()
|
||||
if err := imClient.DestroyGroup(cleanupCtx, roomID); err != nil {
|
||||
t.Errorf("destroy real Tencent IM room group failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
svc := roomservice.New(roomservice.Config{
|
||||
NodeID: "node-real-create-room-im",
|
||||
LeaseTTL: 10 * time.Second,
|
||||
RankLimit: 20,
|
||||
SnapshotEveryN: 1,
|
||||
}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewTencentIMPublisher(imClient), integration.NewNoopOutboxPublisher())
|
||||
|
||||
resp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, ownerID),
|
||||
SeatCount: 10,
|
||||
Mode: "voice",
|
||||
VisibleRegionId: 1001,
|
||||
RoomName: "Real Tencent IM Room",
|
||||
RoomShortId: strconv.FormatInt(ownerID, 10),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRoom with real Tencent IM failed: %v", err)
|
||||
}
|
||||
if resp.GetRoom().GetRoomId() != roomID || !resp.GetResult().GetApplied() {
|
||||
t.Fatalf("unexpected CreateRoom response: %+v", resp)
|
||||
}
|
||||
if _, exists, err := repository.GetRoomMeta(ctx, roomID); err != nil || !exists {
|
||||
t.Fatalf("room meta must be persisted after real IM group succeeds: exists=%t err=%v", exists, err)
|
||||
}
|
||||
|
||||
t.Logf("real create room IM smoke passed: room_id=%s owner_user_id=%d", roomID, ownerID)
|
||||
}
|
||||
|
||||
func realRoomCreateIMConfig(t *testing.T) tencentim.RESTConfig {
|
||||
t.Helper()
|
||||
|
||||
sdkAppID, err := strconv.ParseInt(requiredRoomCreateEnv(t, "TENCENT_IM_SDK_APP_ID"), 10, 64)
|
||||
if err != nil || sdkAppID <= 0 {
|
||||
t.Fatalf("TENCENT_IM_SDK_APP_ID is invalid")
|
||||
}
|
||||
endpoint := strings.TrimSpace(os.Getenv("TENCENT_IM_ENDPOINT"))
|
||||
if endpoint == "" {
|
||||
endpoint = tencentim.DefaultEndpoint
|
||||
}
|
||||
ttl := time.Hour
|
||||
if rawTTL := strings.TrimSpace(os.Getenv("TENCENT_IM_ADMIN_USERSIG_TTL")); rawTTL != "" {
|
||||
parsed, err := time.ParseDuration(rawTTL)
|
||||
if err != nil || parsed <= 0 {
|
||||
t.Fatalf("TENCENT_IM_ADMIN_USERSIG_TTL is invalid")
|
||||
}
|
||||
ttl = parsed
|
||||
}
|
||||
|
||||
return tencentim.RESTConfig{
|
||||
SDKAppID: sdkAppID,
|
||||
SecretKey: requiredRoomCreateEnv(t, "TENCENT_IM_SECRET_KEY"),
|
||||
AdminIdentifier: requiredRoomCreateEnv(t, "TENCENT_IM_ADMIN_IDENTIFIER"),
|
||||
AdminUserSigTTL: ttl,
|
||||
Endpoint: endpoint,
|
||||
GroupType: tencentim.DefaultGroupType,
|
||||
}
|
||||
}
|
||||
|
||||
func requiredRoomCreateEnv(t *testing.T, key string) string {
|
||||
t.Helper()
|
||||
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
t.Fatalf("%s is required for real create-room smoke test", key)
|
||||
}
|
||||
return value
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
"hyapp/pkg/tencentim"
|
||||
"hyapp/services/room-service/internal/integration"
|
||||
roomservice "hyapp/services/room-service/internal/room/service"
|
||||
"hyapp/services/room-service/internal/router"
|
||||
"hyapp/services/room-service/internal/testutil/mysqltest"
|
||||
)
|
||||
|
||||
type createRoomIMPublisher struct {
|
||||
err error
|
||||
roomID string
|
||||
ownerUserID int64
|
||||
callCount int
|
||||
}
|
||||
|
||||
func (p *createRoomIMPublisher) EnsureRoomGroup(_ context.Context, roomID string, ownerUserID int64) error {
|
||||
p.callCount++
|
||||
p.roomID = roomID
|
||||
p.ownerUserID = ownerUserID
|
||||
return p.err
|
||||
}
|
||||
|
||||
func (p *createRoomIMPublisher) PublishRoomEvent(context.Context, tencentim.RoomEvent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCreateRoomSynchronouslyEnsuresIMGroup(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
publisher := &createRoomIMPublisher{}
|
||||
svc := roomservice.New(roomservice.Config{
|
||||
NodeID: "node-create-room-im-test",
|
||||
LeaseTTL: 10 * time.Second,
|
||||
RankLimit: 20,
|
||||
SnapshotEveryN: 1,
|
||||
}, router.NewMemoryDirectory(), repository, followTestWallet{}, publisher, integration.NewNoopOutboxPublisher())
|
||||
|
||||
roomID := "room-sync-im-ok"
|
||||
ownerID := int64(7001)
|
||||
resp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, ownerID),
|
||||
SeatCount: 10,
|
||||
Mode: "voice",
|
||||
VisibleRegionId: 1001,
|
||||
RoomName: "Sync IM Room",
|
||||
RoomShortId: "sync-im-ok",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create room failed: %v", err)
|
||||
}
|
||||
if publisher.callCount != 1 || publisher.roomID != roomID || publisher.ownerUserID != ownerID {
|
||||
t.Fatalf("IM group must be ensured once before create returns: %+v", publisher)
|
||||
}
|
||||
if resp.GetRoom().GetRoomId() != roomID || !resp.GetResult().GetApplied() {
|
||||
t.Fatalf("create response mismatch: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRoomDoesNotPersistWhenIMGroupEnsureFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
publisher := &createRoomIMPublisher{err: errors.New("tencent im unavailable")}
|
||||
svc := roomservice.New(roomservice.Config{
|
||||
NodeID: "node-create-room-im-fail-test",
|
||||
LeaseTTL: 10 * time.Second,
|
||||
RankLimit: 20,
|
||||
SnapshotEveryN: 1,
|
||||
}, router.NewMemoryDirectory(), repository, followTestWallet{}, publisher, integration.NewNoopOutboxPublisher())
|
||||
|
||||
roomID := "room-sync-im-fail"
|
||||
ownerID := int64(7002)
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, ownerID),
|
||||
SeatCount: 10,
|
||||
Mode: "voice",
|
||||
VisibleRegionId: 1001,
|
||||
RoomName: "Sync IM Failure",
|
||||
RoomShortId: "sync-im-fail",
|
||||
}); err == nil {
|
||||
t.Fatalf("create room must fail when IM group cannot be created")
|
||||
}
|
||||
if publisher.callCount != 1 || publisher.roomID != roomID || publisher.ownerUserID != ownerID {
|
||||
t.Fatalf("IM group ensure call mismatch: %+v", publisher)
|
||||
}
|
||||
if meta, exists, err := repository.GetRoomMeta(ctx, roomID); err != nil || exists {
|
||||
t.Fatalf("room meta must not persist after IM failure: meta=%+v exists=%t err=%v", meta, exists, err)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ service_name: statistics-service
|
||||
node_id: statistics-prod-1
|
||||
environment: prod
|
||||
health_http_addr: ":13110"
|
||||
query_http_addr: ":13010"
|
||||
mysql_dsn: "${STATISTICS_MYSQL_DSN}"
|
||||
rocketmq:
|
||||
enabled: true
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user