Compare commits

...

5 Commits

Author SHA1 Message Date
zhx
31f686bd68 房间用户列表 2026-05-27 18:50:40 +08:00
zhx
9fa6991ffd 启动mq 2026-05-27 18:04:07 +08:00
zhx
e73e2f5ee8 修复zeeone 的oder问题 2026-05-27 17:27:13 +08:00
zhx
30a25a9d4d 同步创建房间 2026-05-27 15:57:01 +08:00
zhx
aa43bf65a9 幸运礼物文档的逻辑和房间用户列表接口 2026-05-27 15:11:00 +08:00
85 changed files with 4273 additions and 3066 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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. 旧实例进入 draininggRPC 完成 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`
- SecretMySQL/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` 只表示事件已进入 RocketMQactivity、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。

View File

@ -1 +0,0 @@
# Package marker for deployment helper tests.

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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';

View File

@ -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;

View File

@ -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

View File

@ -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变更走对应云产品流程。

View File

@ -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

View File

@ -1,3 +0,0 @@
IMAGE=10.2.1.3:18082/hyapp/admin-platform:REPLACE_TAG
CONTAINER_NAME=hyapp-admin-platform
STOP_TIMEOUT_SEC=15

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 或太慢,把构建放到 CIdeploy 服务器只负责调度发布。
```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 目录里执行本仓库部署命令。

View File

@ -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:]))

View File

@ -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
}
}
}
}

View File

@ -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()

View File

@ -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:]))

View File

@ -1 +0,0 @@
# Package marker for Tencent TAT deployment helper tests.

View File

@ -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

View File

@ -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

View File

@ -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` outboxworker 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 群 |

View 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`。 |

View File

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

View File

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

View File

@ -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`
## 当前目录结构

View File

@ -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:

View File

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

View File

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

View File

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

View File

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

View File

@ -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 投递 |

View File

@ -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。

View File

@ -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` | 公屏消息内容审核、频控未做 |

View File

@ -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 |

View 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` 去重。

View 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-serviceroom-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/...
```

View File

@ -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

View File

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

View File

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

View File

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

View File

@ -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
}

View File

@ -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"`

View File

@ -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{})

View File

@ -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() {

View File

@ -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)
}
})
}
}

View File

@ -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

View File

@ -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 走 MQroom-service 同进程启动 IM bridge consumer 补偿房间群消息
enabled: true
publish_mode: "mq"
poll_interval: "1s"

View File

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

View File

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

View File

@ -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),
)

View File

@ -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
}

View File

@ -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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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