修复zeeone 的oder问题
This commit is contained in:
parent
30a25a9d4d
commit
e73e2f5ee8
164
deploy/README.md
164
deploy/README.md
@ -1,164 +0,0 @@
|
||||
# 多机部署与无感发布
|
||||
|
||||
本文定义不使用 Kubernetes 时的生产部署边界。目标是让 gateway 和内部微服务可以多机滚动发布,并让 `room-service` 在普通内网负载均衡命中非 owner 节点时自动转发到当前 Room Cell owner。
|
||||
|
||||
## 网络拓扑
|
||||
|
||||
推荐拓扑:
|
||||
|
||||
```text
|
||||
公网 CLB
|
||||
-> gateway-service 多实例
|
||||
-> room-service 内网入口
|
||||
-> user-service 内网入口
|
||||
-> wallet-service 内网入口
|
||||
-> activity-service 内网入口
|
||||
-> game-service 内网入口
|
||||
-> notice-service 内网入口
|
||||
|
||||
room-service 实例之间通过 Redis owner route 和 node registry 直连转发。
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 公网只暴露 `gateway-service`。
|
||||
- 内部 gRPC 服务只监听私网,安全组只允许服务网段访问 `13xxx` 端口。
|
||||
- `user-service`、`wallet-service`、`activity-service`、`game-service`、`notice-service` 可以直接挂内网 CLB 或 DNS。
|
||||
- `room-service` 可以挂内网 CLB/DNS 给 gateway 使用,但 owner 转发必须使用实例级 `advertise_addr`,不能使用 CLB 地址。
|
||||
- MySQL、Redis、MQ 使用腾讯云托管资源,不在业务 CVM 上部署;服务只通过内网 endpoint 访问这些托管依赖。
|
||||
|
||||
当前生产入口:
|
||||
|
||||
| 层级 | 地址 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 公网 API | `https://api.global-interaction.com` | HTTPS 443 由公网 CLB 转发到两台 `gateway-service:13000` |
|
||||
| 后台前端 | `http://1.14.164.2:7001` | 广州 CVM `172.16.0.10` 上 nginx 托管 `admin-platform` 静态资源,`/api` 反代内网后台服务 |
|
||||
| 后台 API | `172.16.0.6:13100` | 广州 CVM `ins-0x5mjwmc` 运行 `hyapp-admin-server`,通过 `wg-quick@hyapp-admin` 访问生产内网依赖 |
|
||||
| room/user/activity/game/notice 内网入口 | `10.2.1.16:13001/13005/13006/13008/13009` | `lb-epvnr4o0` 的 Go 服务监听器 |
|
||||
| wallet 内网入口 | `10.2.1.15:13004` | `lb-4f5xi6p0` 的 Go 服务监听器 |
|
||||
|
||||
当前生产镜像仓库前缀是 `10.2.1.3:18082/hyapp`。部署脚本和 systemd env 只允许使用 `hyapp` 项目路径。
|
||||
|
||||
## Room Owner 路由
|
||||
|
||||
`room-service` 使用 Redis 维护两个短租约:
|
||||
|
||||
```text
|
||||
room:route:{app_code + "\0" + room_id} -> owner node_id / lease_token / expires_at_ms
|
||||
room:node:{node_id} -> 实例 advertise_addr / expires_at_ms
|
||||
```
|
||||
|
||||
写命令、IM guard 和完整房间快照查询的处理顺序:
|
||||
|
||||
1. 请求先到任意 `room-service` 节点。
|
||||
2. 节点按 `app_code + "\0" + room_id` 查询 Redis owner route。
|
||||
3. 如果当前节点就是 owner,直接进入本地 Room Cell。
|
||||
4. 如果 owner 是其他节点,按 `room:node:{owner_node_id}` 找到实例私网地址并转发 gRPC。
|
||||
5. 如果 route 不存在或已过期,当前节点按原有 `EnsureOwner` 逻辑接管并从 MySQL snapshot + command log 恢复。
|
||||
|
||||
转发只做一跳。`advertise_addr` 写错成 CLB 地址时,请求会在一跳后停止递归,并暴露真实 owner 冲突或 unavailable。
|
||||
|
||||
## room-service 配置
|
||||
|
||||
每个实例必须有唯一配置:
|
||||
|
||||
```yaml
|
||||
node_id: "room-node-prod-1"
|
||||
grpc_addr: ":13001"
|
||||
advertise_addr: "10.0.1.12:13001"
|
||||
health_http_addr: ":13101"
|
||||
node_registry_ttl: "30s"
|
||||
node_registry_heartbeat_interval: "10s"
|
||||
owner_forward_timeout: "2s"
|
||||
lease_ttl: "10s"
|
||||
```
|
||||
|
||||
配置边界:
|
||||
|
||||
- `grpc_addr` 是本进程监听地址,可以是 `:13001`。
|
||||
- `advertise_addr` 是其他 room 节点能直连的实例私网地址,必须是单实例地址。
|
||||
- `node_id` 参与 room owner lease、节点注册和日志排障,不能在多个实例间复用。
|
||||
- `node_registry_ttl` 必须大于心跳周期;默认 `30s/10s`。
|
||||
- `lease_ttl` 是节点故障后的自动接管窗口;正常滚动发布会在 gRPC drain 后主动释放本节点已加载房间 lease,减少等待 TTL 的窗口。
|
||||
|
||||
## 无感发布步骤
|
||||
|
||||
单个服务滚动发布顺序:
|
||||
|
||||
1. 在新机器或新端口启动新版本实例。
|
||||
2. 等 `health_http_addr` 的 `/readyz` 或 gRPC health 通过。
|
||||
3. 把新实例加入对应 CLB 后端或内部 DNS。
|
||||
4. 观察错误率、延迟和日志。
|
||||
5. 从 CLB 摘除一个旧实例,等待 CLB 健康检查和连接排空周期。
|
||||
6. 对旧实例发送 `SIGTERM`。
|
||||
7. 旧实例进入 draining,gRPC 完成 in-flight 请求后释放已加载房间 lease,并删除 `room:node:{node_id}`。
|
||||
8. 新请求命中新节点后,如果 Redis route 已释放,会立即接管并从 MySQL 恢复;如果旧实例异常退出,则等待 `lease_ttl` 到期后接管。
|
||||
|
||||
发布约束:
|
||||
|
||||
- 不要先杀进程再摘 CLB。
|
||||
- 不要把 `room-service` 的 `advertise_addr` 写成内网 CLB。
|
||||
- 不要多个实例复用同一个 `node_id`。
|
||||
- 数据库变更要先执行兼容当前发布窗口的迁移,再滚动服务。
|
||||
|
||||
## 配置拆分
|
||||
|
||||
不用统一配置中心也可以上线。推荐发布系统渲染最终 `config.yaml`:
|
||||
|
||||
- 公共环境配置:腾讯云 MySQL/Redis/MQ 私网地址、日志等级、腾讯云 endpoint。
|
||||
- 服务配置:端口、worker 周期、批量大小、超时。
|
||||
- 实例配置:`node_id`、`advertise_addr`、`user-service.id_generator.node_id`。
|
||||
- Secret:MySQL/Redis 密码、JWT secret、腾讯 IM/RTC/COS secret、callback token。
|
||||
|
||||
只有当需要动态配置、灰度下发、审计回滚时,再引入 Consul/Nacos/Apollo。当前代码的启动配置仍以单服务 YAML 为边界。
|
||||
|
||||
线上字段契约以 `services/<service>/configs/config.tencent.example.yaml` 为准。开发时新增配置字段的动作必须同时更新三处:
|
||||
|
||||
1. `services/<service>/configs/config.yaml`:本地直跑默认配置。
|
||||
2. `services/<service>/configs/config.docker.yaml`:本地 Docker/Compose 配置。
|
||||
3. `services/<service>/configs/config.tencent.example.yaml`:线上渲染契约。
|
||||
|
||||
部署前运行:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/validate_rendered_configs.py \
|
||||
--inventory deploy/tencent-tat/inventory.prod.example.json \
|
||||
--rendered-dir /tmp/hyapp-rendered-prod
|
||||
```
|
||||
|
||||
这个检查只比较 YAML 字段路径,不比较也不输出配置值。只要本地新增字段没有进入线上契约,或者最终渲染 YAML 漏字段,部署前会直接失败。
|
||||
|
||||
托管依赖边界:
|
||||
|
||||
- 腾讯云 MySQL 是业务事实和恢复来源;DSN 必须保留 `loc=UTC`。
|
||||
- 腾讯云 Redis 保存 owner route、node registry、登录风控和短 TTL 缓存;Redis 不能作为业务恢复事实来源。
|
||||
- 腾讯云 MQ 只作为 outbox/event bus 的托管消息通道;创建 topic、consumer group 和权限变更走云产品流程,不混进服务滚动发布。
|
||||
|
||||
当前 RocketMQ 资源约定:
|
||||
|
||||
| Topic | Producer Group | Consumer Group | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-activity-room-outbox` | activity-service 消费房间事实,处理贵重礼物播报、宝箱播报、成长等活动逻辑 |
|
||||
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-notice-room-outbox` | notice-service 消费私有通知相关房间事实 |
|
||||
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-room-im-bridge` | room-service IM bridge 消费房间群系统消息和群成员控制事实 |
|
||||
| `hyapp_room_treasure_open` | `hyapp-room-treasure-open-producer` | `hyapp-room-treasure-open` | room-service 消费宝箱延迟开箱唤醒消息 |
|
||||
|
||||
线上推荐配置:
|
||||
|
||||
- `services/room-service/configs/config.tencent.example.yaml` 保持 `outbox_worker.publish_mode: "mq"`。
|
||||
- `rocketmq.room_outbox.enabled: true` 后,`room_outbox.status=delivered` 只表示事件已进入 RocketMQ;activity、notice、IM bridge 的成功状态由各自消费位点负责。
|
||||
- `rocketmq.treasure_open.enabled: true` 后,宝箱倒计时使用毫秒级 `open_at_ms` 投递延迟消息;消息到达后 room-service 仍要重新校验 Room Cell 状态,不能把 MQ 当成宝箱状态来源。
|
||||
- 本地 Docker 默认关闭 MQ,使用 direct publisher 维持无云依赖的可运行形态。
|
||||
|
||||
## 真实数据验证
|
||||
|
||||
本地真实 MySQL/Redis 验证:
|
||||
|
||||
```bash
|
||||
docker compose up -d mysql redis
|
||||
ROOM_SERVICE_MYSQL_TEST_DSN='hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC' \
|
||||
ROOM_SERVICE_REDIS_TEST_ADDR='127.0.0.1:13379' \
|
||||
go test -count=1 ./services/room-service/internal/transport/grpc -run TestOwnerForwardingUsesRedisRouteAndNodeRegistry
|
||||
```
|
||||
|
||||
该测试会启动两个真实 gRPC `room-service` 实例,共用真实 MySQL schema 和 Redis route/node registry:在 node A 创建房间,通过 node B 调 `JoinRoom`、`CheckSpeakPermission` 和 `GetRoomSnapshot`,验证请求会转发到当前 owner。
|
||||
@ -1 +0,0 @@
|
||||
# Package marker for deployment helper tests.
|
||||
@ -1,45 +0,0 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
-- 使用 utf8mb4 连接字符集,保证中文注释、中文名称和 emoji 在导入时不被降级。
|
||||
-- 本地 Docker 入口脚本会先创建 MYSQL_DATABASE,服务 DDL 随后创建各业务库。
|
||||
-- 这里统一修复新旧本地卷的默认字符集,保证中文文本、中文 COMMENT 和 emoji 不被旧默认字符集截断。
|
||||
ALTER DATABASE hyapp_room CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER DATABASE hyapp_user CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER DATABASE hyapp_wallet CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER DATABASE hyapp_activity CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_room;
|
||||
ALTER TABLE rooms CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE room_list_entries CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE room_snapshots CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE room_command_log CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE room_outbox CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_user;
|
||||
ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE countries CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE regions CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE region_countries CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE region_change_logs CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE user_region_rebuild_tasks CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE user_display_user_ids CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE pretty_display_user_id_leases CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE display_user_id_change_logs CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE user_country_change_logs CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE password_accounts CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE third_party_identities CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE auth_sessions CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE login_audit CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_wallet;
|
||||
ALTER TABLE wallet_accounts CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE wallet_transactions CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE wallet_entries CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE wallet_outbox CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE wallet_gift_prices CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_activity;
|
||||
ALTER TABLE activities CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE activity_event_consumption CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE activity_outbox CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@ -1,5 +0,0 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
-- 使用 utf8mb4 连接字符集,保证中文注释、中文名称和 emoji 在导入时不被降级。
|
||||
CREATE DATABASE IF NOT EXISTS hyapp_admin DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@ -1,57 +0,0 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 使用 utf8mb4 连接字符集,保证中文注释、中文名称和 emoji 在导入时不被降级。
|
||||
|
||||
USE hyapp_wallet;
|
||||
|
||||
DROP PROCEDURE IF EXISTS migrate_resource_group_wallet_asset_items;
|
||||
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE migrate_resource_group_wallet_asset_items()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_group_items' AND COLUMN_NAME = 'item_type'
|
||||
) THEN
|
||||
ALTER TABLE resource_group_items
|
||||
ADD COLUMN item_type VARCHAR(32) NOT NULL DEFAULT 'resource' COMMENT '明细类型' AFTER group_id;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_group_items' AND COLUMN_NAME = 'wallet_asset_type'
|
||||
) THEN
|
||||
ALTER TABLE resource_group_items
|
||||
ADD COLUMN wallet_asset_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '钱包资产类型' AFTER resource_id;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_group_items' AND COLUMN_NAME = 'wallet_asset_amount'
|
||||
) THEN
|
||||
ALTER TABLE resource_group_items
|
||||
ADD COLUMN wallet_asset_amount BIGINT NOT NULL DEFAULT 0 COMMENT '钱包资产数量' AFTER wallet_asset_type;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_group_items' AND INDEX_NAME = 'uk_resource_group_items_resource'
|
||||
) THEN
|
||||
ALTER TABLE resource_group_items DROP INDEX uk_resource_group_items_resource;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_group_items' AND INDEX_NAME = 'uk_resource_group_items_member'
|
||||
) THEN
|
||||
ALTER TABLE resource_group_items
|
||||
ADD UNIQUE KEY uk_resource_group_items_member (app_code, group_id, item_type, resource_id, wallet_asset_type);
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
CALL migrate_resource_group_wallet_asset_items();
|
||||
|
||||
DROP PROCEDURE migrate_resource_group_wallet_asset_items;
|
||||
@ -1,14 +0,0 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 使用 utf8mb4 连接字符集,保证中文注释、中文名称和 emoji 在导入时不被降级。
|
||||
|
||||
USE hyapp_user;
|
||||
|
||||
-- 对已经执行过旧国家种子的本地库做幂等修正,保证国家主数据与当前产品范围一致。
|
||||
DELETE FROM region_countries
|
||||
WHERE app_code = 'lalu'
|
||||
AND country_code = 'TW';
|
||||
|
||||
DELETE FROM countries
|
||||
WHERE app_code = 'lalu'
|
||||
AND country_code = 'TW';
|
||||
@ -1,17 +0,0 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
-- 使用 utf8mb4 连接字符集,保证中文注释、中文名称和 emoji 在导入时不被降级。
|
||||
CREATE USER IF NOT EXISTS 'hyapp'@'%' IDENTIFIED BY 'hyapp';
|
||||
|
||||
GRANT ALL PRIVILEGES ON hyapp_room.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_user.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_wallet.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_activity.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_admin.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_cron.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_game.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_notice.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_statistics.* TO 'hyapp'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
@ -1,9 +0,0 @@
|
||||
brokerClusterName = hyapp-local
|
||||
brokerName = broker-a
|
||||
brokerId = 0
|
||||
deleteWhen = 04
|
||||
fileReservedTime = 48
|
||||
brokerRole = ASYNC_MASTER
|
||||
flushDiskType = ASYNC_FLUSH
|
||||
autoCreateTopicEnable = true
|
||||
listenPort = 10911
|
||||
@ -1,101 +0,0 @@
|
||||
# Standalone CVM Deployment Templates
|
||||
|
||||
本目录提供不依赖 Kubernetes 的生产部署模板。目标形态是 systemd 托管 Docker 容器,公网 CLB 只接 gateway,内部服务通过内网 CLB/DNS 或固定私网地址访问。
|
||||
|
||||
生产环境 MySQL、Redis、MQ 使用腾讯云托管资源。本目录只管理本仓库 Go 服务容器,不在业务 CVM 上安装或重启 MySQL/Redis/MQ。
|
||||
|
||||
## Layout
|
||||
|
||||
- `systemd/*.service`: 每个后端服务一份 systemd unit,负责启动、停止和故障重启 Docker 容器。
|
||||
- `docker/*.env.example`: 每个服务一份 Docker 运行参数示例,只保存镜像名、容器名、配置路径和停止等待时间。
|
||||
- 服务业务配置仍使用 `services/<service>/configs/config.tencent.example.yaml` 作为线上模板,渲染后放到 `/etc/hyapp/<service>/config.yaml`。
|
||||
|
||||
## Install One Service
|
||||
|
||||
以 `gateway-service` 为例:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/hyapp/gateway-service
|
||||
sudo cp deploy/standalone/docker/gateway-service.env.example /etc/hyapp/gateway-service/docker.env
|
||||
sudo cp services/gateway-service/configs/config.tencent.example.yaml /etc/hyapp/gateway-service/config.yaml
|
||||
sudo vi /etc/hyapp/gateway-service/docker.env
|
||||
sudo vi /etc/hyapp/gateway-service/config.yaml
|
||||
|
||||
sudo cp deploy/standalone/systemd/hyapp-gateway-service.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now hyapp-gateway-service
|
||||
sudo systemctl status hyapp-gateway-service
|
||||
sudo docker inspect --format '{{json .State.Health}}' hyapp-gateway-service
|
||||
```
|
||||
|
||||
`docker.env` 不承载业务密钥;密钥仍在最终 `config.yaml` 中,或者由发布系统先从密钥系统渲染到 `config.yaml`。当前代码只读取单个 YAML,不会自动展开 `${VAR}`。
|
||||
|
||||
## Network
|
||||
|
||||
模板默认使用 `--network=host`,让容器直接监听项目约定的 `13xxx` 端口。这样 CLB、安全组和本机排障都以同一个端口工作:
|
||||
|
||||
- `13000`: gateway HTTP
|
||||
- `13001`: room gRPC
|
||||
- `13004`: wallet gRPC
|
||||
- `13005`: user gRPC
|
||||
- `13006`: activity gRPC
|
||||
- `13007`: cron gRPC
|
||||
- `13008`: game gRPC
|
||||
- `13009`: notice gRPC
|
||||
|
||||
同一台 CVM 上不要启动两个相同端口的同服务实例。做蓝绿发布时优先使用新 CVM 或新容器节点,健康通过后再挂载到 CLB。
|
||||
|
||||
## Rolling Update
|
||||
|
||||
普通服务的无感更新流程:
|
||||
|
||||
1. 在新 CVM 上准备 `/etc/hyapp/<service>/docker.env` 和 `/etc/hyapp/<service>/config.yaml`。
|
||||
2. `systemctl start hyapp-<service>`,等待 Docker health 进入 `healthy`。
|
||||
3. 将新实例加入对应 CLB 或内部服务入口。
|
||||
4. 观察错误率、延迟和服务日志。
|
||||
5. 从 CLB 摘除旧实例,等待连接保护或业务 drain 窗口结束。
|
||||
6. `systemctl stop hyapp-<service>`,Docker 会先发送 SIGTERM,服务内的 graceful shutdown 会进入 draining 并等待已进入请求结束。
|
||||
|
||||
`room-service` 已经具备 Redis owner route 和非 owner 自动转发能力,可以把多个 active 实例挂到内网 CLB 后面。每个实例仍必须配置唯一 `node_id`,且 `advertise_addr` 必须是实例私网地址,不能写 CLB 地址。
|
||||
|
||||
## Tencent TAT Rolling Deploy
|
||||
|
||||
当前腾讯云 CVM 生产部署使用 `deploy/tencent-tat`:
|
||||
|
||||
- `inventory.prod.example.json`: 记录 gateway/new-app/new-game/new-app-activity/pay 这批机器和服务映射。
|
||||
- `hyapp_tat_deploy.py`: 通过 TAT 在目标机执行 systemd 重启和镜像更新。
|
||||
- 发布时对配置了 CLB 的服务逐实例改权重到 `0`,健康恢复后再恢复原权重。
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py restart \
|
||||
--inventory deploy/tencent-tat/inventory.prod.json \
|
||||
--services gateway-service \
|
||||
--yes
|
||||
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy \
|
||||
--inventory deploy/tencent-tat/inventory.prod.json \
|
||||
--services gateway-service,room-service,user-service,wallet-service,activity-service,game-service,notice-service \
|
||||
--tag 20260513-1349 \
|
||||
--yes
|
||||
```
|
||||
|
||||
详细步骤和无感发布边界见 `deploy/tencent-tat/README.md`。
|
||||
|
||||
## Service Discovery
|
||||
|
||||
不使用 Kubernetes 时,第一阶段推荐用内网 CLB/DNS 提供稳定入口,例如:
|
||||
|
||||
```yaml
|
||||
user_service_addr: "user.service.internal:13005"
|
||||
wallet_service_addr: "wallet.service.internal:13004"
|
||||
activity_service_addr: "activity.service.internal:13006"
|
||||
room_service_addr: "room.service.internal:13001"
|
||||
game_service_addr: "game.service.internal:13008"
|
||||
notice_service_addr: "notice.service.internal:13009"
|
||||
```
|
||||
|
||||
如果后续引入 Consul/Nacos,仍建议让业务配置只保存稳定服务名,不把实例 IP 写进每个服务配置。
|
||||
|
||||
MySQL、Redis、MQ 不属于这里的服务发现对象。它们在 `config.yaml` 中写腾讯云托管资源的内网 endpoint,变更走对应云产品流程。
|
||||
@ -1,6 +0,0 @@
|
||||
# Image is immutable per release; publish scripts pull this tag before restarting systemd.
|
||||
IMAGE=10.2.1.3:18082/hyapp/activity-service:REPLACE_WITH_RELEASE_TAG
|
||||
CONTAINER_NAME=hyapp-activity-service
|
||||
CONFIG_PATH=/etc/hyapp/activity-service/config.yaml
|
||||
STOP_TIMEOUT_SEC=60
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
IMAGE=10.2.1.3:18082/hyapp/admin-platform:REPLACE_TAG
|
||||
CONTAINER_NAME=hyapp-admin-platform
|
||||
STOP_TIMEOUT_SEC=15
|
||||
@ -1,4 +0,0 @@
|
||||
IMAGE=10.2.1.3:18082/hyapp/admin-server:REPLACE_TAG
|
||||
CONTAINER_NAME=hyapp-admin-server
|
||||
CONFIG_PATH=/etc/hyapp/admin-server/config.yaml
|
||||
STOP_TIMEOUT_SEC=30
|
||||
@ -1,6 +0,0 @@
|
||||
# cron-service already uses MySQL task leases, but keep one replica until every task is verified under multi-node scheduling.
|
||||
IMAGE=10.2.1.3:18082/hyapp/cron-service:REPLACE_WITH_RELEASE_TAG
|
||||
CONTAINER_NAME=hyapp-cron-service
|
||||
CONFIG_PATH=/etc/hyapp/cron-service/config.yaml
|
||||
STOP_TIMEOUT_SEC=90
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
IMAGE=10.2.1.3:18082/hyapp/game-service:REPLACE_WITH_RELEASE_TAG
|
||||
CONTAINER_NAME=hyapp-game-service
|
||||
CONFIG_PATH=/etc/hyapp/game-service/config.yaml
|
||||
STOP_TIMEOUT_SEC=60
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
# gateway instances are stateless HTTP entries and should be attached behind the public CLB only after /healthz/ready passes.
|
||||
IMAGE=10.2.1.3:18082/hyapp/gateway-service:REPLACE_WITH_RELEASE_TAG
|
||||
CONTAINER_NAME=hyapp-gateway-service
|
||||
CONFIG_PATH=/etc/hyapp/gateway-service/config.yaml
|
||||
STOP_TIMEOUT_SEC=60
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
# Image is immutable per release; publish scripts pull this tag before restarting systemd.
|
||||
IMAGE=10.2.1.3:18082/hyapp/notice-service:REPLACE_WITH_RELEASE_TAG
|
||||
CONTAINER_NAME=hyapp-notice-service
|
||||
CONFIG_PATH=/etc/hyapp/notice-service/config.yaml
|
||||
STOP_TIMEOUT_SEC=60
|
||||
@ -1,6 +0,0 @@
|
||||
# Keep room-service single-active until owner routing or non-owner forwarding is implemented.
|
||||
IMAGE=10.2.1.3:18082/hyapp/room-service:REPLACE_WITH_RELEASE_TAG
|
||||
CONTAINER_NAME=hyapp-room-service
|
||||
CONFIG_PATH=/etc/hyapp/room-service/config.yaml
|
||||
STOP_TIMEOUT_SEC=90
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
IMAGE=10.2.1.3:18082/hyapp/statistics-service:RELEASE_TAG
|
||||
CONTAINER_NAME=hyapp-statistics-service
|
||||
CONFIG_PATH=/etc/hyapp/statistics-service/config.yaml
|
||||
STOP_TIMEOUT_SEC=60
|
||||
@ -1,6 +0,0 @@
|
||||
# id_generator.node_id in config.yaml must be unique for every active user-service instance.
|
||||
IMAGE=10.2.1.3:18082/hyapp/user-service:REPLACE_WITH_RELEASE_TAG
|
||||
CONTAINER_NAME=hyapp-user-service
|
||||
CONFIG_PATH=/etc/hyapp/user-service/config.yaml
|
||||
STOP_TIMEOUT_SEC=60
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
IMAGE=10.2.1.3:18082/hyapp/wallet-service:REPLACE_WITH_RELEASE_TAG
|
||||
CONTAINER_NAME=hyapp-wallet-service
|
||||
CONFIG_PATH=/etc/hyapp/wallet-service/config.yaml
|
||||
STOP_TIMEOUT_SEC=60
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
[Unit]
|
||||
Description=Hyapp activity-service container
|
||||
Requires=docker.service
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/activity-service/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13006 -service=activity-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStartSec=60
|
||||
TimeoutStopSec=90
|
||||
KillMode=none
|
||||
LimitNOFILE=1048576
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
[Unit]
|
||||
Description=HYApp admin platform frontend
|
||||
After=docker.service network-online.target hyapp-admin-server.service
|
||||
Wants=network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/admin-platform/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop -t ${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -1,17 +0,0 @@
|
||||
[Unit]
|
||||
Description=HYApp admin-server
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/admin-server/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --mount=type=bind,src=/var/lib/hyapp/admin-server/storage,dst=/app/storage ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop -t ${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -1,24 +0,0 @@
|
||||
[Unit]
|
||||
Description=Hyapp cron-service container
|
||||
Requires=docker.service
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/cron-service/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13007 -service=cron-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStartSec=60
|
||||
TimeoutStopSec=120
|
||||
KillMode=none
|
||||
LimitNOFILE=1048576
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
[Unit]
|
||||
Description=Hyapp game-service container
|
||||
Requires=docker.service
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/game-service/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13008 -service=game-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStartSec=60
|
||||
TimeoutStopSec=90
|
||||
KillMode=none
|
||||
LimitNOFILE=1048576
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
[Unit]
|
||||
Description=Hyapp gateway-service container
|
||||
Requires=docker.service
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/gateway-service/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="wget -q -O - http://127.0.0.1:13000/healthz/ready >/dev/null" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStartSec=60
|
||||
TimeoutStopSec=90
|
||||
KillMode=none
|
||||
LimitNOFILE=1048576
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
[Unit]
|
||||
Description=Hyapp notice-service container
|
||||
Requires=docker.service
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/notice-service/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13009 -service=notice-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStartSec=60
|
||||
TimeoutStopSec=90
|
||||
KillMode=none
|
||||
LimitNOFILE=1048576
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -1,24 +0,0 @@
|
||||
[Unit]
|
||||
Description=Hyapp room-service container
|
||||
Requires=docker.service
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/room-service/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13001 -service=room-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStartSec=60
|
||||
TimeoutStopSec=120
|
||||
KillMode=none
|
||||
LimitNOFILE=1048576
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
[Unit]
|
||||
Description=HYApp statistics-service
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/statistics-service/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop -t ${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -1,24 +0,0 @@
|
||||
[Unit]
|
||||
Description=Hyapp user-service container
|
||||
Requires=docker.service
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/user-service/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13005 -service=user-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStartSec=60
|
||||
TimeoutStopSec=90
|
||||
KillMode=none
|
||||
LimitNOFILE=1048576
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
[Unit]
|
||||
Description=Hyapp wallet-service container
|
||||
Requires=docker.service
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/wallet-service/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --mount=type=bind,src=/etc/hyapp/secrets,dst=/etc/hyapp/secrets,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13004 -service=wallet-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStartSec=60
|
||||
TimeoutStopSec=90
|
||||
KillMode=none
|
||||
LimitNOFILE=1048576
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -1,307 +0,0 @@
|
||||
# 腾讯云 TAT 滚动部署方案
|
||||
|
||||
本方案只部署本仓库 Go 微服务,不接管同机已有 Java 项目。远端连接使用腾讯云 TAT,发布时只操作 `hyapp-*` systemd unit、`hyapp-*` Docker 容器和 `/etc/hyapp/<service>/docker.env`。
|
||||
|
||||
MySQL、Redis、MQ 都按腾讯云托管资源处理。本方案不会在这些 CVM 上部署、重启或迁移本地 MySQL/Redis/MQ;服务配置只需要指向腾讯云托管实例的内网地址。
|
||||
|
||||
## 机器拓扑
|
||||
|
||||
`deploy` 机器是发布控制面,不承载业务流量:
|
||||
|
||||
| 机器 | 实例 ID | 公网 IP | 私网 IP | 角色 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `deploy` | `ns-ntmpcd3a` | `43.164.75.199` | `10.2.1.3` | 构建镜像、推送镜像、调用 TAT/CLB API 调度发布 |
|
||||
|
||||
当前机器按截图落到以下服务:
|
||||
|
||||
| 机器 | 实例 ID | 私网 IP | 本仓库服务 |
|
||||
| --- | --- | --- | --- |
|
||||
| `gateway-1` | `ins-aeyaoj40` | `10.2.1.8` | `gateway-service` |
|
||||
| `gateway-2` | `ins-irvi5b7u` | `10.2.2.17` | `gateway-service` |
|
||||
| `new-app-1` | `ins-fi5zufpk` | `10.2.1.4` | `room-service`, `user-service` |
|
||||
| `new-app-2` | `ins-hwhaoe8c` | `10.2.1.13` | `room-service`, `user-service` |
|
||||
| `new-game-1` | `ins-ir229jtw` | `10.2.1.10` | `game-service` |
|
||||
| `new-game-2` | `ins-460rw7gu` | `10.2.1.6` | `game-service` |
|
||||
| `new-app-activity-1` | `ins-n8wut6n8` | `10.2.1.11` | `activity-service`, `notice-service` |
|
||||
| `new-app-activity-2` | `ins-d1n303sa` | `10.2.1.7` | `activity-service`, `notice-service` |
|
||||
| `pay-1` | `ins-ceqfcxd2` | `10.2.11.14` | `wallet-service` |
|
||||
| `pay-2` | `ins-awhb74q6` | `10.2.12.5` | `wallet-service` |
|
||||
|
||||
如果 `new-app-*` 只部署 `room-service` 或只部署 `user-service`,直接删掉 `inventory` 中对应服务的 host 绑定,不要让脚本猜。
|
||||
|
||||
## 部署模式
|
||||
|
||||
采用“deploy 服务器调度 + 业务机只运行镜像”的模式:
|
||||
|
||||
1. `deploy` 服务器拉取本仓库代码,构建 Docker 镜像,推送到 CCR/Harbor。
|
||||
2. `deploy` 服务器调用腾讯云 CLB API 对目标实例摘流。
|
||||
3. `deploy` 服务器通过 TAT 在目标业务机执行 `docker pull` 和 `systemctl restart hyapp-<service>`。
|
||||
4. 目标业务机只保存 `/etc/hyapp/<service>/config.yaml` 和 `docker.env`,不拉代码、不编译、不保存 Git 凭据。
|
||||
|
||||
不要让每台业务服务器自己 `git pull && build`。那会把代码权限、构建环境、版本一致性和回滚都扩散到业务机,且多服务发布时无法统一做 CLB 摘流。
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. 每台目标机已安装腾讯云 TAT agent,控制台能执行命令。
|
||||
2. 每个 Go 服务已按 `deploy/standalone/systemd` 安装对应 systemd unit。
|
||||
3. 每个 Go 服务已有 `/etc/hyapp/<service>/config.yaml` 和 `/etc/hyapp/<service>/docker.env`。
|
||||
4. 每个服务至少有两台实例,CLB 后端都挂实例端口。
|
||||
5. `room-service` 每台机器必须有唯一 `node_id`,`advertise_addr` 必须是当前机器私网地址,例如 `10.2.1.4:13001`,不能写内网 CLB。
|
||||
6. 每个 `config.yaml` 的 `mysql_dsn`、`redis_addr`、MQ endpoint/topic/group 指向腾讯云托管资源的私网地址。
|
||||
7. 发布机安装腾讯云 Python SDK 和 YAML 解析依赖:
|
||||
|
||||
```bash
|
||||
python3 -m pip install tencentcloud-sdk-python PyYAML
|
||||
```
|
||||
|
||||
凭据通过环境变量提供:
|
||||
|
||||
```bash
|
||||
export TENCENTCLOUD_SECRET_ID="..."
|
||||
export TENCENTCLOUD_SECRET_KEY="..."
|
||||
export TENCENTCLOUD_REGION="me-saudi-arabia"
|
||||
```
|
||||
|
||||
如果继续沿用 `hy-app-monitor`,可以把这些变量放在 deploy 机器的 `/opt/hy-app-monitor/.env`,运行脚本时传 `--env-file /opt/hy-app-monitor/.env`。本地开发机路径 `/Users/hy/Documents/hy/hy-app-monitor/.env` 只用于本机调试。
|
||||
|
||||
## Deploy 服务器初始化
|
||||
|
||||
在 `deploy / 10.2.1.3` 上安装基础工具:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/deploy/sources /opt/deploy/inventory
|
||||
sudo yum install -y git docker python3 python3-pip || sudo apt-get update && sudo apt-get install -y git docker.io python3 python3-pip
|
||||
sudo systemctl enable --now docker
|
||||
python3 -m pip install --user tencentcloud-sdk-python PyYAML
|
||||
```
|
||||
|
||||
拉取本仓库:
|
||||
|
||||
```bash
|
||||
cd /opt/deploy/sources
|
||||
git clone <YOUR_GIT_REPO_URL> hyapp-server
|
||||
cd hyapp-server
|
||||
```
|
||||
|
||||
准备 inventory:
|
||||
|
||||
```bash
|
||||
cp deploy/tencent-tat/inventory.prod.example.json /opt/deploy/inventory/hyapp.prod.json
|
||||
vi /opt/deploy/inventory/hyapp.prod.json
|
||||
```
|
||||
|
||||
`inventory` 里的 `registry` 必须和实际镜像仓库一致。如果使用 `hy-app-monitor` 里的 Harbor 配置,通常是:
|
||||
|
||||
```text
|
||||
registry = ${HARBOR_REGISTRY}/${HARBOR_PROJECT}
|
||||
```
|
||||
|
||||
业务配置放到各目标机 `/etc/hyapp/<service>/config.yaml`。腾讯云 MySQL、Redis、MQ、JWT、IM/RTC/COS 等密钥不要提交到 Git。
|
||||
|
||||
配置字段契约以 `services/<service>/configs/config.tencent.example.yaml` 为准。开发时如果在本地 `config.yaml` 或 `config.docker.yaml` 新增字段,必须同时补到 `config.tencent.example.yaml`;生产渲染前运行校验,避免线上漏字段:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/validate_rendered_configs.py \
|
||||
--inventory deploy/tencent-tat/inventory.prod.example.json
|
||||
```
|
||||
|
||||
如果已经渲染了生产最终配置,还要校验每台机器的最终 YAML:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/validate_rendered_configs.py \
|
||||
--inventory deploy/tencent-tat/inventory.prod.example.json \
|
||||
--rendered-dir /tmp/hyapp-rendered-prod
|
||||
```
|
||||
|
||||
该脚本只比较 YAML key,不打印配置值,避免把生产密钥带到日志里。
|
||||
|
||||
## 构建并推送镜像
|
||||
|
||||
在 `deploy` 服务器上执行。2 核 2GiB 机器可以作为调度器;如果全量构建 OOM 或太慢,把构建放到 CI,deploy 服务器只负责调度发布。
|
||||
|
||||
```bash
|
||||
cd /opt/deploy/sources/hyapp-server
|
||||
TAG="$(date -u +%Y%m%d%H%M%S)-$(git rev-parse --short HEAD)"
|
||||
REGISTRY="10.2.1.3:18082/hyapp"
|
||||
|
||||
docker build -t "$REGISTRY/gateway-service:$TAG" -f services/gateway-service/Dockerfile .
|
||||
docker build -t "$REGISTRY/room-service:$TAG" -f services/room-service/Dockerfile .
|
||||
docker build -t "$REGISTRY/user-service:$TAG" -f services/user-service/Dockerfile .
|
||||
docker build -t "$REGISTRY/wallet-service:$TAG" -f services/wallet-service/Dockerfile .
|
||||
docker build -t "$REGISTRY/activity-service:$TAG" -f services/activity-service/Dockerfile .
|
||||
docker build -t "$REGISTRY/game-service:$TAG" -f services/game-service/Dockerfile .
|
||||
docker build -t "$REGISTRY/notice-service:$TAG" -f services/notice-service/Dockerfile .
|
||||
|
||||
docker push "$REGISTRY/gateway-service:$TAG"
|
||||
docker push "$REGISTRY/room-service:$TAG"
|
||||
docker push "$REGISTRY/user-service:$TAG"
|
||||
docker push "$REGISTRY/wallet-service:$TAG"
|
||||
docker push "$REGISTRY/activity-service:$TAG"
|
||||
docker push "$REGISTRY/game-service:$TAG"
|
||||
docker push "$REGISTRY/notice-service:$TAG"
|
||||
```
|
||||
|
||||
如果实际用 Harbor,把 `REGISTRY` 换成 `${HARBOR_REGISTRY}/${HARBOR_PROJECT}`,并先 `docker login`。
|
||||
|
||||
## 配置 inventory
|
||||
|
||||
复制模板后填真实 CLB:
|
||||
|
||||
```bash
|
||||
cp deploy/tencent-tat/inventory.prod.example.json deploy/tencent-tat/inventory.prod.json
|
||||
vi deploy/tencent-tat/inventory.prod.json
|
||||
```
|
||||
|
||||
必须替换:
|
||||
|
||||
- `gateway-service.clb.load_balancer_id`:公网 gateway CLB。
|
||||
- `gateway-service.clb.listener_ids`:公网 HTTP/HTTPS 监听器。
|
||||
- 内部服务各自的 `load_balancer_id` 和 `listener_ids`:room/user/wallet/activity/game 内网 CLB。
|
||||
|
||||
脚本通过实例 ID、私网 IP、端口三者匹配 CLB target。发布前会把当前实例在相关 listener 下的权重改成 `0`,服务健康后恢复原权重。
|
||||
|
||||
`managed_dependencies` 只记录部署边界:腾讯云 MySQL/Redis/MQ 是外部托管依赖,不纳入 TAT 发布动作。生产变更这些资源时单独走云数据库、Redis 或 MQ 的变更流程。
|
||||
|
||||
当前生产 inventory 已写入真实绑定:
|
||||
|
||||
| 服务 | CLB | 监听器 | 对外/内网入口 |
|
||||
| --- | --- | --- | --- |
|
||||
| `gateway-service` | `lb-cbiabr1q` | `lbl-ktvz8m6k` / rule `loc-pwedmsmm` | `https://api.global-interaction.com` -> `13000` |
|
||||
| `room-service` | `lb-epvnr4o0` | `lbl-d2urlzba` | `10.2.1.16:13001` |
|
||||
| `user-service` | `lb-epvnr4o0` | `lbl-honi8z3a` | `10.2.1.16:13005` |
|
||||
| `activity-service` | `lb-epvnr4o0` | `lbl-j7oqtfhq` | `10.2.1.16:13006` |
|
||||
| `game-service` | `lb-epvnr4o0` | `lbl-ku138b4y` | `10.2.1.16:13008` |
|
||||
| `notice-service` | `lb-epvnr4o0` | `lbl-aux9xsjq` | `10.2.1.16:13009` |
|
||||
| `wallet-service` | `lb-4f5xi6p0` | `lbl-9wi5mvu4` | `10.2.1.15:13004` |
|
||||
|
||||
Harbor 项目使用 `hyapp`,当前生产镜像前缀是 `10.2.1.3:18082/hyapp`。不要使用其他业务项目名前缀。
|
||||
|
||||
## 线上服务配置边界
|
||||
|
||||
这些机器上只部署 Go 服务进程:
|
||||
|
||||
- `gateway-service`:`room_service_addr/user_service_addr/wallet_service_addr/activity_service_addr/game_service_addr` 指向各服务内网 CLB/DNS。
|
||||
- `room-service`:`mysql_dsn` 指向腾讯云 MySQL,`redis_addr` 指向腾讯云 Redis;`advertise_addr` 指向本机私网 IP。
|
||||
- `user-service`:`mysql_dsn`、worker 里引用的 wallet/room MySQL DSN、登录风控 Redis 都指向腾讯云托管实例。
|
||||
- `wallet-service`、`activity-service`、`game-service`:MySQL 和下游 gRPC 地址都使用腾讯云托管资源或内网 CLB/DNS。
|
||||
- `notice-service`:`mysql_dsn` 指向 `hyapp_notice`,`wallet_database` 指向 `hyapp_wallet`;它只读取 wallet outbox 并写自己的投递位点,不修改钱包事实表。
|
||||
- MQ 当前只作为 outbox/event bus 方向的外部托管依赖;本脚本不会创建 topic、consumer group 或本地 broker。
|
||||
|
||||
腾讯云 MySQL DSN 必须保留 `loc=UTC`。Redis 使用同一托管实例时,用 `redis_db` 或 key prefix 区分业务域,不要把 Redis 当恢复事实来源。
|
||||
|
||||
## 常用命令
|
||||
|
||||
先看计划,不执行远端动作:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py plan \
|
||||
--inventory deploy/tencent-tat/inventory.prod.json \
|
||||
--services gateway-service,room-service
|
||||
```
|
||||
|
||||
用 TAT 连接目标机并自动发现真实部署状态和 CLB 绑定:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py discover \
|
||||
--env-file /opt/hy-app-monitor/.env \
|
||||
--inventory /opt/deploy/inventory/hyapp.prod.json \
|
||||
--services gateway-service,room-service,user-service,wallet-service,activity-service,game-service,notice-service
|
||||
```
|
||||
|
||||
`discover` 会读取每台目标机的 `hyapp-*` systemd unit、`/etc/hyapp/*/docker.env`、`hyapp-*` 容器和监听端口,并通过 CLB API 按后端私网 IP/端口反查负载均衡绑定。
|
||||
|
||||
单服务重启:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py restart \
|
||||
--env-file /opt/hy-app-monitor/.env \
|
||||
--inventory /opt/deploy/inventory/hyapp.prod.json \
|
||||
--services wallet-service \
|
||||
--yes
|
||||
```
|
||||
|
||||
多服务重启:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py restart \
|
||||
--env-file /opt/hy-app-monitor/.env \
|
||||
--inventory /opt/deploy/inventory/hyapp.prod.json \
|
||||
--services room-service,user-service,wallet-service,activity-service,game-service,notice-service \
|
||||
--yes
|
||||
```
|
||||
|
||||
单服务更新部署:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy \
|
||||
--env-file /opt/hy-app-monitor/.env \
|
||||
--inventory /opt/deploy/inventory/hyapp.prod.json \
|
||||
--services gateway-service \
|
||||
--tag 20260513-1349 \
|
||||
--yes
|
||||
```
|
||||
|
||||
多服务更新部署:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy \
|
||||
--env-file /opt/hy-app-monitor/.env \
|
||||
--inventory /opt/deploy/inventory/hyapp.prod.json \
|
||||
--services gateway-service,room-service,user-service,wallet-service,activity-service,game-service,notice-service \
|
||||
--tag 20260513-1349 \
|
||||
--yes
|
||||
```
|
||||
|
||||
如果某个服务镜像 tag 不同,可以显式传镜像:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy \
|
||||
--env-file /opt/hy-app-monitor/.env \
|
||||
--inventory /opt/deploy/inventory/hyapp.prod.json \
|
||||
--services gateway-service,wallet-service \
|
||||
--image gateway-service=10.2.1.3:18082/hyapp/gateway-service:gw-20260513 \
|
||||
--image wallet-service=10.2.1.3:18082/hyapp/wallet-service:pay-20260513 \
|
||||
--yes
|
||||
```
|
||||
|
||||
只维护某一台机器时使用 `--hosts`,例如只重启 `gateway-1`:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py restart \
|
||||
--env-file /opt/hy-app-monitor/.env \
|
||||
--inventory /opt/deploy/inventory/hyapp.prod.json \
|
||||
--services gateway-service \
|
||||
--hosts gateway-1 \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 无感更新顺序
|
||||
|
||||
对每个服务实例,脚本固定执行:
|
||||
|
||||
1. 读取该实例当前 CLB 绑定和原权重。
|
||||
2. 将该实例在所有匹配 listener/rule 下的权重改为 `0`。
|
||||
3. 等待 `drain_seconds`,让 CLB 停止分配新请求。
|
||||
4. 通过 TAT 在目标机更新 `docker.env` 的 `IMAGE`,执行 `docker pull`。
|
||||
5. `systemctl restart hyapp-<service>`,Docker 发送 `SIGTERM`,服务进入 graceful shutdown。
|
||||
6. 等待 systemd active 和 Docker health 恢复。
|
||||
7. 恢复该实例原 CLB 权重。
|
||||
8. 等待 `stabilize_seconds` 后进入下一台实例。
|
||||
|
||||
如果重启或健康检查失败,脚本会停止后续步骤;已经摘流的故障实例保持权重 `0`,避免坏实例重新接流,需要人工确认后再恢复。
|
||||
|
||||
## 用户无感边界
|
||||
|
||||
- 客户端长连接、房间公屏、单聊仍由腾讯云 IM 承接,gateway 重启不会断开 IM。
|
||||
- gateway 是 HTTP 入口,摘流后只影响当前实例的新 HTTP 请求;另一台 gateway 继续接流。
|
||||
- 内部服务通过各自内网 CLB 摘流滚动;gateway 的 gRPC client 已配置短重试,只覆盖瞬时 `UNAVAILABLE`。
|
||||
- `room-service` 已有 owner route/node registry。旧 owner 停机时会释放 lease;异常退出时最多等 `lease_ttl` 到期后其他节点接管。
|
||||
- 正在执行的单个 HTTP/gRPC 请求可能返回失败,客户端必须按现有接口幂等键重试;“无感”指发布期间整体入口不断流、房间 IM 不断连。
|
||||
|
||||
## 禁止项
|
||||
|
||||
- 不要使用 `--no-clb` 做正常发布;它只适合孤立排障。
|
||||
- 不要多个 `room-service` 共用同一个 `node_id`。
|
||||
- 不要把 `room-service.advertise_addr` 写成 CLB 地址。
|
||||
- 不要先 `systemctl restart` 再摘 CLB。
|
||||
- 不要在同机 Java compose 目录里执行本仓库部署命令。
|
||||
@ -1,703 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def load_env_file(path: str) -> None:
|
||||
# hy-app-monitor 现有 .env 可直接复用;这里只注入缺失变量,不覆盖调用方显式 export 的值。
|
||||
if not path:
|
||||
return
|
||||
env_path = Path(path).expanduser().resolve()
|
||||
if not env_path.exists():
|
||||
raise RuntimeError(f"env file not found: {env_path}")
|
||||
for raw_line in env_path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def load_inventory(path: str) -> dict[str, Any]:
|
||||
inventory_path = Path(path).expanduser().resolve()
|
||||
with inventory_path.open("r", encoding="utf-8") as handle:
|
||||
inventory = json.load(handle)
|
||||
validate_inventory(inventory)
|
||||
return inventory
|
||||
|
||||
|
||||
def validate_inventory(inventory: dict[str, Any]) -> None:
|
||||
# 发布脚本只认明确的 host/service 映射,避免误操作同机已有 Java 项目。
|
||||
if not isinstance(inventory.get("hosts"), dict) or not inventory["hosts"]:
|
||||
raise RuntimeError("inventory.hosts is required")
|
||||
if not isinstance(inventory.get("services"), dict) or not inventory["services"]:
|
||||
raise RuntimeError("inventory.services is required")
|
||||
for host_name, host in inventory["hosts"].items():
|
||||
if not str(host.get("instance_id") or "").strip():
|
||||
raise RuntimeError(f"hosts.{host_name}.instance_id is required")
|
||||
if not str(host.get("private_ip") or "").strip():
|
||||
raise RuntimeError(f"hosts.{host_name}.private_ip is required")
|
||||
for service_name, service in inventory["services"].items():
|
||||
for key in ("unit", "container", "env_file", "target_port", "hosts"):
|
||||
if key not in service:
|
||||
raise RuntimeError(f"services.{service_name}.{key} is required")
|
||||
for host_name in service["hosts"]:
|
||||
if host_name not in inventory["hosts"]:
|
||||
raise RuntimeError(f"services.{service_name}.hosts contains unknown host: {host_name}")
|
||||
|
||||
|
||||
def csv_values(text: str) -> list[str]:
|
||||
return [item.strip() for item in str(text or "").split(",") if item.strip()]
|
||||
|
||||
|
||||
def selected_service_names(inventory: dict[str, Any], raw_services: str) -> list[str]:
|
||||
names = csv_values(raw_services)
|
||||
if not names:
|
||||
raise RuntimeError("--services is required")
|
||||
unknown = [name for name in names if name not in inventory["services"]]
|
||||
if unknown:
|
||||
raise RuntimeError(f"unsupported services: {', '.join(unknown)}")
|
||||
return list(dict.fromkeys(names))
|
||||
|
||||
|
||||
def selected_host_names(service: dict[str, Any], raw_hosts: str) -> list[str]:
|
||||
hosts = list(dict.fromkeys(str(item) for item in service.get("hosts") or []))
|
||||
requested = csv_values(raw_hosts)
|
||||
if not requested:
|
||||
return hosts
|
||||
missing = [host for host in requested if host not in hosts]
|
||||
if missing:
|
||||
raise RuntimeError(f"selected hosts are not assigned to this service: {', '.join(missing)}")
|
||||
return requested
|
||||
|
||||
|
||||
def image_overrides(items: list[str]) -> dict[str, str]:
|
||||
overrides: dict[str, str] = {}
|
||||
for item in items:
|
||||
if "=" not in item:
|
||||
raise RuntimeError("--image must use service=image")
|
||||
service_name, image = item.split("=", 1)
|
||||
service_name = service_name.strip()
|
||||
image = image.strip()
|
||||
if not service_name or not image:
|
||||
raise RuntimeError("--image must use non-empty service=image")
|
||||
overrides[service_name] = image
|
||||
return overrides
|
||||
|
||||
|
||||
def render_image(inventory: dict[str, Any], service_name: str, tag: str, overrides: dict[str, str]) -> str:
|
||||
if service_name in overrides:
|
||||
return overrides[service_name]
|
||||
if not tag:
|
||||
raise RuntimeError(f"--tag or --image {service_name}=... is required for deploy")
|
||||
service = inventory["services"][service_name]
|
||||
registry = str(inventory.get("registry") or "").rstrip("/")
|
||||
template = str(service.get("image_template") or "${REGISTRY}/" + service_name + ":${TAG}")
|
||||
return template.replace("${REGISTRY}", registry).replace("${TAG}", tag)
|
||||
|
||||
|
||||
def build_plan(
|
||||
inventory: dict[str, Any],
|
||||
*,
|
||||
action: str,
|
||||
service_names: list[str],
|
||||
raw_hosts: str,
|
||||
tag: str,
|
||||
overrides: dict[str, str],
|
||||
no_clb: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
# 顺序是服务优先、实例串行;多服务发布不会同时摘掉同一组双机实例。
|
||||
plan: list[dict[str, Any]] = []
|
||||
for service_name in service_names:
|
||||
service = inventory["services"][service_name]
|
||||
image = render_image(inventory, service_name, tag, overrides) if action == "deploy" else ""
|
||||
for host_name in selected_host_names(service, raw_hosts):
|
||||
host = inventory["hosts"][host_name]
|
||||
clb = service.get("clb") or {}
|
||||
plan.append(
|
||||
{
|
||||
"action": action,
|
||||
"service": service_name,
|
||||
"host": host_name,
|
||||
"instance_id": host["instance_id"],
|
||||
"private_ip": host["private_ip"],
|
||||
"target_port": int(service["target_port"]),
|
||||
"unit": service["unit"],
|
||||
"container": service["container"],
|
||||
"image": image,
|
||||
"clb_enabled": bool(clb.get("enabled")) and not no_clb,
|
||||
"clb_load_balancer_id": str(clb.get("load_balancer_id") or ""),
|
||||
"clb_listener_ids": list(clb.get("listener_ids") or []),
|
||||
"drain_seconds": int(clb.get("drain_seconds") or 0),
|
||||
}
|
||||
)
|
||||
return plan
|
||||
|
||||
|
||||
def secret_id() -> str:
|
||||
return os.environ.get("TENCENTCLOUD_SECRET_ID") or os.environ.get("TENCENT_SECRET_ID") or ""
|
||||
|
||||
|
||||
def secret_key() -> str:
|
||||
return os.environ.get("TENCENTCLOUD_SECRET_KEY") or os.environ.get("TENCENT_SECRET_KEY") or ""
|
||||
|
||||
|
||||
def region(inventory: dict[str, Any]) -> str:
|
||||
return os.environ.get("TENCENTCLOUD_REGION") or os.environ.get("DEPLOY_REGION") or str(inventory.get("region") or "")
|
||||
|
||||
|
||||
def require_tencent_credentials(inventory: dict[str, Any]) -> tuple[str, str, str]:
|
||||
sid = secret_id()
|
||||
skey = secret_key()
|
||||
reg = region(inventory)
|
||||
missing = []
|
||||
if not sid:
|
||||
missing.append("TENCENTCLOUD_SECRET_ID")
|
||||
if not skey:
|
||||
missing.append("TENCENTCLOUD_SECRET_KEY")
|
||||
if not reg:
|
||||
missing.append("TENCENTCLOUD_REGION")
|
||||
if missing:
|
||||
raise RuntimeError("missing Tencent Cloud credentials: " + ", ".join(missing))
|
||||
return sid, skey, reg
|
||||
|
||||
|
||||
def build_tat_client(inventory: dict[str, Any]) -> Any:
|
||||
sid, skey, reg = require_tencent_credentials(inventory)
|
||||
try:
|
||||
from tencentcloud.common import credential
|
||||
from tencentcloud.common.profile.client_profile import ClientProfile
|
||||
from tencentcloud.common.profile.http_profile import HttpProfile
|
||||
from tencentcloud.tat.v20201028 import tat_client
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise RuntimeError(f"tencentcloud sdk unavailable: {exc}") from exc
|
||||
return tat_client.TatClient(
|
||||
credential.Credential(sid, skey),
|
||||
reg,
|
||||
ClientProfile(httpProfile=HttpProfile(endpoint="tat.tencentcloudapi.com")),
|
||||
)
|
||||
|
||||
|
||||
def build_clb_client(inventory: dict[str, Any]) -> Any:
|
||||
sid, skey, reg = require_tencent_credentials(inventory)
|
||||
try:
|
||||
from tencentcloud.clb.v20180317 import clb_client
|
||||
from tencentcloud.common import credential
|
||||
from tencentcloud.common.profile.client_profile import ClientProfile
|
||||
from tencentcloud.common.profile.http_profile import HttpProfile
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise RuntimeError(f"tencentcloud sdk unavailable: {exc}") from exc
|
||||
return clb_client.ClbClient(
|
||||
credential.Credential(sid, skey),
|
||||
reg,
|
||||
ClientProfile(httpProfile=HttpProfile(endpoint="clb.tencentcloudapi.com")),
|
||||
)
|
||||
|
||||
|
||||
def invoke_tencent(action: Any, label: str, retries: int = 3) -> Any:
|
||||
try:
|
||||
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
|
||||
except Exception: # noqa: BLE001
|
||||
TencentCloudSDKException = Exception # type: ignore[assignment]
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
return action()
|
||||
except TencentCloudSDKException as exc: # type: ignore[misc]
|
||||
last_error = exc
|
||||
if attempt == retries:
|
||||
break
|
||||
time.sleep(2 * attempt)
|
||||
raise RuntimeError(f"{label} failed: {last_error}") from last_error
|
||||
|
||||
|
||||
def decode_tat_output(output: str) -> str:
|
||||
if not output:
|
||||
return ""
|
||||
return base64.b64decode(output).decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def run_tat_shell(
|
||||
client: Any,
|
||||
*,
|
||||
instance_id: str,
|
||||
script: str,
|
||||
command_name: str,
|
||||
timeout_seconds: int,
|
||||
poll_seconds: int,
|
||||
) -> dict[str, Any]:
|
||||
# TAT 是本方案唯一远端入口;脚本不会要求目标机开放 SSH。
|
||||
from tencentcloud.tat.v20201028 import models as tat_models
|
||||
|
||||
request = tat_models.RunCommandRequest()
|
||||
request.from_json_string(
|
||||
json.dumps(
|
||||
{
|
||||
"CommandName": command_name,
|
||||
"CommandType": "SHELL",
|
||||
"Content": base64.b64encode(script.encode("utf-8")).decode("ascii"),
|
||||
"InstanceIds": [instance_id],
|
||||
"Timeout": timeout_seconds,
|
||||
"WorkingDirectory": "/root",
|
||||
}
|
||||
)
|
||||
)
|
||||
response = json.loads(invoke_tencent(lambda: client.RunCommand(request), "RunCommand").to_json_string())
|
||||
invocation_id = response["InvocationId"]
|
||||
deadline = time.time() + timeout_seconds + 60
|
||||
while time.time() < deadline:
|
||||
time.sleep(max(poll_seconds, 1))
|
||||
query = tat_models.DescribeInvocationTasksRequest()
|
||||
query.from_json_string(
|
||||
json.dumps(
|
||||
{
|
||||
"HideOutput": False,
|
||||
"Limit": 50,
|
||||
"Filters": [{"Name": "invocation-id", "Values": [invocation_id]}],
|
||||
}
|
||||
)
|
||||
)
|
||||
payload = json.loads(invoke_tencent(lambda: client.DescribeInvocationTasks(query), "DescribeInvocationTasks").to_json_string())
|
||||
for task in payload.get("InvocationTaskSet") or []:
|
||||
if str(task.get("InstanceId") or "") != instance_id:
|
||||
continue
|
||||
status = str(task.get("TaskStatus") or "")
|
||||
if status in {"SUCCESS", "FAILED", "TIMEOUT", "CANCELLED", "START_FAILED"}:
|
||||
return {
|
||||
"status": status,
|
||||
"output": decode_tat_output(str((task.get("TaskResult") or {}).get("Output") or "")),
|
||||
}
|
||||
return {"status": "TIMEOUT", "output": ""}
|
||||
|
||||
|
||||
def call_clb(client: Any, method_name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
from tencentcloud.clb.v20180317 import models as clb_models
|
||||
|
||||
request_class = getattr(clb_models, f"{method_name}Request")
|
||||
request = request_class()
|
||||
request.from_json_string(json.dumps(payload))
|
||||
response = invoke_tencent(lambda: getattr(client, method_name)(request), method_name)
|
||||
decoded = json.loads(response.to_json_string())
|
||||
if isinstance(decoded.get("Response"), dict):
|
||||
return dict(decoded["Response"])
|
||||
return dict(decoded)
|
||||
|
||||
|
||||
def wait_clb_task(client: Any, task_id: str, *, timeout_seconds: int, poll_seconds: int) -> None:
|
||||
# ModifyTargetWeight 是异步任务,必须轮询到成功后才能重启实例。
|
||||
deadline = time.time() + max(timeout_seconds, 1)
|
||||
while time.time() < deadline:
|
||||
payload = call_clb(client, "DescribeTaskStatus", {"TaskId": task_id})
|
||||
status = int(payload.get("Status") if payload.get("Status") is not None else 2)
|
||||
if status == 0:
|
||||
return
|
||||
if status == 1:
|
||||
raise RuntimeError(f"CLB task failed: {payload.get('Message') or task_id}")
|
||||
time.sleep(max(poll_seconds, 1))
|
||||
raise RuntimeError(f"CLB task timed out: {task_id}")
|
||||
|
||||
|
||||
def target_matches(step: dict[str, Any], target: dict[str, Any]) -> bool:
|
||||
instance_id = str(target.get("InstanceId") or target.get("TargetId") or "").strip()
|
||||
if instance_id and instance_id != step["instance_id"]:
|
||||
return False
|
||||
target_port = int(target.get("Port") or 0)
|
||||
if target_port and target_port != int(step["target_port"]):
|
||||
return False
|
||||
ips = [str(item or "").strip() for item in (target.get("PrivateIpAddresses") or [target.get("IP")]) if str(item or "").strip()]
|
||||
if ips and step["private_ip"] not in ips:
|
||||
return False
|
||||
return bool(instance_id or ips)
|
||||
|
||||
|
||||
def binding_key(binding: dict[str, Any]) -> str:
|
||||
return "|".join(
|
||||
[
|
||||
str(binding.get("listener_id") or ""),
|
||||
str(binding.get("location_id") or ""),
|
||||
str(binding.get("instance_id") or ""),
|
||||
str(int(binding.get("port") or 0)),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def collect_bindings(step: dict[str, Any], listeners: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
bindings: dict[str, dict[str, Any]] = {}
|
||||
for listener in listeners:
|
||||
listener_id = str(listener.get("ListenerId") or "").strip()
|
||||
for target in listener.get("Targets") or []:
|
||||
if target_matches(step, target):
|
||||
binding = make_binding(step, listener_id, "", target)
|
||||
bindings[binding_key(binding)] = binding
|
||||
for rule in listener.get("Rules") or []:
|
||||
location_id = str(rule.get("LocationId") or "").strip()
|
||||
for target in rule.get("Targets") or []:
|
||||
if target_matches(step, target):
|
||||
binding = make_binding(step, listener_id, location_id, target)
|
||||
bindings[binding_key(binding)] = binding
|
||||
return list(bindings.values())
|
||||
|
||||
|
||||
def make_binding(step: dict[str, Any], listener_id: str, location_id: str, target: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"listener_id": listener_id,
|
||||
"location_id": location_id,
|
||||
"instance_id": str(target.get("InstanceId") or target.get("TargetId") or step["instance_id"]).strip(),
|
||||
"port": int(target.get("Port") or step["target_port"]),
|
||||
"weight": int(target.get("Weight") or 0),
|
||||
}
|
||||
|
||||
|
||||
def prepare_clb_snapshot(client: Any, step: dict[str, Any]) -> dict[str, Any]:
|
||||
if should_auto_discover_clb(step):
|
||||
return discover_clb_snapshot(client, step)
|
||||
payload = call_clb(
|
||||
client,
|
||||
"DescribeTargets",
|
||||
{
|
||||
"LoadBalancerId": step["clb_load_balancer_id"],
|
||||
"ListenerIds": step["clb_listener_ids"],
|
||||
},
|
||||
)
|
||||
bindings = collect_bindings(step, list(payload.get("Listeners") or []))
|
||||
if not bindings:
|
||||
return discover_clb_snapshot(client, step)
|
||||
return {"step": step, "bindings": bindings}
|
||||
|
||||
|
||||
def should_auto_discover_clb(step: dict[str, Any]) -> bool:
|
||||
load_balancer_id = str(step.get("clb_load_balancer_id") or "").strip()
|
||||
listener_ids = [str(item or "").strip() for item in step.get("clb_listener_ids") or []]
|
||||
if not load_balancer_id or "REPLACE" in load_balancer_id or load_balancer_id.upper() == "AUTO":
|
||||
return True
|
||||
return any((not item) or "REPLACE" in item or item.upper() == "AUTO" for item in listener_ids)
|
||||
|
||||
|
||||
def discover_load_balancers_for_backend(client: Any, step: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
# 腾讯云 CLB 支持按后端私网 IP 反查负载均衡,避免手填内网 CLB ID。
|
||||
offset = 0
|
||||
found: list[dict[str, Any]] = []
|
||||
while True:
|
||||
payload = call_clb(
|
||||
client,
|
||||
"DescribeLoadBalancers",
|
||||
{
|
||||
"BackendPrivateIps": [step["private_ip"]],
|
||||
"WithRs": 1,
|
||||
"Limit": 100,
|
||||
"Offset": offset,
|
||||
},
|
||||
)
|
||||
items = list(payload.get("LoadBalancerSet") or [])
|
||||
found.extend(items)
|
||||
total = int(payload.get("TotalCount") or len(found))
|
||||
if len(found) >= total or not items:
|
||||
break
|
||||
offset += len(items)
|
||||
return found
|
||||
|
||||
|
||||
def discover_clb_snapshot(client: Any, step: dict[str, Any]) -> dict[str, Any]:
|
||||
load_balancers = discover_load_balancers_for_backend(client, step)
|
||||
matched: list[dict[str, Any]] = []
|
||||
for load_balancer in load_balancers:
|
||||
load_balancer_id = str(load_balancer.get("LoadBalancerId") or "").strip()
|
||||
if not load_balancer_id:
|
||||
continue
|
||||
payload = call_clb(client, "DescribeTargets", {"LoadBalancerId": load_balancer_id})
|
||||
bindings = collect_bindings(step, list(payload.get("Listeners") or []))
|
||||
if bindings:
|
||||
matched.append({"load_balancer_id": load_balancer_id, "bindings": bindings})
|
||||
|
||||
if not matched:
|
||||
raise RuntimeError(f"CLB target not found for {step['service']} on {step['host']} {step['private_ip']}:{step['target_port']}")
|
||||
if len(matched) > 1:
|
||||
summary = ", ".join(item["load_balancer_id"] for item in matched)
|
||||
raise RuntimeError(f"multiple CLB bindings discovered for {step['service']} on {step['host']}: {summary}")
|
||||
|
||||
discovered = matched[0]
|
||||
step["clb_load_balancer_id"] = discovered["load_balancer_id"]
|
||||
step["clb_listener_ids"] = sorted({binding["listener_id"] for binding in discovered["bindings"] if binding["listener_id"]})
|
||||
return {"step": step, "bindings": discovered["bindings"], "discovered": True}
|
||||
|
||||
|
||||
def modify_binding_weight(client: Any, inventory: dict[str, Any], binding: dict[str, Any], weight: int, load_balancer_id: str) -> None:
|
||||
payload: dict[str, Any] = {
|
||||
"LoadBalancerId": load_balancer_id,
|
||||
"ListenerId": binding["listener_id"],
|
||||
"Targets": [{"InstanceId": binding["instance_id"], "Port": int(binding["port"])}],
|
||||
"Weight": int(weight),
|
||||
}
|
||||
if binding.get("location_id"):
|
||||
payload["LocationId"] = binding["location_id"]
|
||||
response = call_clb(client, "ModifyTargetWeight", payload)
|
||||
wait_clb_task(
|
||||
client,
|
||||
str(response.get("RequestId") or "").strip(),
|
||||
timeout_seconds=int(inventory.get("clb_task_timeout_seconds") or 180),
|
||||
poll_seconds=int(inventory.get("clb_poll_seconds") or 2),
|
||||
)
|
||||
|
||||
|
||||
def set_clb_snapshot_weight(client: Any, inventory: dict[str, Any], snapshot: dict[str, Any], weight: int | None) -> None:
|
||||
step = snapshot["step"]
|
||||
for binding in snapshot["bindings"]:
|
||||
target_weight = int(binding["weight"]) if weight is None else int(weight)
|
||||
modify_binding_weight(client, inventory, binding, target_weight, step["clb_load_balancer_id"])
|
||||
|
||||
|
||||
def remote_restart_script(service: dict[str, Any], *, image: str, health_timeout_seconds: int) -> str:
|
||||
# 远端只改当前 Go 服务的 docker.env 和 systemd unit,避免碰同机 Java compose。
|
||||
lines = [
|
||||
"set -euo pipefail",
|
||||
f"UNIT={shlex.quote(str(service['unit']))}",
|
||||
f"CONTAINER={shlex.quote(str(service['container']))}",
|
||||
f"ENV_FILE={shlex.quote(str(service['env_file']))}",
|
||||
f"HEALTH_TIMEOUT={int(health_timeout_seconds)}",
|
||||
]
|
||||
if image:
|
||||
lines.extend(
|
||||
[
|
||||
f"IMAGE={shlex.quote(image)}",
|
||||
'test -f "$ENV_FILE"',
|
||||
'TMP_FILE="$(mktemp)"',
|
||||
'awk -v image="$IMAGE" \'BEGIN{done=0} /^IMAGE=/{print "IMAGE=" image; done=1; next} {print} END{if(!done) print "IMAGE=" image}\' "$ENV_FILE" > "$TMP_FILE"',
|
||||
'install -m 0640 "$TMP_FILE" "$ENV_FILE"',
|
||||
'rm -f "$TMP_FILE"',
|
||||
'docker pull "$IMAGE"',
|
||||
]
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
'systemctl restart "$UNIT"',
|
||||
'deadline=$((SECONDS + HEALTH_TIMEOUT))',
|
||||
'while ! systemctl is-active --quiet "$UNIT"; do',
|
||||
' if [ "$SECONDS" -ge "$deadline" ]; then',
|
||||
' systemctl status "$UNIT" --no-pager || true',
|
||||
' exit 20',
|
||||
" fi",
|
||||
" sleep 2",
|
||||
"done",
|
||||
'while [ "$SECONDS" -lt "$deadline" ]; do',
|
||||
' state="$(docker inspect --format \'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}\' "$CONTAINER" 2>/dev/null || true)"',
|
||||
' if [ "$state" = "healthy" ] || [ "$state" = "running" ]; then',
|
||||
' docker ps --filter "name=$CONTAINER" --format "{{.Names}} {{.Status}}"',
|
||||
" exit 0",
|
||||
" fi",
|
||||
" sleep 2",
|
||||
"done",
|
||||
'docker inspect "$CONTAINER" 2>/dev/null || true',
|
||||
'journalctl -u "$UNIT" -n 80 --no-pager || true',
|
||||
"exit 21",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def remote_discover_script() -> str:
|
||||
# 只读取 hyapp 命名空间内的 unit/env/container,不扫描或修改 Java 项目。
|
||||
return r"""set -euo pipefail
|
||||
echo "== hyapp units =="
|
||||
systemctl list-units 'hyapp-*.service' --no-pager --all || true
|
||||
echo "== hyapp env files =="
|
||||
if [ -d /etc/hyapp ]; then
|
||||
find /etc/hyapp -maxdepth 2 -name docker.env -print 2>/dev/null | sort | while read -r env_file; do
|
||||
echo "-- $env_file"
|
||||
sed -n 's/^\(IMAGE\|CONTAINER_NAME\|CONFIG_PATH\|STOP_TIMEOUT_SEC\)=/\1=/p' "$env_file" || true
|
||||
done
|
||||
else
|
||||
echo "/etc/hyapp missing"
|
||||
fi
|
||||
echo "== hyapp containers =="
|
||||
docker ps --filter 'name=hyapp-' --format '{{.Names}} {{.Image}} {{.Status}}' || true
|
||||
echo "== listening ports =="
|
||||
ss -lntp 2>/dev/null | awk 'NR==1 || /:13000|:13001|:13004|:13005|:13006|:13008|:13009|:13010|:13100|:13101|:13104|:13105|:13106|:13108|:13109|:13110/' || true
|
||||
"""
|
||||
|
||||
|
||||
def execute_discovery(inventory: dict[str, Any], plan: list[dict[str, Any]], *, dry_run: bool) -> dict[str, Any]:
|
||||
if dry_run:
|
||||
return {"ok": True, "dry_run": True, "steps": plan}
|
||||
tat_client = build_tat_client(inventory)
|
||||
clb_client = build_clb_client(inventory)
|
||||
seen_hosts = list(dict.fromkeys(step["host"] for step in plan))
|
||||
by_host = {name: next(step for step in plan if step["host"] == name) for name in seen_hosts}
|
||||
hosts: list[dict[str, Any]] = []
|
||||
for host_name, seed in by_host.items():
|
||||
try:
|
||||
result = run_tat_shell(
|
||||
tat_client,
|
||||
instance_id=seed["instance_id"],
|
||||
script=remote_discover_script(),
|
||||
command_name=f"hyapp-discover-{host_name}",
|
||||
timeout_seconds=int(inventory.get("tat_timeout_seconds") or 900),
|
||||
poll_seconds=int(inventory.get("tat_poll_seconds") or 2),
|
||||
)
|
||||
hosts.append(
|
||||
{
|
||||
"host": host_name,
|
||||
"instance_id": seed["instance_id"],
|
||||
"private_ip": seed["private_ip"],
|
||||
"status": result["status"],
|
||||
"output": str(result.get("output") or "")[-5000:],
|
||||
}
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
hosts.append(
|
||||
{
|
||||
"host": host_name,
|
||||
"instance_id": seed["instance_id"],
|
||||
"private_ip": seed["private_ip"],
|
||||
"status": "ERROR",
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
|
||||
clb_bindings: list[dict[str, Any]] = []
|
||||
for step in plan:
|
||||
if not step["clb_enabled"]:
|
||||
continue
|
||||
try:
|
||||
snapshot = prepare_clb_snapshot(clb_client, dict(step))
|
||||
clb_bindings.append(
|
||||
{
|
||||
"service": step["service"],
|
||||
"host": step["host"],
|
||||
"load_balancer_id": snapshot["step"]["clb_load_balancer_id"],
|
||||
"listener_ids": snapshot["step"]["clb_listener_ids"],
|
||||
"bindings": snapshot["bindings"],
|
||||
"discovered": bool(snapshot.get("discovered")),
|
||||
}
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
clb_bindings.append(
|
||||
{
|
||||
"service": step["service"],
|
||||
"host": step["host"],
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
return {"ok": True, "hosts": hosts, "clb": clb_bindings}
|
||||
|
||||
|
||||
def execute_plan(inventory: dict[str, Any], plan: list[dict[str, Any]], *, dry_run: bool, assume_yes: bool) -> dict[str, Any]:
|
||||
if dry_run:
|
||||
return {"ok": True, "dry_run": True, "steps": plan}
|
||||
if not assume_yes:
|
||||
raise RuntimeError("real execution requires --yes")
|
||||
|
||||
tat_client = build_tat_client(inventory)
|
||||
clb_client = build_clb_client(inventory) if any(step["clb_enabled"] for step in plan) else None
|
||||
results: list[dict[str, Any]] = []
|
||||
for step in plan:
|
||||
started = time.time()
|
||||
record = dict(step)
|
||||
snapshot: dict[str, Any] | None = None
|
||||
try:
|
||||
if step["clb_enabled"]:
|
||||
snapshot = prepare_clb_snapshot(clb_client, step)
|
||||
set_clb_snapshot_weight(clb_client, inventory, snapshot, 0)
|
||||
if step["drain_seconds"] > 0:
|
||||
time.sleep(step["drain_seconds"])
|
||||
record["clb_drained"] = True
|
||||
|
||||
service_cfg = inventory["services"][step["service"]]
|
||||
script = remote_restart_script(
|
||||
service_cfg,
|
||||
image=step["image"] if step["action"] == "deploy" else "",
|
||||
health_timeout_seconds=int(inventory.get("service_health_timeout_seconds") or 150),
|
||||
)
|
||||
remote = run_tat_shell(
|
||||
tat_client,
|
||||
instance_id=step["instance_id"],
|
||||
script=script,
|
||||
command_name=f"hyapp-{step['action']}-{step['service']}-{step['host']}",
|
||||
timeout_seconds=int(inventory.get("tat_timeout_seconds") or 900),
|
||||
poll_seconds=int(inventory.get("tat_poll_seconds") or 2),
|
||||
)
|
||||
record["remote_status"] = remote["status"]
|
||||
record["remote_output"] = str(remote.get("output") or "")[-2000:]
|
||||
if remote["status"] != "SUCCESS":
|
||||
raise RuntimeError(f"TAT command failed: {remote['status']}")
|
||||
|
||||
if snapshot is not None:
|
||||
# CLB 后端健康由 CLB 自己持续探测;服务本地健康通过后恢复原权重。
|
||||
set_clb_snapshot_weight(clb_client, inventory, snapshot, None)
|
||||
record["clb_restored"] = True
|
||||
|
||||
stabilize = int(inventory.get("stabilize_seconds") or 0)
|
||||
if stabilize > 0:
|
||||
time.sleep(stabilize)
|
||||
record["status"] = "success"
|
||||
record["duration_seconds"] = round(time.time() - started, 2)
|
||||
results.append(record)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
record["status"] = "failed"
|
||||
record["error"] = str(exc)
|
||||
record["duration_seconds"] = round(time.time() - started, 2)
|
||||
results.append(record)
|
||||
return {"ok": False, "steps": results, "error": str(exc)}
|
||||
return {"ok": True, "steps": results}
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Deploy hyapp services through Tencent TAT with optional CLB drain.")
|
||||
parser.add_argument("action", choices=["plan", "discover", "restart", "deploy"])
|
||||
parser.add_argument("--inventory", default="deploy/tencent-tat/inventory.prod.example.json")
|
||||
parser.add_argument("--env-file", default="", help="Optional .env file with Tencent Cloud credentials.")
|
||||
parser.add_argument("--services", required=True, help="Comma separated service names.")
|
||||
parser.add_argument("--hosts", default="", help="Optional comma separated host names for one-node maintenance.")
|
||||
parser.add_argument("--tag", default="", help="Release tag used by image_template during deploy.")
|
||||
parser.add_argument("--image", action="append", default=[], help="Override one image: service=image. Can be repeated.")
|
||||
parser.add_argument("--no-clb", action="store_true", help="Skip CLB drain. Only use for isolated emergency maintenance.")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print the execution plan without calling Tencent APIs.")
|
||||
parser.add_argument("--yes", action="store_true", help="Required for real restart/deploy execution.")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
try:
|
||||
args = parse_args(argv)
|
||||
load_env_file(args.env_file)
|
||||
inventory = load_inventory(args.inventory)
|
||||
service_names = selected_service_names(inventory, args.services)
|
||||
overrides = image_overrides(args.image)
|
||||
action = "restart" if args.action == "plan" else args.action
|
||||
plan = build_plan(
|
||||
inventory,
|
||||
action=action,
|
||||
service_names=service_names,
|
||||
raw_hosts=args.hosts,
|
||||
tag=args.tag,
|
||||
overrides=overrides,
|
||||
no_clb=args.no_clb,
|
||||
)
|
||||
if args.action == "plan":
|
||||
print(json.dumps({"ok": True, "steps": plan}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if args.action == "discover":
|
||||
result = execute_discovery(inventory, plan, dry_run=args.dry_run)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0 if result.get("ok") else 1
|
||||
result = execute_plan(inventory, plan, dry_run=args.dry_run, assume_yes=args.yes)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0 if result.get("ok") else 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@ -1,218 +0,0 @@
|
||||
{
|
||||
"region": "me-saudi-arabia",
|
||||
"registry": "10.2.1.3:18082/hyapp",
|
||||
"tat_timeout_seconds": 900,
|
||||
"tat_poll_seconds": 2,
|
||||
"clb_task_timeout_seconds": 180,
|
||||
"clb_poll_seconds": 2,
|
||||
"service_health_timeout_seconds": 150,
|
||||
"stabilize_seconds": 5,
|
||||
"managed_dependencies": {
|
||||
"mysql": "TencentDB for MySQL private endpoint; this deployment never starts local MySQL.",
|
||||
"redis": "TencentDB for Redis private endpoint; room owner route and login/risk cache use this managed Redis.",
|
||||
"mq": "Tencent Cloud managed MQ; future consumers only receive connection/topic config and are not deployed as local infra."
|
||||
},
|
||||
"deploy_server": {
|
||||
"name": "deploy",
|
||||
"instance_id": "ins-ntmpcd3a",
|
||||
"public_ip": "43.164.75.199",
|
||||
"private_ip": "10.2.1.3",
|
||||
"role": "control plane only: build/push images and call Tencent TAT/CLB APIs; do not attach to business CLB."
|
||||
},
|
||||
"hosts": {
|
||||
"gateway-1": {
|
||||
"instance_id": "ins-aeyaoj40",
|
||||
"private_ip": "10.2.1.8"
|
||||
},
|
||||
"gateway-2": {
|
||||
"instance_id": "ins-irvi5b7u",
|
||||
"private_ip": "10.2.2.17"
|
||||
},
|
||||
"new-app-1": {
|
||||
"instance_id": "ins-fi5zufpk",
|
||||
"private_ip": "10.2.1.4"
|
||||
},
|
||||
"new-app-2": {
|
||||
"instance_id": "ins-hwhaoe8c",
|
||||
"private_ip": "10.2.1.13"
|
||||
},
|
||||
"new-game-1": {
|
||||
"instance_id": "ins-ir229jtw",
|
||||
"private_ip": "10.2.1.10"
|
||||
},
|
||||
"new-game-2": {
|
||||
"instance_id": "ins-460rw7gu",
|
||||
"private_ip": "10.2.1.6"
|
||||
},
|
||||
"new-app-activity-1": {
|
||||
"instance_id": "ins-n8wut6n8",
|
||||
"private_ip": "10.2.1.11"
|
||||
},
|
||||
"new-app-activity-2": {
|
||||
"instance_id": "ins-d1n303sa",
|
||||
"private_ip": "10.2.1.7"
|
||||
},
|
||||
"pay-1": {
|
||||
"instance_id": "ins-ceqfcxd2",
|
||||
"private_ip": "10.2.11.14"
|
||||
},
|
||||
"pay-2": {
|
||||
"instance_id": "ins-awhb74q6",
|
||||
"private_ip": "10.2.12.5"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"gateway-service": {
|
||||
"unit": "hyapp-gateway-service",
|
||||
"container": "hyapp-gateway-service",
|
||||
"env_file": "/etc/hyapp/gateway-service/docker.env",
|
||||
"image_template": "${REGISTRY}/gateway-service:${TAG}",
|
||||
"target_port": 13000,
|
||||
"hosts": [
|
||||
"gateway-1",
|
||||
"gateway-2"
|
||||
],
|
||||
"clb": {
|
||||
"enabled": true,
|
||||
"load_balancer_id": "lb-cbiabr1q",
|
||||
"listener_ids": [
|
||||
"lbl-ktvz8m6k"
|
||||
],
|
||||
"drain_seconds": 10
|
||||
}
|
||||
},
|
||||
"room-service": {
|
||||
"unit": "hyapp-room-service",
|
||||
"container": "hyapp-room-service",
|
||||
"env_file": "/etc/hyapp/room-service/docker.env",
|
||||
"image_template": "${REGISTRY}/room-service:${TAG}",
|
||||
"target_port": 13001,
|
||||
"hosts": [
|
||||
"new-app-1",
|
||||
"new-app-2"
|
||||
],
|
||||
"clb": {
|
||||
"enabled": true,
|
||||
"load_balancer_id": "lb-epvnr4o0",
|
||||
"listener_ids": [
|
||||
"lbl-d2urlzba"
|
||||
],
|
||||
"drain_seconds": 5
|
||||
}
|
||||
},
|
||||
"user-service": {
|
||||
"unit": "hyapp-user-service",
|
||||
"container": "hyapp-user-service",
|
||||
"env_file": "/etc/hyapp/user-service/docker.env",
|
||||
"image_template": "${REGISTRY}/user-service:${TAG}",
|
||||
"target_port": 13005,
|
||||
"hosts": [
|
||||
"new-app-1",
|
||||
"new-app-2"
|
||||
],
|
||||
"clb": {
|
||||
"enabled": true,
|
||||
"load_balancer_id": "lb-epvnr4o0",
|
||||
"listener_ids": [
|
||||
"lbl-honi8z3a"
|
||||
],
|
||||
"drain_seconds": 5
|
||||
}
|
||||
},
|
||||
"wallet-service": {
|
||||
"unit": "hyapp-wallet-service",
|
||||
"container": "hyapp-wallet-service",
|
||||
"env_file": "/etc/hyapp/wallet-service/docker.env",
|
||||
"image_template": "${REGISTRY}/wallet-service:${TAG}",
|
||||
"target_port": 13004,
|
||||
"hosts": [
|
||||
"pay-1",
|
||||
"pay-2"
|
||||
],
|
||||
"clb": {
|
||||
"enabled": true,
|
||||
"load_balancer_id": "lb-4f5xi6p0",
|
||||
"listener_ids": [
|
||||
"lbl-9wi5mvu4"
|
||||
],
|
||||
"drain_seconds": 5
|
||||
}
|
||||
},
|
||||
"activity-service": {
|
||||
"unit": "hyapp-activity-service",
|
||||
"container": "hyapp-activity-service",
|
||||
"env_file": "/etc/hyapp/activity-service/docker.env",
|
||||
"image_template": "${REGISTRY}/activity-service:${TAG}",
|
||||
"target_port": 13006,
|
||||
"hosts": [
|
||||
"new-app-activity-1",
|
||||
"new-app-activity-2"
|
||||
],
|
||||
"clb": {
|
||||
"enabled": true,
|
||||
"load_balancer_id": "lb-epvnr4o0",
|
||||
"listener_ids": [
|
||||
"lbl-j7oqtfhq"
|
||||
],
|
||||
"drain_seconds": 5
|
||||
}
|
||||
},
|
||||
"notice-service": {
|
||||
"unit": "hyapp-notice-service",
|
||||
"container": "hyapp-notice-service",
|
||||
"env_file": "/etc/hyapp/notice-service/docker.env",
|
||||
"image_template": "${REGISTRY}/notice-service:${TAG}",
|
||||
"target_port": 13009,
|
||||
"hosts": [
|
||||
"new-app-activity-1",
|
||||
"new-app-activity-2"
|
||||
],
|
||||
"clb": {
|
||||
"enabled": true,
|
||||
"load_balancer_id": "lb-epvnr4o0",
|
||||
"listener_ids": [
|
||||
"lbl-aux9xsjq"
|
||||
],
|
||||
"drain_seconds": 5
|
||||
}
|
||||
},
|
||||
"game-service": {
|
||||
"unit": "hyapp-game-service",
|
||||
"container": "hyapp-game-service",
|
||||
"env_file": "/etc/hyapp/game-service/docker.env",
|
||||
"image_template": "${REGISTRY}/game-service:${TAG}",
|
||||
"target_port": 13008,
|
||||
"hosts": [
|
||||
"new-game-1",
|
||||
"new-game-2"
|
||||
],
|
||||
"clb": {
|
||||
"enabled": true,
|
||||
"load_balancer_id": "lb-epvnr4o0",
|
||||
"listener_ids": [
|
||||
"lbl-ku138b4y"
|
||||
],
|
||||
"drain_seconds": 5
|
||||
}
|
||||
},
|
||||
"statistics-service": {
|
||||
"unit": "hyapp-statistics-service",
|
||||
"container": "hyapp-statistics-service",
|
||||
"env_file": "/etc/hyapp/statistics-service/docker.env",
|
||||
"image_template": "${REGISTRY}/statistics-service:${TAG}",
|
||||
"target_port": 13010,
|
||||
"hosts": [
|
||||
"new-app-1",
|
||||
"new-app-2"
|
||||
],
|
||||
"clb": {
|
||||
"enabled": true,
|
||||
"load_balancer_id": "lb-epvnr4o0",
|
||||
"listener_ids": [
|
||||
"lbl-a0myhr6q"
|
||||
],
|
||||
"drain_seconds": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,112 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from deploy.tencent_tat import hyapp_tat_deploy as deploy
|
||||
|
||||
|
||||
class TatDeployPlanTests(unittest.TestCase):
|
||||
def load_inventory(self) -> dict:
|
||||
return deploy.load_inventory("deploy/tencent-tat/inventory.prod.example.json")
|
||||
|
||||
def test_inventory_contains_current_cvm_topology(self) -> None:
|
||||
inventory = self.load_inventory()
|
||||
self.assertEqual(inventory["hosts"]["gateway-1"]["instance_id"], "ins-aeyaoj40")
|
||||
self.assertEqual(inventory["hosts"]["gateway-2"]["private_ip"], "10.2.2.17")
|
||||
self.assertEqual(inventory["services"]["wallet-service"]["hosts"], ["pay-1", "pay-2"])
|
||||
self.assertEqual(inventory["services"]["game-service"]["target_port"], 13008)
|
||||
self.assertEqual(inventory["services"]["notice-service"]["hosts"], ["new-app-activity-1", "new-app-activity-2"])
|
||||
self.assertEqual(inventory["services"]["notice-service"]["target_port"], 13009)
|
||||
self.assertIn("TencentDB for MySQL", inventory["managed_dependencies"]["mysql"])
|
||||
|
||||
def test_plan_is_service_first_and_keeps_clb_enabled(self) -> None:
|
||||
inventory = self.load_inventory()
|
||||
plan = deploy.build_plan(
|
||||
inventory,
|
||||
action="deploy",
|
||||
service_names=["gateway-service", "wallet-service"],
|
||||
raw_hosts="",
|
||||
tag="20260513",
|
||||
overrides={},
|
||||
no_clb=False,
|
||||
)
|
||||
self.assertEqual(
|
||||
[(step["service"], step["host"]) for step in plan],
|
||||
[
|
||||
("gateway-service", "gateway-1"),
|
||||
("gateway-service", "gateway-2"),
|
||||
("wallet-service", "pay-1"),
|
||||
("wallet-service", "pay-2"),
|
||||
],
|
||||
)
|
||||
self.assertTrue(all(step["clb_enabled"] for step in plan))
|
||||
self.assertEqual(plan[0]["image"], "10.2.1.3:18082/hyapp/gateway-service:20260513")
|
||||
|
||||
def test_host_filter_must_belong_to_service(self) -> None:
|
||||
inventory = self.load_inventory()
|
||||
with self.assertRaisesRegex(RuntimeError, "not assigned"):
|
||||
deploy.build_plan(
|
||||
inventory,
|
||||
action="restart",
|
||||
service_names=["gateway-service"],
|
||||
raw_hosts="pay-1",
|
||||
tag="",
|
||||
overrides={},
|
||||
no_clb=False,
|
||||
)
|
||||
|
||||
def test_remote_deploy_script_only_touches_hyapp_unit_and_env(self) -> None:
|
||||
inventory = self.load_inventory()
|
||||
service = inventory["services"]["gateway-service"]
|
||||
script = deploy.remote_restart_script(service, image="registry/gateway:tag", health_timeout_seconds=30)
|
||||
self.assertIn("systemctl restart \"$UNIT\"", script)
|
||||
self.assertIn("ENV_FILE=/etc/hyapp/gateway-service/docker.env", script)
|
||||
self.assertIn("docker pull \"$IMAGE\"", script)
|
||||
self.assertNotIn("docker compose", script)
|
||||
self.assertNotIn("java", script.lower())
|
||||
|
||||
def test_discover_plan_does_not_require_image_tag(self) -> None:
|
||||
inventory = self.load_inventory()
|
||||
plan = deploy.build_plan(
|
||||
inventory,
|
||||
action="discover",
|
||||
service_names=["gateway-service"],
|
||||
raw_hosts="",
|
||||
tag="",
|
||||
overrides={},
|
||||
no_clb=False,
|
||||
)
|
||||
self.assertEqual([step["action"] for step in plan], ["discover", "discover"])
|
||||
self.assertEqual([step["image"] for step in plan], ["", ""])
|
||||
|
||||
def test_real_clb_ids_disable_auto_discovery_but_placeholders_enable_it(self) -> None:
|
||||
inventory = self.load_inventory()
|
||||
step = deploy.build_plan(
|
||||
inventory,
|
||||
action="restart",
|
||||
service_names=["gateway-service"],
|
||||
raw_hosts="gateway-1",
|
||||
tag="",
|
||||
overrides={},
|
||||
no_clb=False,
|
||||
)[0]
|
||||
self.assertFalse(deploy.should_auto_discover_clb(step))
|
||||
|
||||
step["clb_load_balancer_id"] = "lb-REPLACE_PUBLIC_GATEWAY"
|
||||
self.assertTrue(deploy.should_auto_discover_clb(step))
|
||||
|
||||
def test_validate_inventory_rejects_unknown_service_host(self) -> None:
|
||||
inventory = self.load_inventory()
|
||||
inventory["services"]["gateway-service"]["hosts"] = ["missing-host"]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "inventory.json"
|
||||
path.write_text(json.dumps(inventory), encoding="utf-8")
|
||||
with self.assertRaisesRegex(RuntimeError, "unknown host"):
|
||||
deploy.load_inventory(str(path))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -1,124 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
CONFIG_FILENAMES = ("config.yaml", "config.docker.yaml", "config.tencent.example.yaml")
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
raise RuntimeError(f"config file missing: {path}")
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError(f"config file must be a mapping: {path}")
|
||||
return data
|
||||
|
||||
|
||||
def load_inventory(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
raise RuntimeError(f"inventory missing: {path}")
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data.get("services"), dict):
|
||||
raise RuntimeError("inventory.services is required")
|
||||
return data
|
||||
|
||||
|
||||
def mapping_paths(value: Any, prefix: str = "") -> set[str]:
|
||||
# 只比较 YAML object key,不比较值;密钥和环境差异不会被打印或误判。
|
||||
if not isinstance(value, dict):
|
||||
return set()
|
||||
paths: set[str] = set()
|
||||
for key, child in value.items():
|
||||
current = f"{prefix}.{key}" if prefix else str(key)
|
||||
paths.add(current)
|
||||
paths.update(mapping_paths(child, current))
|
||||
return paths
|
||||
|
||||
|
||||
def selected_service_names(inventory: dict[str, Any], raw_services: str) -> list[str]:
|
||||
names = [item.strip() for item in raw_services.split(",") if item.strip()]
|
||||
if not names:
|
||||
return sorted(str(name) for name in inventory["services"].keys())
|
||||
unknown = [name for name in names if name not in inventory["services"]]
|
||||
if unknown:
|
||||
raise RuntimeError("unknown services: " + ", ".join(unknown))
|
||||
return list(dict.fromkeys(names))
|
||||
|
||||
|
||||
def selected_hosts(service: dict[str, Any], raw_hosts: str) -> list[str]:
|
||||
hosts = [str(item) for item in service.get("hosts") or []]
|
||||
requested = [item.strip() for item in raw_hosts.split(",") if item.strip()]
|
||||
if not requested:
|
||||
return hosts
|
||||
invalid = [host for host in requested if host not in hosts]
|
||||
if invalid:
|
||||
raise RuntimeError("selected hosts are not assigned to this service: " + ", ".join(invalid))
|
||||
return requested
|
||||
|
||||
|
||||
def validate_service_templates(service_name: str) -> list[str]:
|
||||
errors: list[str] = []
|
||||
config_dir = Path("services") / service_name / "configs"
|
||||
configs = {name: load_yaml(config_dir / name) for name in CONFIG_FILENAMES}
|
||||
contract_paths = mapping_paths(configs["config.tencent.example.yaml"])
|
||||
for filename in ("config.yaml", "config.docker.yaml"):
|
||||
missing = sorted(mapping_paths(configs[filename]) - contract_paths)
|
||||
if missing:
|
||||
errors.append(
|
||||
f"{service_name}: {filename} contains keys missing from config.tencent.example.yaml: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def validate_rendered_config(service_name: str, host_name: str, rendered_dir: Path) -> list[str]:
|
||||
contract = load_yaml(Path("services") / service_name / "configs" / "config.tencent.example.yaml")
|
||||
rendered_path = rendered_dir / host_name / service_name / "config.yaml"
|
||||
rendered = load_yaml(rendered_path)
|
||||
missing = sorted(mapping_paths(contract) - mapping_paths(rendered))
|
||||
if not missing:
|
||||
return []
|
||||
return [
|
||||
f"{service_name}@{host_name}: rendered config missing keys required by config.tencent.example.yaml: "
|
||||
+ ", ".join(missing)
|
||||
]
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate service config contracts before TAT deployment.")
|
||||
parser.add_argument("--inventory", default="deploy/tencent-tat/inventory.prod.example.json")
|
||||
parser.add_argument("--rendered-dir", default="", help="Directory with <host>/<service>/config.yaml files.")
|
||||
parser.add_argument("--services", default="", help="Comma separated service names. Defaults to all inventory services.")
|
||||
parser.add_argument("--hosts", default="", help="Optional comma separated hosts assigned to the selected service(s).")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
inventory = load_inventory(Path(args.inventory))
|
||||
rendered_dir = Path(args.rendered_dir).expanduser().resolve() if args.rendered_dir else None
|
||||
errors: list[str] = []
|
||||
for service_name in selected_service_names(inventory, args.services):
|
||||
errors.extend(validate_service_templates(service_name))
|
||||
if rendered_dir is None:
|
||||
continue
|
||||
for host_name in selected_hosts(inventory["services"][service_name], args.hosts):
|
||||
errors.extend(validate_rendered_config(service_name, host_name, rendered_dir))
|
||||
if errors:
|
||||
print(json.dumps({"ok": False, "errors": errors}, ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps({"ok": True}, ensure_ascii=False))
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@ -1 +0,0 @@
|
||||
# Package marker for Tencent TAT deployment helper tests.
|
||||
@ -1,16 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_SOURCE = Path(__file__).resolve().parents[1] / "tencent-tat" / "hyapp_tat_deploy.py"
|
||||
_SPEC = importlib.util.spec_from_file_location("_hyapp_tat_deploy_impl", _SOURCE)
|
||||
if _SPEC is None or _SPEC.loader is None:
|
||||
raise ImportError(f"cannot load deployment helper: {_SOURCE}")
|
||||
_MODULE = importlib.util.module_from_spec(_SPEC)
|
||||
_SPEC.loader.exec_module(_MODULE)
|
||||
|
||||
for _name, _value in vars(_MODULE).items():
|
||||
if not _name.startswith("__"):
|
||||
globals()[_name] = _value
|
||||
@ -26,7 +26,7 @@ services:
|
||||
- "19009:10909"
|
||||
- "19011:10911"
|
||||
volumes:
|
||||
- ./deploy/rocketmq/broker.conf:/home/rocketmq/broker.conf:ro
|
||||
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/rocketmq/broker.conf:/home/rocketmq/broker.conf:ro
|
||||
- rocketmq-store:/home/rocketmq/store
|
||||
depends_on:
|
||||
- rocketmq-namesrv
|
||||
@ -284,15 +284,15 @@ services:
|
||||
- ./services/user-service/deploy/mysql/initdb/001_user_service.sql:/docker-entrypoint-initdb.d/002_user_service.sql:ro
|
||||
- ./services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql:/docker-entrypoint-initdb.d/003_wallet_service.sql:ro
|
||||
- ./services/activity-service/deploy/mysql/initdb/001_activity_service.sql:/docker-entrypoint-initdb.d/004_activity_service.sql:ro
|
||||
- ./deploy/mysql/initdb/005_utf8mb4_chinese_support.sql:/docker-entrypoint-initdb.d/005_utf8mb4_chinese_support.sql:ro
|
||||
- ./deploy/mysql/initdb/006_admin_database.sql:/docker-entrypoint-initdb.d/006_admin_database.sql:ro
|
||||
- ./deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql:/docker-entrypoint-initdb.d/007_resource_group_wallet_asset_items.sql:ro
|
||||
- ./deploy/mysql/initdb/008_remove_taiwan_country.sql:/docker-entrypoint-initdb.d/008_remove_taiwan_country.sql:ro
|
||||
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/005_utf8mb4_chinese_support.sql:/docker-entrypoint-initdb.d/005_utf8mb4_chinese_support.sql:ro
|
||||
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/006_admin_database.sql:/docker-entrypoint-initdb.d/006_admin_database.sql:ro
|
||||
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql:/docker-entrypoint-initdb.d/007_resource_group_wallet_asset_items.sql:ro
|
||||
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/008_remove_taiwan_country.sql:/docker-entrypoint-initdb.d/008_remove_taiwan_country.sql:ro
|
||||
- ./services/cron-service/deploy/mysql/initdb/001_cron_service.sql:/docker-entrypoint-initdb.d/009_cron_service.sql:ro
|
||||
- ./services/game-service/deploy/mysql/initdb/001_game_service.sql:/docker-entrypoint-initdb.d/010_game_service.sql:ro
|
||||
- ./services/notice-service/deploy/mysql/initdb/001_notice_service.sql:/docker-entrypoint-initdb.d/011_notice_service.sql:ro
|
||||
- ./services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql:/docker-entrypoint-initdb.d/012_statistics_service.sql:ro
|
||||
- ./deploy/mysql/initdb/999_local_grants.sql:/docker-entrypoint-initdb.d/999_local_grants.sql:ro
|
||||
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/999_local_grants.sql:/docker-entrypoint-initdb.d/999_local_grants.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"]
|
||||
interval: 5s
|
||||
|
||||
404
docs/flutter对接/创建房间与IM入群Flutter对接.md
Normal file
404
docs/flutter对接/创建房间与IM入群Flutter对接.md
Normal file
@ -0,0 +1,404 @@
|
||||
# 创建房间与 IM 入群 Flutter 对接
|
||||
|
||||
本文描述 Flutter App 从创建语音房到加入腾讯云 IM 房间群的调用顺序。当前服务端已改为:`CreateRoom` 返回前同步创建腾讯 IM 群;创建成功后,`data.room.im_group_id` 对应的腾讯 IM 群已经存在。
|
||||
|
||||
## 接口地址
|
||||
涉及接口:
|
||||
|
||||
| 方法 | 接口 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/v1/im/usersig` | 获取腾讯 IM SDK 登录票据。 |
|
||||
| `POST` | `/api/v1/rooms/create` | 创建房间;成功前同步创建腾讯 IM 群。 |
|
||||
| `POST` | `/api/v1/rooms/join` | 建立 room-service 业务 presence;成功后才调用腾讯 IM `joinGroup`。 |
|
||||
|
||||
## 请求头
|
||||
|
||||
| Header | 必填 | 示例 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Authorization` | 是 | `Bearer <access_token>` | 登录 access token;服务端从 token 读取当前用户 ID。 |
|
||||
| `X-App-Code` | 否 | `lalu` | App 编码;登录态接口最终以 token 内的 `app_code` 为准。 |
|
||||
| `Content-Type` | POST 必填 | `application/json` | JSON 请求体。 |
|
||||
|
||||
## 正确调用顺序
|
||||
|
||||
```text
|
||||
1. App 登录后获取 access_token
|
||||
2. GET /api/v1/im/usersig
|
||||
3. 腾讯 IM SDK login(user_id, user_sig)
|
||||
4. POST /api/v1/rooms/create
|
||||
5. POST /api/v1/rooms/join
|
||||
6. 腾讯 IM SDK joinGroup(join_response.data.im.group_id)
|
||||
7. 成功后进入房间页并启动 heartbeat / RTC
|
||||
```
|
||||
|
||||
关键规则:
|
||||
|
||||
| 规则 | 说明 |
|
||||
| --- | --- |
|
||||
| `CreateRoom` 成功代表 IM 群已创建 | 服务端同步调用腾讯 IM `create_group`,失败时创建接口直接失败,不落房间。 |
|
||||
| `CreateRoom` 后不要直接 `joinGroup` | 腾讯 IM 回调守卫依赖 room-service presence;必须先 `JoinRoom`。 |
|
||||
| `im_group_id` 当前等于 `room_id` | 客户端仍然必须使用接口返回字段,不能硬编码映射规则。 |
|
||||
| `joinGroup` 成功或“已在群内”都按成功处理 | 页面重进、SDK 重连或重复 join 时可能出现已在群内。 |
|
||||
| `command_id` 同一次用户动作重试必须复用 | 创建房间和进房分别使用不同 `command_id`。 |
|
||||
|
||||
## 获取 IM UserSig
|
||||
|
||||
请求:
|
||||
|
||||
```http
|
||||
GET /api/v1/im/usersig
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
```
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_abc",
|
||||
"data": {
|
||||
"sdk_app_id": 20036101,
|
||||
"user_id": "10001",
|
||||
"user_sig": "xxx",
|
||||
"expire_at_ms": 1778007200000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
客户端行为:
|
||||
|
||||
- `user_id` 是腾讯 IM identifier,直接传给腾讯 IM SDK。
|
||||
- `user_sig` 按 `expire_at_ms` 缓存;过期前可以复用。
|
||||
- `UNAUTHORIZED` 或 `PROFILE_REQUIRED` 时不进入房间流程。
|
||||
|
||||
## 创建房间
|
||||
|
||||
请求:
|
||||
|
||||
```http
|
||||
POST /api/v1/rooms/create
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"command_id": "cmd_create_room_10001_1778000000000",
|
||||
"seat_count": 10,
|
||||
"mode": "voice",
|
||||
"room_name": "Live Room",
|
||||
"room_avatar": "https://cdn.example/room.png",
|
||||
"room_description": "welcome"
|
||||
}
|
||||
```
|
||||
|
||||
请求字段:
|
||||
|
||||
| 字段 | 必填 | 类型 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `command_id` | 是 | string | 创建房间动作幂等键;同一次点击超时重试必须复用。 |
|
||||
| `seat_count` | 否 | int | 不传或传 `0` 使用后台默认麦位数;正数必须命中后台启用座位数。 |
|
||||
| `mode` | 是 | string | 当前传 `voice`。 |
|
||||
| `room_name` | 是 | string | 房间标题,最长 128 个字符。 |
|
||||
| `room_avatar` | 否 | string | 房间头像 URL;为空时服务端写默认头像。 |
|
||||
| `room_description` | 否 | string | 房间简介,最长 512 个字符。 |
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_abc",
|
||||
"data": {
|
||||
"result": {
|
||||
"applied": true,
|
||||
"room_version": 1,
|
||||
"server_time_ms": 1778000000123
|
||||
},
|
||||
"room": {
|
||||
"room_id": "lalu_abc123",
|
||||
"im_group_id": "lalu_abc123",
|
||||
"room_short_id": "10001",
|
||||
"title": "Live Room",
|
||||
"cover_url": "https://cdn.example/room.png",
|
||||
"description": "welcome",
|
||||
"owner_user_id": "10001",
|
||||
"mode": "voice",
|
||||
"status": "active",
|
||||
"chat_enabled": true,
|
||||
"locked": false,
|
||||
"heat": 0,
|
||||
"online_count": 1,
|
||||
"seat_count": 10,
|
||||
"occupied_seat_count": 0,
|
||||
"visible_region_id": 1001,
|
||||
"version": 1
|
||||
},
|
||||
"seats": [
|
||||
{
|
||||
"seat_no": 1,
|
||||
"locked": false
|
||||
}
|
||||
],
|
||||
"im": {
|
||||
"group_id": "lalu_abc123",
|
||||
"need_join_group": true
|
||||
},
|
||||
"server_time_ms": 1778000000123
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `data.room.room_id` | 房间 ID;后续 JoinRoom、heartbeat、RTC 都使用它。 |
|
||||
| `data.room.im_group_id` | 腾讯 IM 房间群 ID;创建成功时群已存在。 |
|
||||
| `data.im.group_id` | 同一个 IM 群 ID;客户端最终以 JoinRoom 响应里的 `data.im.group_id` 入群。 |
|
||||
| `data.result.applied` | `true` 表示本次创建写入;同一 `command_id` 重试可能为 `false`。 |
|
||||
|
||||
创建失败处理:
|
||||
|
||||
| code | HTTP | 客户端处理 |
|
||||
| --- | --- | --- |
|
||||
| `INVALID_ARGUMENT` | 400 | 检查必填字段、座位数、标题长度。 |
|
||||
| `UNAUTHORIZED` | 401 | 回登录。 |
|
||||
| `PROFILE_REQUIRED` | 403 | 引导补全资料。 |
|
||||
| `CONFLICT` | 409 | 当前用户已经有房间;调用 `/api/v1/rooms/me` 恢复已有房间。 |
|
||||
| `UPSTREAM_ERROR` | 502 | 可能是腾讯 IM 建群失败或 room-service 不可用;不要进入房间页,可提示稍后重试。 |
|
||||
|
||||
## JoinRoom 后加入 IM 群
|
||||
|
||||
创建房间成功后,房主仍然要调用 `JoinRoom`。原因是 IM 入群回调会检查 room-service presence;只创建房间不能替代进房动作。
|
||||
|
||||
请求:
|
||||
|
||||
```http
|
||||
POST /api/v1/rooms/join
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"room_id": "lalu_abc123",
|
||||
"command_id": "cmd_join_room_lalu_abc123_10001_1778000001000",
|
||||
"role": "audience"
|
||||
}
|
||||
```
|
||||
|
||||
成功响应只展示 IM 相关字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_def",
|
||||
"data": {
|
||||
"room": {
|
||||
"room_id": "lalu_abc123",
|
||||
"im_group_id": "lalu_abc123",
|
||||
"version": 2
|
||||
},
|
||||
"viewer": {
|
||||
"user_id": "10001",
|
||||
"role": "owner",
|
||||
"joined_at_ms": 1778000001000,
|
||||
"last_seen_at_ms": 1778000001000
|
||||
},
|
||||
"im": {
|
||||
"group_id": "lalu_abc123",
|
||||
"need_join_group": true
|
||||
},
|
||||
"rtc": {
|
||||
"need_token": true,
|
||||
"available": true
|
||||
},
|
||||
"server_time_ms": 1778000001000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
客户端行为:
|
||||
|
||||
| 条件 | 行为 |
|
||||
| --- | --- |
|
||||
| `data.im.need_join_group=true` | 调腾讯 IM SDK `joinGroup(data.im.group_id)`。 |
|
||||
| `joinGroup` 成功 | 开始监听房间群 `TIMCustomElem`,继续 RTC 进房。 |
|
||||
| `joinGroup` 返回已在群内 | 按成功处理。 |
|
||||
| `joinGroup` 返回群不存在 | 视为异常;当前服务端 CreateRoom 已同步建群,记录 `request_id` 和 `group_id` 上报。 |
|
||||
| `joinGroup` 返回被拒绝/无权限 | 重新调用 `JoinRoom`;仍失败则退出房间页。 |
|
||||
|
||||
## Flutter 示例
|
||||
|
||||
下面示例只展示调用边界,项目内可替换为现有 Dio 封装、错误码枚举和腾讯 IM SDK 适配层。
|
||||
|
||||
```dart
|
||||
class VoiceRoomApi {
|
||||
VoiceRoomApi(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
Future<TencentIMUserSig> fetchIMUserSig() async {
|
||||
final resp = await _dio.get('/api/v1/im/usersig');
|
||||
final data = resp.data['data'] as Map<String, dynamic>;
|
||||
return TencentIMUserSig.fromJson(data);
|
||||
}
|
||||
|
||||
Future<CreateRoomResult> createRoom({
|
||||
required String commandId,
|
||||
required String roomName,
|
||||
String mode = 'voice',
|
||||
int seatCount = 0,
|
||||
String roomAvatar = '',
|
||||
String roomDescription = '',
|
||||
}) async {
|
||||
final resp = await _dio.post(
|
||||
'/api/v1/rooms/create',
|
||||
data: {
|
||||
'command_id': commandId,
|
||||
'seat_count': seatCount,
|
||||
'mode': mode,
|
||||
'room_name': roomName,
|
||||
'room_avatar': roomAvatar,
|
||||
'room_description': roomDescription,
|
||||
},
|
||||
);
|
||||
return CreateRoomResult.fromJson(resp.data['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<JoinRoomResult> joinRoom({
|
||||
required String roomId,
|
||||
required String commandId,
|
||||
String role = 'audience',
|
||||
String password = '',
|
||||
}) async {
|
||||
final resp = await _dio.post(
|
||||
'/api/v1/rooms/join',
|
||||
data: {
|
||||
'room_id': roomId,
|
||||
'command_id': commandId,
|
||||
'role': role,
|
||||
if (password.isNotEmpty) 'password': password,
|
||||
},
|
||||
);
|
||||
return JoinRoomResult.fromJson(resp.data['data'] as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
IM 登录和入群:
|
||||
|
||||
```dart
|
||||
class VoiceRoomIMBridge {
|
||||
Future<void> ensureLoggedIn(TencentIMUserSig sig) async {
|
||||
// 根据项目使用的腾讯云 IM Flutter SDK 版本适配。
|
||||
// 常见入参是 sdkAppID、userID、userSig。
|
||||
await TencentIMSDK.login(
|
||||
sdkAppId: sig.sdkAppId,
|
||||
userId: sig.userId,
|
||||
userSig: sig.userSig,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> joinRoomGroup(String groupId) async {
|
||||
try {
|
||||
await TencentIMSDK.joinGroup(groupId: groupId);
|
||||
} on TencentIMException catch (error) {
|
||||
if (_isAlreadyInGroup(error)) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isAlreadyInGroup(TencentIMException error) {
|
||||
// 按项目实际 SDK 错误码收敛,例如已在群内、重复入群等。
|
||||
return error.code == TencentIMErrors.alreadyInGroup;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
创建并进入自己房间:
|
||||
|
||||
```dart
|
||||
Future<void> createAndEnterRoom({
|
||||
required VoiceRoomApi api,
|
||||
required VoiceRoomIMBridge im,
|
||||
required String roomName,
|
||||
}) async {
|
||||
final sig = await api.fetchIMUserSig();
|
||||
await im.ensureLoggedIn(sig);
|
||||
|
||||
final create = await api.createRoom(
|
||||
commandId: newCommandId('create_room'),
|
||||
roomName: roomName,
|
||||
);
|
||||
|
||||
final roomId = create.room.roomId;
|
||||
|
||||
final join = await api.joinRoom(
|
||||
roomId: roomId,
|
||||
commandId: newCommandId('join_room_$roomId'),
|
||||
);
|
||||
|
||||
if (join.im.needJoinGroup) {
|
||||
await im.joinRoomGroup(join.im.groupId);
|
||||
}
|
||||
|
||||
// join.rtc.available=true 时继续 enterRoom;无论是否进 RTC,业务 presence 已经建立。
|
||||
openVoiceRoomPage(join);
|
||||
}
|
||||
```
|
||||
|
||||
`command_id` 生成建议:
|
||||
|
||||
```dart
|
||||
String newCommandId(String action) {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final nonce = randomBase32(8);
|
||||
return 'cmd_${action}_${now}_$nonce';
|
||||
}
|
||||
```
|
||||
|
||||
同一次点击因网络超时重试时,必须复用原 `command_id`;用户重新点击创建或进房时再生成新的 `command_id`。
|
||||
|
||||
## 状态机建议
|
||||
|
||||
| 状态 | 入口 | 成功下一步 | 失败处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| `imLogin` | `/im/usersig` + IM `login` | `createRoom` | 回登录或提示 IM 初始化失败。 |
|
||||
| `createRoom` | `/rooms/create` | `joinRoom` | 不进入房间页;`CONFLICT` 时查询我的房间。 |
|
||||
| `joinRoom` | `/rooms/join` | `joinIMGroup` | 不调用 `joinGroup`;展示进房失败。 |
|
||||
| `joinIMGroup` | IM SDK `joinGroup` | `enterRTC` / 打开房间页 | 已在群内按成功;其他错误退出房间页或重试 JoinRoom。 |
|
||||
| `inRoom` | 房间页 active | heartbeat loop | heartbeat 失败按重连策略处理。 |
|
||||
|
||||
## IM 消息监听
|
||||
|
||||
房间系统消息通过腾讯 IM 群 `TIMCustomElem` 下发:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `MsgType` | `TIMCustomElem` |
|
||||
| `Ext` | `room_system_message` |
|
||||
| `Data` | JSON 字符串,包含 `event_id`、`room_id`、`event_type`、`room_version` 等。 |
|
||||
| `CloudCustomData` | 与 `Data` 相同,便于漫游和排障。 |
|
||||
|
||||
客户端处理规则:
|
||||
|
||||
- 按 `event_id` 去重。
|
||||
- 按 `room_version` 丢弃旧事件。
|
||||
- 收到无法解析或版本断层时,调用 `GET /api/v1/rooms/snapshot?room_id={room_id}` 拉最新快照。
|
||||
|
||||
## 常见错误处理
|
||||
|
||||
| 场景 | 客户端动作 |
|
||||
| --- | --- |
|
||||
| `/rooms/create` 返回 `UPSTREAM_ERROR` | 不调用 JoinRoom,不进入房间页;提示稍后重试。 |
|
||||
| `/rooms/create` 返回 `CONFLICT` | 用户已有房间,调用 `/api/v1/rooms/me` 恢复。 |
|
||||
| `/rooms/join` 返回 `PERMISSION_DENIED` | 可能被 ban 或锁房密码错误;不调用 `joinGroup`。 |
|
||||
| IM `joinGroup` 群不存在 | 当前不应出现;记录 `request_id`、`room_id`、`group_id` 上报。 |
|
||||
| IM 登录票据过期 | 重新调用 `/api/v1/im/usersig` 并 `login`。 |
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -862,6 +862,53 @@ func TestHandleZeeOneAccessTokenIgnoresStaleLaunchSessionGameID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleZeeOneAllowsDebitAndCreditWithSameProviderOrderID(t *testing.T) {
|
||||
secret := "zeeone-test-secret"
|
||||
token := leaderccTestAccessToken(t, "lalu", 123456, "123456", 1700003600)
|
||||
repo := &fakeRepository{
|
||||
platform: gamedomain.Platform{
|
||||
PlatformCode: "zeeone",
|
||||
AdapterType: gamedomain.AdapterZeeOneV1,
|
||||
CallbackSecretCiphertext: secret,
|
||||
AdapterConfigJSON: `{"merchant_id":307715,"platform_id":2032,"uid_mode":"display_user_id"}`,
|
||||
},
|
||||
}
|
||||
wallet := &fakeWallet{balanceAfter: 900}
|
||||
svc := New(Config{}, repo, wallet, &fakeUser{})
|
||||
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
|
||||
|
||||
base := `{"appId":"M307715_P2032","userId":"123456","sessionId":"` + token + `","diff_msg":"settle","game_id":1021,"room_id":"6","change_time_at":1700000000,"order_id":"zee_round_order_1"`
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
diff string
|
||||
wantOp string
|
||||
wantID string
|
||||
wantCoins int64
|
||||
}{
|
||||
{name: "debit", diff: "-500", wantOp: "debit", wantID: "game:zeeone:zee_round_order_1:debit", wantCoins: 500},
|
||||
{name: "credit", diff: "750", wantOp: "credit", wantID: "game:zeeone:zee_round_order_1:credit", wantCoins: 750},
|
||||
} {
|
||||
raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
|
||||
Meta: &gamev1.RequestMeta{RequestId: "req-zeeone-" + item.name, AppCode: "lalu"},
|
||||
PlatformCode: "zeeone",
|
||||
Operation: "change_balance",
|
||||
RawBody: zeeoneTestEnvelope(t, secret, base+`,"currency_diff":`+item.diff+`}`, 1700000000000),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleCallback %s failed: %v", item.name, err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"code":0`) {
|
||||
t.Fatalf("zeeone %s response mismatch: %s", item.name, raw)
|
||||
}
|
||||
if wallet.lastApply.GetCommandId() != item.wantID || wallet.lastApply.GetOpType() != item.wantOp || wallet.lastApply.GetCoinAmount() != item.wantCoins {
|
||||
t.Fatalf("zeeone %s wallet command mismatch: %+v", item.name, wallet.lastApply)
|
||||
}
|
||||
}
|
||||
if wallet.applyCount != 2 {
|
||||
t.Fatalf("debit and credit should both reach wallet, got %d applies", wallet.applyCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBaishunSSTokenUserInfoUpdateAndChangeBalance(t *testing.T) {
|
||||
secret := "baishun-test-app-key"
|
||||
token := "app_access_token_baishun"
|
||||
@ -1058,6 +1105,7 @@ type fakeRepository struct {
|
||||
platform gamedomain.Platform
|
||||
session gamedomain.LaunchSession
|
||||
order gamedomain.GameOrder
|
||||
orders []gamedomain.GameOrder
|
||||
repair gamedomain.RepairOrder
|
||||
levelEvents []gamedomain.LevelEventOutbox
|
||||
deliveredEvents []string
|
||||
@ -1086,10 +1134,20 @@ func (f *fakeRepository) GetLaunchSessionByToken(_ context.Context, _ string, to
|
||||
}
|
||||
func (f *fakeRepository) InsertCallbackLog(context.Context, gamedomain.CallbackLog) error { return nil }
|
||||
func (f *fakeRepository) CreateGameOrder(_ context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) {
|
||||
if f.order.OrderID != "" {
|
||||
return f.order, true, nil
|
||||
for _, existing := range f.orders {
|
||||
if existing.OrderID == order.OrderID {
|
||||
f.order = existing
|
||||
return existing, true, nil
|
||||
}
|
||||
}
|
||||
if f.order.OrderID != "" && len(f.orders) == 0 {
|
||||
if f.order.OrderID == order.OrderID {
|
||||
return f.order, true, nil
|
||||
}
|
||||
f.orders = append(f.orders, f.order)
|
||||
}
|
||||
f.order = order
|
||||
f.orders = append(f.orders, order)
|
||||
return order, false, nil
|
||||
}
|
||||
func (f *fakeRepository) MarkOrderSucceeded(_ context.Context, _ string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error {
|
||||
@ -1098,6 +1156,12 @@ func (f *fakeRepository) MarkOrderSucceeded(_ context.Context, _ string, orderID
|
||||
f.order.WalletTransactionID = walletTransactionID
|
||||
f.order.WalletBalanceAfter = balanceAfter
|
||||
f.order.UpdatedAtMS = nowMs
|
||||
for index := range f.orders {
|
||||
if f.orders[index].OrderID == orderID {
|
||||
f.orders[index] = f.order
|
||||
break
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(f.order.OpType, "debit") && f.order.CoinAmount > 0 && len(f.levelEvents) == 0 {
|
||||
f.levelEvents = append(f.levelEvents, gamedomain.LevelEventOutbox{
|
||||
AppCode: f.order.AppCode,
|
||||
@ -1122,6 +1186,12 @@ func (f *fakeRepository) MarkOrderFailed(_ context.Context, _ string, orderID st
|
||||
f.order.FailureCode = code
|
||||
f.order.FailureMessage = message
|
||||
f.order.UpdatedAtMS = nowMs
|
||||
for index := range f.orders {
|
||||
if f.orders[index].OrderID == orderID {
|
||||
f.orders[index] = f.order
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRepository) CreateRepairOrder(_ context.Context, order gamedomain.RepairOrder) (gamedomain.RepairOrder, bool, error) {
|
||||
@ -1165,11 +1235,13 @@ func (f *fakeRepository) DeleteCatalog(context.Context, string, string) error {
|
||||
|
||||
type fakeWallet struct {
|
||||
lastApply *walletv1.ApplyGameCoinChangeRequest
|
||||
applyCount int
|
||||
balanceAfter int64
|
||||
}
|
||||
|
||||
func (f *fakeWallet) ApplyGameCoinChange(_ context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) {
|
||||
f.lastApply = req
|
||||
f.applyCount++
|
||||
return &walletv1.ApplyGameCoinChangeResponse{WalletTransactionId: "wtx-game-1", BalanceAfter: f.balanceAfter}, nil
|
||||
}
|
||||
|
||||
|
||||
@ -276,7 +276,8 @@ func (s *Service) handleZeeOneChangeBalance(ctx context.Context, app string, con
|
||||
session.GameID = session.ProviderGameID
|
||||
}
|
||||
opType, amount := zeeoneOpType(body.CurrencyDiff)
|
||||
result, err := s.applyYomiCoinChange(ctx, app, req, session, body.OrderID, strconv.FormatInt(body.ChangeTimeAt, 10), opType, amount, body.RoomID, requestHash)
|
||||
providerOrderID := zeeoneCoinChangeProviderOrderID(body.OrderID, opType)
|
||||
result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, strconv.FormatInt(body.ChangeTimeAt, 10), opType, amount, body.RoomID, requestHash)
|
||||
if err != nil {
|
||||
code := zeeoneCodeFromError(err)
|
||||
balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
|
||||
@ -359,6 +360,15 @@ func zeeoneOpType(diff int64) (string, int64) {
|
||||
return "credit", diff
|
||||
}
|
||||
|
||||
func zeeoneCoinChangeProviderOrderID(orderID string, opType string) string {
|
||||
orderID = strings.TrimSpace(orderID)
|
||||
opType = strings.ToLower(strings.TrimSpace(opType))
|
||||
if orderID == "" || opType == "" {
|
||||
return orderID
|
||||
}
|
||||
return orderID + ":" + opType
|
||||
}
|
||||
|
||||
func zeeoneProviderRequestID(reqBody string) string {
|
||||
var body struct {
|
||||
OrderID string `json:"order_id"`
|
||||
|
||||
@ -2,6 +2,7 @@ service_name: statistics-service
|
||||
node_id: statistics-prod-1
|
||||
environment: prod
|
||||
health_http_addr: ":13110"
|
||||
query_http_addr: ":13010"
|
||||
mysql_dsn: "${STATISTICS_MYSQL_DSN}"
|
||||
rocketmq:
|
||||
enabled: true
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user