修复zeeone 的oder问题

This commit is contained in:
zhx 2026-05-27 17:27:13 +08:00
parent 30a25a9d4d
commit e73e2f5ee8
45 changed files with 503 additions and 2206 deletions

View File

@ -1,164 +0,0 @@
# 多机部署与无感发布
本文定义不使用 Kubernetes 时的生产部署边界。目标是让 gateway 和内部微服务可以多机滚动发布,并让 `room-service` 在普通内网负载均衡命中非 owner 节点时自动转发到当前 Room Cell owner。
## 网络拓扑
推荐拓扑:
```text
公网 CLB
-> gateway-service 多实例
-> room-service 内网入口
-> user-service 内网入口
-> wallet-service 内网入口
-> activity-service 内网入口
-> game-service 内网入口
-> notice-service 内网入口
room-service 实例之间通过 Redis owner route 和 node registry 直连转发。
```
规则:
- 公网只暴露 `gateway-service`
- 内部 gRPC 服务只监听私网,安全组只允许服务网段访问 `13xxx` 端口。
- `user-service``wallet-service``activity-service``game-service``notice-service` 可以直接挂内网 CLB 或 DNS。
- `room-service` 可以挂内网 CLB/DNS 给 gateway 使用,但 owner 转发必须使用实例级 `advertise_addr`,不能使用 CLB 地址。
- MySQL、Redis、MQ 使用腾讯云托管资源,不在业务 CVM 上部署;服务只通过内网 endpoint 访问这些托管依赖。
当前生产入口:
| 层级 | 地址 | 说明 |
| --- | --- | --- |
| 公网 API | `https://api.global-interaction.com` | HTTPS 443 由公网 CLB 转发到两台 `gateway-service:13000` |
| 后台前端 | `http://1.14.164.2:7001` | 广州 CVM `172.16.0.10` 上 nginx 托管 `admin-platform` 静态资源,`/api` 反代内网后台服务 |
| 后台 API | `172.16.0.6:13100` | 广州 CVM `ins-0x5mjwmc` 运行 `hyapp-admin-server`,通过 `wg-quick@hyapp-admin` 访问生产内网依赖 |
| room/user/activity/game/notice 内网入口 | `10.2.1.16:13001/13005/13006/13008/13009` | `lb-epvnr4o0` 的 Go 服务监听器 |
| wallet 内网入口 | `10.2.1.15:13004` | `lb-4f5xi6p0` 的 Go 服务监听器 |
当前生产镜像仓库前缀是 `10.2.1.3:18082/hyapp`。部署脚本和 systemd env 只允许使用 `hyapp` 项目路径。
## Room Owner 路由
`room-service` 使用 Redis 维护两个短租约:
```text
room:route:{app_code + "\0" + room_id} -> owner node_id / lease_token / expires_at_ms
room:node:{node_id} -> 实例 advertise_addr / expires_at_ms
```
写命令、IM guard 和完整房间快照查询的处理顺序:
1. 请求先到任意 `room-service` 节点。
2. 节点按 `app_code + "\0" + room_id` 查询 Redis owner route。
3. 如果当前节点就是 owner直接进入本地 Room Cell。
4. 如果 owner 是其他节点,按 `room:node:{owner_node_id}` 找到实例私网地址并转发 gRPC。
5. 如果 route 不存在或已过期,当前节点按原有 `EnsureOwner` 逻辑接管并从 MySQL snapshot + command log 恢复。
转发只做一跳。`advertise_addr` 写错成 CLB 地址时,请求会在一跳后停止递归,并暴露真实 owner 冲突或 unavailable。
## room-service 配置
每个实例必须有唯一配置:
```yaml
node_id: "room-node-prod-1"
grpc_addr: ":13001"
advertise_addr: "10.0.1.12:13001"
health_http_addr: ":13101"
node_registry_ttl: "30s"
node_registry_heartbeat_interval: "10s"
owner_forward_timeout: "2s"
lease_ttl: "10s"
```
配置边界:
- `grpc_addr` 是本进程监听地址,可以是 `:13001`
- `advertise_addr` 是其他 room 节点能直连的实例私网地址,必须是单实例地址。
- `node_id` 参与 room owner lease、节点注册和日志排障不能在多个实例间复用。
- `node_registry_ttl` 必须大于心跳周期;默认 `30s/10s`
- `lease_ttl` 是节点故障后的自动接管窗口;正常滚动发布会在 gRPC drain 后主动释放本节点已加载房间 lease减少等待 TTL 的窗口。
## 无感发布步骤
单个服务滚动发布顺序:
1. 在新机器或新端口启动新版本实例。
2. 等 `health_http_addr``/readyz` 或 gRPC health 通过。
3. 把新实例加入对应 CLB 后端或内部 DNS。
4. 观察错误率、延迟和日志。
5. 从 CLB 摘除一个旧实例,等待 CLB 健康检查和连接排空周期。
6. 对旧实例发送 `SIGTERM`
7. 旧实例进入 draininggRPC 完成 in-flight 请求后释放已加载房间 lease并删除 `room:node:{node_id}`
8. 新请求命中新节点后,如果 Redis route 已释放,会立即接管并从 MySQL 恢复;如果旧实例异常退出,则等待 `lease_ttl` 到期后接管。
发布约束:
- 不要先杀进程再摘 CLB。
- 不要把 `room-service``advertise_addr` 写成内网 CLB。
- 不要多个实例复用同一个 `node_id`
- 数据库变更要先执行兼容当前发布窗口的迁移,再滚动服务。
## 配置拆分
不用统一配置中心也可以上线。推荐发布系统渲染最终 `config.yaml`
- 公共环境配置:腾讯云 MySQL/Redis/MQ 私网地址、日志等级、腾讯云 endpoint。
- 服务配置端口、worker 周期、批量大小、超时。
- 实例配置:`node_id``advertise_addr``user-service.id_generator.node_id`
- SecretMySQL/Redis 密码、JWT secret、腾讯 IM/RTC/COS secret、callback token。
只有当需要动态配置、灰度下发、审计回滚时,再引入 Consul/Nacos/Apollo。当前代码的启动配置仍以单服务 YAML 为边界。
线上字段契约以 `services/<service>/configs/config.tencent.example.yaml` 为准。开发时新增配置字段的动作必须同时更新三处:
1. `services/<service>/configs/config.yaml`:本地直跑默认配置。
2. `services/<service>/configs/config.docker.yaml`:本地 Docker/Compose 配置。
3. `services/<service>/configs/config.tencent.example.yaml`:线上渲染契约。
部署前运行:
```bash
python3 deploy/tencent-tat/validate_rendered_configs.py \
--inventory deploy/tencent-tat/inventory.prod.example.json \
--rendered-dir /tmp/hyapp-rendered-prod
```
这个检查只比较 YAML 字段路径,不比较也不输出配置值。只要本地新增字段没有进入线上契约,或者最终渲染 YAML 漏字段,部署前会直接失败。
托管依赖边界:
- 腾讯云 MySQL 是业务事实和恢复来源DSN 必须保留 `loc=UTC`
- 腾讯云 Redis 保存 owner route、node registry、登录风控和短 TTL 缓存Redis 不能作为业务恢复事实来源。
- 腾讯云 MQ 只作为 outbox/event bus 的托管消息通道;创建 topic、consumer group 和权限变更走云产品流程,不混进服务滚动发布。
当前 RocketMQ 资源约定:
| Topic | Producer Group | Consumer Group | 说明 |
| --- | --- | --- | --- |
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-activity-room-outbox` | activity-service 消费房间事实,处理贵重礼物播报、宝箱播报、成长等活动逻辑 |
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-notice-room-outbox` | notice-service 消费私有通知相关房间事实 |
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-room-im-bridge` | room-service IM bridge 消费房间群系统消息和群成员控制事实 |
| `hyapp_room_treasure_open` | `hyapp-room-treasure-open-producer` | `hyapp-room-treasure-open` | room-service 消费宝箱延迟开箱唤醒消息 |
线上推荐配置:
- `services/room-service/configs/config.tencent.example.yaml` 保持 `outbox_worker.publish_mode: "mq"`
- `rocketmq.room_outbox.enabled: true` 后,`room_outbox.status=delivered` 只表示事件已进入 RocketMQactivity、notice、IM bridge 的成功状态由各自消费位点负责。
- `rocketmq.treasure_open.enabled: true` 后,宝箱倒计时使用毫秒级 `open_at_ms` 投递延迟消息;消息到达后 room-service 仍要重新校验 Room Cell 状态,不能把 MQ 当成宝箱状态来源。
- 本地 Docker 默认关闭 MQ使用 direct publisher 维持无云依赖的可运行形态。
## 真实数据验证
本地真实 MySQL/Redis 验证:
```bash
docker compose up -d mysql redis
ROOM_SERVICE_MYSQL_TEST_DSN='hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC' \
ROOM_SERVICE_REDIS_TEST_ADDR='127.0.0.1:13379' \
go test -count=1 ./services/room-service/internal/transport/grpc -run TestOwnerForwardingUsesRedisRouteAndNodeRegistry
```
该测试会启动两个真实 gRPC `room-service` 实例,共用真实 MySQL schema 和 Redis route/node registry在 node A 创建房间,通过 node B 调 `JoinRoom``CheckSpeakPermission``GetRoomSnapshot`,验证请求会转发到当前 owner。

View File

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

View File

@ -1,45 +0,0 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 使用 utf8mb4 连接字符集,保证中文注释、中文名称和 emoji 在导入时不被降级。
-- 本地 Docker 入口脚本会先创建 MYSQL_DATABASE服务 DDL 随后创建各业务库。
-- 这里统一修复新旧本地卷的默认字符集,保证中文文本、中文 COMMENT 和 emoji 不被旧默认字符集截断。
ALTER DATABASE hyapp_room CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER DATABASE hyapp_user CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER DATABASE hyapp_wallet CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER DATABASE hyapp_activity CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_room;
ALTER TABLE rooms CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE room_list_entries CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE room_snapshots CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE room_command_log CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE room_outbox CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_user;
ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE countries CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE regions CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE region_countries CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE region_change_logs CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE user_region_rebuild_tasks CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE user_display_user_ids CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE pretty_display_user_id_leases CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE display_user_id_change_logs CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE user_country_change_logs CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE password_accounts CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE third_party_identities CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE auth_sessions CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE login_audit CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_wallet;
ALTER TABLE wallet_accounts CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE wallet_transactions CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE wallet_entries CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE wallet_outbox CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE wallet_gift_prices CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_activity;
ALTER TABLE activities CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE activity_event_consumption CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE activity_outbox CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -1,5 +0,0 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 使用 utf8mb4 连接字符集,保证中文注释、中文名称和 emoji 在导入时不被降级。
CREATE DATABASE IF NOT EXISTS hyapp_admin DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -1,57 +0,0 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 使用 utf8mb4 连接字符集,保证中文注释、中文名称和 emoji 在导入时不被降级。
USE hyapp_wallet;
DROP PROCEDURE IF EXISTS migrate_resource_group_wallet_asset_items;
DELIMITER $$
CREATE PROCEDURE migrate_resource_group_wallet_asset_items()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_group_items' AND COLUMN_NAME = 'item_type'
) THEN
ALTER TABLE resource_group_items
ADD COLUMN item_type VARCHAR(32) NOT NULL DEFAULT 'resource' COMMENT '明细类型' AFTER group_id;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_group_items' AND COLUMN_NAME = 'wallet_asset_type'
) THEN
ALTER TABLE resource_group_items
ADD COLUMN wallet_asset_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '钱包资产类型' AFTER resource_id;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_group_items' AND COLUMN_NAME = 'wallet_asset_amount'
) THEN
ALTER TABLE resource_group_items
ADD COLUMN wallet_asset_amount BIGINT NOT NULL DEFAULT 0 COMMENT '钱包资产数量' AFTER wallet_asset_type;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_group_items' AND INDEX_NAME = 'uk_resource_group_items_resource'
) THEN
ALTER TABLE resource_group_items DROP INDEX uk_resource_group_items_resource;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_group_items' AND INDEX_NAME = 'uk_resource_group_items_member'
) THEN
ALTER TABLE resource_group_items
ADD UNIQUE KEY uk_resource_group_items_member (app_code, group_id, item_type, resource_id, wallet_asset_type);
END IF;
END$$
DELIMITER ;
CALL migrate_resource_group_wallet_asset_items();
DROP PROCEDURE migrate_resource_group_wallet_asset_items;

View File

@ -1,14 +0,0 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 使用 utf8mb4 连接字符集,保证中文注释、中文名称和 emoji 在导入时不被降级。
USE hyapp_user;
-- 对已经执行过旧国家种子的本地库做幂等修正,保证国家主数据与当前产品范围一致。
DELETE FROM region_countries
WHERE app_code = 'lalu'
AND country_code = 'TW';
DELETE FROM countries
WHERE app_code = 'lalu'
AND country_code = 'TW';

View File

@ -1,17 +0,0 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 使用 utf8mb4 连接字符集,保证中文注释、中文名称和 emoji 在导入时不被降级。
CREATE USER IF NOT EXISTS 'hyapp'@'%' IDENTIFIED BY 'hyapp';
GRANT ALL PRIVILEGES ON hyapp_room.* TO 'hyapp'@'%';
GRANT ALL PRIVILEGES ON hyapp_user.* TO 'hyapp'@'%';
GRANT ALL PRIVILEGES ON hyapp_wallet.* TO 'hyapp'@'%';
GRANT ALL PRIVILEGES ON hyapp_activity.* TO 'hyapp'@'%';
GRANT ALL PRIVILEGES ON hyapp_admin.* TO 'hyapp'@'%';
GRANT ALL PRIVILEGES ON hyapp_cron.* TO 'hyapp'@'%';
GRANT ALL PRIVILEGES ON hyapp_game.* TO 'hyapp'@'%';
GRANT ALL PRIVILEGES ON hyapp_notice.* TO 'hyapp'@'%';
GRANT ALL PRIVILEGES ON hyapp_statistics.* TO 'hyapp'@'%';
FLUSH PRIVILEGES;

View File

@ -1,9 +0,0 @@
brokerClusterName = hyapp-local
brokerName = broker-a
brokerId = 0
deleteWhen = 04
fileReservedTime = 48
brokerRole = ASYNC_MASTER
flushDiskType = ASYNC_FLUSH
autoCreateTopicEnable = true
listenPort = 10911

View File

@ -1,101 +0,0 @@
# Standalone CVM Deployment Templates
本目录提供不依赖 Kubernetes 的生产部署模板。目标形态是 systemd 托管 Docker 容器,公网 CLB 只接 gateway内部服务通过内网 CLB/DNS 或固定私网地址访问。
生产环境 MySQL、Redis、MQ 使用腾讯云托管资源。本目录只管理本仓库 Go 服务容器,不在业务 CVM 上安装或重启 MySQL/Redis/MQ。
## Layout
- `systemd/*.service`: 每个后端服务一份 systemd unit负责启动、停止和故障重启 Docker 容器。
- `docker/*.env.example`: 每个服务一份 Docker 运行参数示例,只保存镜像名、容器名、配置路径和停止等待时间。
- 服务业务配置仍使用 `services/<service>/configs/config.tencent.example.yaml` 作为线上模板,渲染后放到 `/etc/hyapp/<service>/config.yaml`
## Install One Service
`gateway-service` 为例:
```bash
sudo mkdir -p /etc/hyapp/gateway-service
sudo cp deploy/standalone/docker/gateway-service.env.example /etc/hyapp/gateway-service/docker.env
sudo cp services/gateway-service/configs/config.tencent.example.yaml /etc/hyapp/gateway-service/config.yaml
sudo vi /etc/hyapp/gateway-service/docker.env
sudo vi /etc/hyapp/gateway-service/config.yaml
sudo cp deploy/standalone/systemd/hyapp-gateway-service.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now hyapp-gateway-service
sudo systemctl status hyapp-gateway-service
sudo docker inspect --format '{{json .State.Health}}' hyapp-gateway-service
```
`docker.env` 不承载业务密钥;密钥仍在最终 `config.yaml` 中,或者由发布系统先从密钥系统渲染到 `config.yaml`。当前代码只读取单个 YAML不会自动展开 `${VAR}`
## Network
模板默认使用 `--network=host`,让容器直接监听项目约定的 `13xxx` 端口。这样 CLB、安全组和本机排障都以同一个端口工作
- `13000`: gateway HTTP
- `13001`: room gRPC
- `13004`: wallet gRPC
- `13005`: user gRPC
- `13006`: activity gRPC
- `13007`: cron gRPC
- `13008`: game gRPC
- `13009`: notice gRPC
同一台 CVM 上不要启动两个相同端口的同服务实例。做蓝绿发布时优先使用新 CVM 或新容器节点,健康通过后再挂载到 CLB。
## Rolling Update
普通服务的无感更新流程:
1. 在新 CVM 上准备 `/etc/hyapp/<service>/docker.env``/etc/hyapp/<service>/config.yaml`
2. `systemctl start hyapp-<service>`,等待 Docker health 进入 `healthy`
3. 将新实例加入对应 CLB 或内部服务入口。
4. 观察错误率、延迟和服务日志。
5. 从 CLB 摘除旧实例,等待连接保护或业务 drain 窗口结束。
6. `systemctl stop hyapp-<service>`Docker 会先发送 SIGTERM服务内的 graceful shutdown 会进入 draining 并等待已进入请求结束。
`room-service` 已经具备 Redis owner route 和非 owner 自动转发能力,可以把多个 active 实例挂到内网 CLB 后面。每个实例仍必须配置唯一 `node_id`,且 `advertise_addr` 必须是实例私网地址,不能写 CLB 地址。
## Tencent TAT Rolling Deploy
当前腾讯云 CVM 生产部署使用 `deploy/tencent-tat`
- `inventory.prod.example.json`: 记录 gateway/new-app/new-game/new-app-activity/pay 这批机器和服务映射。
- `hyapp_tat_deploy.py`: 通过 TAT 在目标机执行 systemd 重启和镜像更新。
- 发布时对配置了 CLB 的服务逐实例改权重到 `0`,健康恢复后再恢复原权重。
常用命令:
```bash
python3 deploy/tencent-tat/hyapp_tat_deploy.py restart \
--inventory deploy/tencent-tat/inventory.prod.json \
--services gateway-service \
--yes
python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy \
--inventory deploy/tencent-tat/inventory.prod.json \
--services gateway-service,room-service,user-service,wallet-service,activity-service,game-service,notice-service \
--tag 20260513-1349 \
--yes
```
详细步骤和无感发布边界见 `deploy/tencent-tat/README.md`
## Service Discovery
不使用 Kubernetes 时,第一阶段推荐用内网 CLB/DNS 提供稳定入口,例如:
```yaml
user_service_addr: "user.service.internal:13005"
wallet_service_addr: "wallet.service.internal:13004"
activity_service_addr: "activity.service.internal:13006"
room_service_addr: "room.service.internal:13001"
game_service_addr: "game.service.internal:13008"
notice_service_addr: "notice.service.internal:13009"
```
如果后续引入 Consul/Nacos仍建议让业务配置只保存稳定服务名不把实例 IP 写进每个服务配置。
MySQL、Redis、MQ 不属于这里的服务发现对象。它们在 `config.yaml` 中写腾讯云托管资源的内网 endpoint变更走对应云产品流程。

View File

@ -1,6 +0,0 @@
# Image is immutable per release; publish scripts pull this tag before restarting systemd.
IMAGE=10.2.1.3:18082/hyapp/activity-service:REPLACE_WITH_RELEASE_TAG
CONTAINER_NAME=hyapp-activity-service
CONFIG_PATH=/etc/hyapp/activity-service/config.yaml
STOP_TIMEOUT_SEC=60

View File

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

View File

@ -1,4 +0,0 @@
IMAGE=10.2.1.3:18082/hyapp/admin-server:REPLACE_TAG
CONTAINER_NAME=hyapp-admin-server
CONFIG_PATH=/etc/hyapp/admin-server/config.yaml
STOP_TIMEOUT_SEC=30

View File

@ -1,6 +0,0 @@
# cron-service already uses MySQL task leases, but keep one replica until every task is verified under multi-node scheduling.
IMAGE=10.2.1.3:18082/hyapp/cron-service:REPLACE_WITH_RELEASE_TAG
CONTAINER_NAME=hyapp-cron-service
CONFIG_PATH=/etc/hyapp/cron-service/config.yaml
STOP_TIMEOUT_SEC=90

View File

@ -1,5 +0,0 @@
IMAGE=10.2.1.3:18082/hyapp/game-service:REPLACE_WITH_RELEASE_TAG
CONTAINER_NAME=hyapp-game-service
CONFIG_PATH=/etc/hyapp/game-service/config.yaml
STOP_TIMEOUT_SEC=60

View File

@ -1,6 +0,0 @@
# gateway instances are stateless HTTP entries and should be attached behind the public CLB only after /healthz/ready passes.
IMAGE=10.2.1.3:18082/hyapp/gateway-service:REPLACE_WITH_RELEASE_TAG
CONTAINER_NAME=hyapp-gateway-service
CONFIG_PATH=/etc/hyapp/gateway-service/config.yaml
STOP_TIMEOUT_SEC=60

View File

@ -1,5 +0,0 @@
# Image is immutable per release; publish scripts pull this tag before restarting systemd.
IMAGE=10.2.1.3:18082/hyapp/notice-service:REPLACE_WITH_RELEASE_TAG
CONTAINER_NAME=hyapp-notice-service
CONFIG_PATH=/etc/hyapp/notice-service/config.yaml
STOP_TIMEOUT_SEC=60

View File

@ -1,6 +0,0 @@
# Keep room-service single-active until owner routing or non-owner forwarding is implemented.
IMAGE=10.2.1.3:18082/hyapp/room-service:REPLACE_WITH_RELEASE_TAG
CONTAINER_NAME=hyapp-room-service
CONFIG_PATH=/etc/hyapp/room-service/config.yaml
STOP_TIMEOUT_SEC=90

View File

@ -1,4 +0,0 @@
IMAGE=10.2.1.3:18082/hyapp/statistics-service:RELEASE_TAG
CONTAINER_NAME=hyapp-statistics-service
CONFIG_PATH=/etc/hyapp/statistics-service/config.yaml
STOP_TIMEOUT_SEC=60

View File

@ -1,6 +0,0 @@
# id_generator.node_id in config.yaml must be unique for every active user-service instance.
IMAGE=10.2.1.3:18082/hyapp/user-service:REPLACE_WITH_RELEASE_TAG
CONTAINER_NAME=hyapp-user-service
CONFIG_PATH=/etc/hyapp/user-service/config.yaml
STOP_TIMEOUT_SEC=60

View File

@ -1,5 +0,0 @@
IMAGE=10.2.1.3:18082/hyapp/wallet-service:REPLACE_WITH_RELEASE_TAG
CONTAINER_NAME=hyapp-wallet-service
CONFIG_PATH=/etc/hyapp/wallet-service/config.yaml
STOP_TIMEOUT_SEC=60

View File

@ -1,24 +0,0 @@
[Unit]
Description=Hyapp activity-service container
Requires=docker.service
After=network-online.target docker.service
Wants=network-online.target
StartLimitIntervalSec=120
StartLimitBurst=3
[Service]
Type=simple
EnvironmentFile=/etc/hyapp/activity-service/docker.env
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13006 -service=activity-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
Restart=on-failure
RestartSec=5s
TimeoutStartSec=60
TimeoutStopSec=90
KillMode=none
LimitNOFILE=1048576
[Install]
WantedBy=multi-user.target

View File

@ -1,17 +0,0 @@
[Unit]
Description=HYApp admin platform frontend
After=docker.service network-online.target hyapp-admin-server.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
EnvironmentFile=/etc/hyapp/admin-platform/docker.env
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 ${IMAGE}
ExecStop=/usr/bin/env docker stop -t ${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

View File

@ -1,17 +0,0 @@
[Unit]
Description=HYApp admin-server
After=docker.service network-online.target
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
EnvironmentFile=/etc/hyapp/admin-server/docker.env
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --mount=type=bind,src=/var/lib/hyapp/admin-server/storage,dst=/app/storage ${IMAGE}
ExecStop=/usr/bin/env docker stop -t ${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

View File

@ -1,24 +0,0 @@
[Unit]
Description=Hyapp cron-service container
Requires=docker.service
After=network-online.target docker.service
Wants=network-online.target
StartLimitIntervalSec=120
StartLimitBurst=3
[Service]
Type=simple
EnvironmentFile=/etc/hyapp/cron-service/docker.env
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13007 -service=cron-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
Restart=on-failure
RestartSec=5s
TimeoutStartSec=60
TimeoutStopSec=120
KillMode=none
LimitNOFILE=1048576
[Install]
WantedBy=multi-user.target

View File

@ -1,24 +0,0 @@
[Unit]
Description=Hyapp game-service container
Requires=docker.service
After=network-online.target docker.service
Wants=network-online.target
StartLimitIntervalSec=120
StartLimitBurst=3
[Service]
Type=simple
EnvironmentFile=/etc/hyapp/game-service/docker.env
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13008 -service=game-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
Restart=on-failure
RestartSec=5s
TimeoutStartSec=60
TimeoutStopSec=90
KillMode=none
LimitNOFILE=1048576
[Install]
WantedBy=multi-user.target

View File

@ -1,24 +0,0 @@
[Unit]
Description=Hyapp gateway-service container
Requires=docker.service
After=network-online.target docker.service
Wants=network-online.target
StartLimitIntervalSec=120
StartLimitBurst=3
[Service]
Type=simple
EnvironmentFile=/etc/hyapp/gateway-service/docker.env
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="wget -q -O - http://127.0.0.1:13000/healthz/ready >/dev/null" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
Restart=on-failure
RestartSec=5s
TimeoutStartSec=60
TimeoutStopSec=90
KillMode=none
LimitNOFILE=1048576
[Install]
WantedBy=multi-user.target

View File

@ -1,23 +0,0 @@
[Unit]
Description=Hyapp notice-service container
Requires=docker.service
After=network-online.target docker.service
Wants=network-online.target
StartLimitIntervalSec=120
StartLimitBurst=3
[Service]
Type=simple
EnvironmentFile=/etc/hyapp/notice-service/docker.env
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13009 -service=notice-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
Restart=on-failure
RestartSec=5s
TimeoutStartSec=60
TimeoutStopSec=90
KillMode=none
LimitNOFILE=1048576
[Install]
WantedBy=multi-user.target

View File

@ -1,24 +0,0 @@
[Unit]
Description=Hyapp room-service container
Requires=docker.service
After=network-online.target docker.service
Wants=network-online.target
StartLimitIntervalSec=120
StartLimitBurst=3
[Service]
Type=simple
EnvironmentFile=/etc/hyapp/room-service/docker.env
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13001 -service=room-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
Restart=on-failure
RestartSec=5s
TimeoutStartSec=60
TimeoutStopSec=120
KillMode=none
LimitNOFILE=1048576
[Install]
WantedBy=multi-user.target

View File

@ -1,17 +0,0 @@
[Unit]
Description=HYApp statistics-service
After=docker.service network-online.target
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
EnvironmentFile=/etc/hyapp/statistics-service/docker.env
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly ${IMAGE}
ExecStop=/usr/bin/env docker stop -t ${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

View File

@ -1,24 +0,0 @@
[Unit]
Description=Hyapp user-service container
Requires=docker.service
After=network-online.target docker.service
Wants=network-online.target
StartLimitIntervalSec=120
StartLimitBurst=3
[Service]
Type=simple
EnvironmentFile=/etc/hyapp/user-service/docker.env
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13005 -service=user-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
Restart=on-failure
RestartSec=5s
TimeoutStartSec=60
TimeoutStopSec=90
KillMode=none
LimitNOFILE=1048576
[Install]
WantedBy=multi-user.target

View File

@ -1,23 +0,0 @@
[Unit]
Description=Hyapp wallet-service container
Requires=docker.service
After=network-online.target docker.service
Wants=network-online.target
StartLimitIntervalSec=120
StartLimitBurst=3
[Service]
Type=simple
EnvironmentFile=/etc/hyapp/wallet-service/docker.env
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --mount=type=bind,src=/etc/hyapp/secrets,dst=/etc/hyapp/secrets,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13004 -service=wallet-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
Restart=on-failure
RestartSec=5s
TimeoutStartSec=60
TimeoutStopSec=90
KillMode=none
LimitNOFILE=1048576
[Install]
WantedBy=multi-user.target

View File

@ -1,307 +0,0 @@
# 腾讯云 TAT 滚动部署方案
本方案只部署本仓库 Go 微服务,不接管同机已有 Java 项目。远端连接使用腾讯云 TAT发布时只操作 `hyapp-*` systemd unit、`hyapp-*` Docker 容器和 `/etc/hyapp/<service>/docker.env`
MySQL、Redis、MQ 都按腾讯云托管资源处理。本方案不会在这些 CVM 上部署、重启或迁移本地 MySQL/Redis/MQ服务配置只需要指向腾讯云托管实例的内网地址。
## 机器拓扑
`deploy` 机器是发布控制面,不承载业务流量:
| 机器 | 实例 ID | 公网 IP | 私网 IP | 角色 |
| --- | --- | --- | --- | --- |
| `deploy` | `ns-ntmpcd3a` | `43.164.75.199` | `10.2.1.3` | 构建镜像、推送镜像、调用 TAT/CLB API 调度发布 |
当前机器按截图落到以下服务:
| 机器 | 实例 ID | 私网 IP | 本仓库服务 |
| --- | --- | --- | --- |
| `gateway-1` | `ins-aeyaoj40` | `10.2.1.8` | `gateway-service` |
| `gateway-2` | `ins-irvi5b7u` | `10.2.2.17` | `gateway-service` |
| `new-app-1` | `ins-fi5zufpk` | `10.2.1.4` | `room-service`, `user-service` |
| `new-app-2` | `ins-hwhaoe8c` | `10.2.1.13` | `room-service`, `user-service` |
| `new-game-1` | `ins-ir229jtw` | `10.2.1.10` | `game-service` |
| `new-game-2` | `ins-460rw7gu` | `10.2.1.6` | `game-service` |
| `new-app-activity-1` | `ins-n8wut6n8` | `10.2.1.11` | `activity-service`, `notice-service` |
| `new-app-activity-2` | `ins-d1n303sa` | `10.2.1.7` | `activity-service`, `notice-service` |
| `pay-1` | `ins-ceqfcxd2` | `10.2.11.14` | `wallet-service` |
| `pay-2` | `ins-awhb74q6` | `10.2.12.5` | `wallet-service` |
如果 `new-app-*` 只部署 `room-service` 或只部署 `user-service`,直接删掉 `inventory` 中对应服务的 host 绑定,不要让脚本猜。
## 部署模式
采用“deploy 服务器调度 + 业务机只运行镜像”的模式:
1. `deploy` 服务器拉取本仓库代码,构建 Docker 镜像,推送到 CCR/Harbor。
2. `deploy` 服务器调用腾讯云 CLB API 对目标实例摘流。
3. `deploy` 服务器通过 TAT 在目标业务机执行 `docker pull``systemctl restart hyapp-<service>`
4. 目标业务机只保存 `/etc/hyapp/<service>/config.yaml``docker.env`,不拉代码、不编译、不保存 Git 凭据。
不要让每台业务服务器自己 `git pull && build`。那会把代码权限、构建环境、版本一致性和回滚都扩散到业务机,且多服务发布时无法统一做 CLB 摘流。
## 前置条件
1. 每台目标机已安装腾讯云 TAT agent控制台能执行命令。
2. 每个 Go 服务已按 `deploy/standalone/systemd` 安装对应 systemd unit。
3. 每个 Go 服务已有 `/etc/hyapp/<service>/config.yaml``/etc/hyapp/<service>/docker.env`
4. 每个服务至少有两台实例CLB 后端都挂实例端口。
5. `room-service` 每台机器必须有唯一 `node_id``advertise_addr` 必须是当前机器私网地址,例如 `10.2.1.4:13001`,不能写内网 CLB。
6. 每个 `config.yaml``mysql_dsn``redis_addr`、MQ endpoint/topic/group 指向腾讯云托管资源的私网地址。
7. 发布机安装腾讯云 Python SDK 和 YAML 解析依赖:
```bash
python3 -m pip install tencentcloud-sdk-python PyYAML
```
凭据通过环境变量提供:
```bash
export TENCENTCLOUD_SECRET_ID="..."
export TENCENTCLOUD_SECRET_KEY="..."
export TENCENTCLOUD_REGION="me-saudi-arabia"
```
如果继续沿用 `hy-app-monitor`,可以把这些变量放在 deploy 机器的 `/opt/hy-app-monitor/.env`,运行脚本时传 `--env-file /opt/hy-app-monitor/.env`。本地开发机路径 `/Users/hy/Documents/hy/hy-app-monitor/.env` 只用于本机调试。
## Deploy 服务器初始化
`deploy / 10.2.1.3` 上安装基础工具:
```bash
sudo mkdir -p /opt/deploy/sources /opt/deploy/inventory
sudo yum install -y git docker python3 python3-pip || sudo apt-get update && sudo apt-get install -y git docker.io python3 python3-pip
sudo systemctl enable --now docker
python3 -m pip install --user tencentcloud-sdk-python PyYAML
```
拉取本仓库:
```bash
cd /opt/deploy/sources
git clone <YOUR_GIT_REPO_URL> hyapp-server
cd hyapp-server
```
准备 inventory
```bash
cp deploy/tencent-tat/inventory.prod.example.json /opt/deploy/inventory/hyapp.prod.json
vi /opt/deploy/inventory/hyapp.prod.json
```
`inventory` 里的 `registry` 必须和实际镜像仓库一致。如果使用 `hy-app-monitor` 里的 Harbor 配置,通常是:
```text
registry = ${HARBOR_REGISTRY}/${HARBOR_PROJECT}
```
业务配置放到各目标机 `/etc/hyapp/<service>/config.yaml`。腾讯云 MySQL、Redis、MQ、JWT、IM/RTC/COS 等密钥不要提交到 Git。
配置字段契约以 `services/<service>/configs/config.tencent.example.yaml` 为准。开发时如果在本地 `config.yaml``config.docker.yaml` 新增字段,必须同时补到 `config.tencent.example.yaml`;生产渲染前运行校验,避免线上漏字段:
```bash
python3 deploy/tencent-tat/validate_rendered_configs.py \
--inventory deploy/tencent-tat/inventory.prod.example.json
```
如果已经渲染了生产最终配置,还要校验每台机器的最终 YAML
```bash
python3 deploy/tencent-tat/validate_rendered_configs.py \
--inventory deploy/tencent-tat/inventory.prod.example.json \
--rendered-dir /tmp/hyapp-rendered-prod
```
该脚本只比较 YAML key不打印配置值避免把生产密钥带到日志里。
## 构建并推送镜像
`deploy` 服务器上执行。2 核 2GiB 机器可以作为调度器;如果全量构建 OOM 或太慢,把构建放到 CIdeploy 服务器只负责调度发布。
```bash
cd /opt/deploy/sources/hyapp-server
TAG="$(date -u +%Y%m%d%H%M%S)-$(git rev-parse --short HEAD)"
REGISTRY="10.2.1.3:18082/hyapp"
docker build -t "$REGISTRY/gateway-service:$TAG" -f services/gateway-service/Dockerfile .
docker build -t "$REGISTRY/room-service:$TAG" -f services/room-service/Dockerfile .
docker build -t "$REGISTRY/user-service:$TAG" -f services/user-service/Dockerfile .
docker build -t "$REGISTRY/wallet-service:$TAG" -f services/wallet-service/Dockerfile .
docker build -t "$REGISTRY/activity-service:$TAG" -f services/activity-service/Dockerfile .
docker build -t "$REGISTRY/game-service:$TAG" -f services/game-service/Dockerfile .
docker build -t "$REGISTRY/notice-service:$TAG" -f services/notice-service/Dockerfile .
docker push "$REGISTRY/gateway-service:$TAG"
docker push "$REGISTRY/room-service:$TAG"
docker push "$REGISTRY/user-service:$TAG"
docker push "$REGISTRY/wallet-service:$TAG"
docker push "$REGISTRY/activity-service:$TAG"
docker push "$REGISTRY/game-service:$TAG"
docker push "$REGISTRY/notice-service:$TAG"
```
如果实际用 Harbor`REGISTRY` 换成 `${HARBOR_REGISTRY}/${HARBOR_PROJECT}`,并先 `docker login`
## 配置 inventory
复制模板后填真实 CLB
```bash
cp deploy/tencent-tat/inventory.prod.example.json deploy/tencent-tat/inventory.prod.json
vi deploy/tencent-tat/inventory.prod.json
```
必须替换:
- `gateway-service.clb.load_balancer_id`:公网 gateway CLB。
- `gateway-service.clb.listener_ids`:公网 HTTP/HTTPS 监听器。
- 内部服务各自的 `load_balancer_id``listener_ids`room/user/wallet/activity/game 内网 CLB。
脚本通过实例 ID、私网 IP、端口三者匹配 CLB target。发布前会把当前实例在相关 listener 下的权重改成 `0`,服务健康后恢复原权重。
`managed_dependencies` 只记录部署边界:腾讯云 MySQL/Redis/MQ 是外部托管依赖,不纳入 TAT 发布动作。生产变更这些资源时单独走云数据库、Redis 或 MQ 的变更流程。
当前生产 inventory 已写入真实绑定:
| 服务 | CLB | 监听器 | 对外/内网入口 |
| --- | --- | --- | --- |
| `gateway-service` | `lb-cbiabr1q` | `lbl-ktvz8m6k` / rule `loc-pwedmsmm` | `https://api.global-interaction.com` -> `13000` |
| `room-service` | `lb-epvnr4o0` | `lbl-d2urlzba` | `10.2.1.16:13001` |
| `user-service` | `lb-epvnr4o0` | `lbl-honi8z3a` | `10.2.1.16:13005` |
| `activity-service` | `lb-epvnr4o0` | `lbl-j7oqtfhq` | `10.2.1.16:13006` |
| `game-service` | `lb-epvnr4o0` | `lbl-ku138b4y` | `10.2.1.16:13008` |
| `notice-service` | `lb-epvnr4o0` | `lbl-aux9xsjq` | `10.2.1.16:13009` |
| `wallet-service` | `lb-4f5xi6p0` | `lbl-9wi5mvu4` | `10.2.1.15:13004` |
Harbor 项目使用 `hyapp`,当前生产镜像前缀是 `10.2.1.3:18082/hyapp`。不要使用其他业务项目名前缀。
## 线上服务配置边界
这些机器上只部署 Go 服务进程:
- `gateway-service``room_service_addr/user_service_addr/wallet_service_addr/activity_service_addr/game_service_addr` 指向各服务内网 CLB/DNS。
- `room-service``mysql_dsn` 指向腾讯云 MySQL`redis_addr` 指向腾讯云 Redis`advertise_addr` 指向本机私网 IP。
- `user-service``mysql_dsn`、worker 里引用的 wallet/room MySQL DSN、登录风控 Redis 都指向腾讯云托管实例。
- `wallet-service``activity-service``game-service`MySQL 和下游 gRPC 地址都使用腾讯云托管资源或内网 CLB/DNS。
- `notice-service``mysql_dsn` 指向 `hyapp_notice``wallet_database` 指向 `hyapp_wallet`;它只读取 wallet outbox 并写自己的投递位点,不修改钱包事实表。
- MQ 当前只作为 outbox/event bus 方向的外部托管依赖;本脚本不会创建 topic、consumer group 或本地 broker。
腾讯云 MySQL DSN 必须保留 `loc=UTC`。Redis 使用同一托管实例时,用 `redis_db` 或 key prefix 区分业务域,不要把 Redis 当恢复事实来源。
## 常用命令
先看计划,不执行远端动作:
```bash
python3 deploy/tencent-tat/hyapp_tat_deploy.py plan \
--inventory deploy/tencent-tat/inventory.prod.json \
--services gateway-service,room-service
```
用 TAT 连接目标机并自动发现真实部署状态和 CLB 绑定:
```bash
python3 deploy/tencent-tat/hyapp_tat_deploy.py discover \
--env-file /opt/hy-app-monitor/.env \
--inventory /opt/deploy/inventory/hyapp.prod.json \
--services gateway-service,room-service,user-service,wallet-service,activity-service,game-service,notice-service
```
`discover` 会读取每台目标机的 `hyapp-*` systemd unit、`/etc/hyapp/*/docker.env``hyapp-*` 容器和监听端口,并通过 CLB API 按后端私网 IP/端口反查负载均衡绑定。
单服务重启:
```bash
python3 deploy/tencent-tat/hyapp_tat_deploy.py restart \
--env-file /opt/hy-app-monitor/.env \
--inventory /opt/deploy/inventory/hyapp.prod.json \
--services wallet-service \
--yes
```
多服务重启:
```bash
python3 deploy/tencent-tat/hyapp_tat_deploy.py restart \
--env-file /opt/hy-app-monitor/.env \
--inventory /opt/deploy/inventory/hyapp.prod.json \
--services room-service,user-service,wallet-service,activity-service,game-service,notice-service \
--yes
```
单服务更新部署:
```bash
python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy \
--env-file /opt/hy-app-monitor/.env \
--inventory /opt/deploy/inventory/hyapp.prod.json \
--services gateway-service \
--tag 20260513-1349 \
--yes
```
多服务更新部署:
```bash
python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy \
--env-file /opt/hy-app-monitor/.env \
--inventory /opt/deploy/inventory/hyapp.prod.json \
--services gateway-service,room-service,user-service,wallet-service,activity-service,game-service,notice-service \
--tag 20260513-1349 \
--yes
```
如果某个服务镜像 tag 不同,可以显式传镜像:
```bash
python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy \
--env-file /opt/hy-app-monitor/.env \
--inventory /opt/deploy/inventory/hyapp.prod.json \
--services gateway-service,wallet-service \
--image gateway-service=10.2.1.3:18082/hyapp/gateway-service:gw-20260513 \
--image wallet-service=10.2.1.3:18082/hyapp/wallet-service:pay-20260513 \
--yes
```
只维护某一台机器时使用 `--hosts`,例如只重启 `gateway-1`
```bash
python3 deploy/tencent-tat/hyapp_tat_deploy.py restart \
--env-file /opt/hy-app-monitor/.env \
--inventory /opt/deploy/inventory/hyapp.prod.json \
--services gateway-service \
--hosts gateway-1 \
--yes
```
## 无感更新顺序
对每个服务实例,脚本固定执行:
1. 读取该实例当前 CLB 绑定和原权重。
2. 将该实例在所有匹配 listener/rule 下的权重改为 `0`
3. 等待 `drain_seconds`,让 CLB 停止分配新请求。
4. 通过 TAT 在目标机更新 `docker.env``IMAGE`,执行 `docker pull`
5. `systemctl restart hyapp-<service>`Docker 发送 `SIGTERM`,服务进入 graceful shutdown。
6. 等待 systemd active 和 Docker health 恢复。
7. 恢复该实例原 CLB 权重。
8. 等待 `stabilize_seconds` 后进入下一台实例。
如果重启或健康检查失败,脚本会停止后续步骤;已经摘流的故障实例保持权重 `0`,避免坏实例重新接流,需要人工确认后再恢复。
## 用户无感边界
- 客户端长连接、房间公屏、单聊仍由腾讯云 IM 承接gateway 重启不会断开 IM。
- gateway 是 HTTP 入口,摘流后只影响当前实例的新 HTTP 请求;另一台 gateway 继续接流。
- 内部服务通过各自内网 CLB 摘流滚动gateway 的 gRPC client 已配置短重试,只覆盖瞬时 `UNAVAILABLE`
- `room-service` 已有 owner route/node registry。旧 owner 停机时会释放 lease异常退出时最多等 `lease_ttl` 到期后其他节点接管。
- 正在执行的单个 HTTP/gRPC 请求可能返回失败,客户端必须按现有接口幂等键重试;“无感”指发布期间整体入口不断流、房间 IM 不断连。
## 禁止项
- 不要使用 `--no-clb` 做正常发布;它只适合孤立排障。
- 不要多个 `room-service` 共用同一个 `node_id`
- 不要把 `room-service.advertise_addr` 写成 CLB 地址。
- 不要先 `systemctl restart` 再摘 CLB。
- 不要在同机 Java compose 目录里执行本仓库部署命令。

View File

@ -1,703 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import base64
import json
import os
import shlex
import sys
import time
from pathlib import Path
from typing import Any
def load_env_file(path: str) -> None:
# hy-app-monitor 现有 .env 可直接复用;这里只注入缺失变量,不覆盖调用方显式 export 的值。
if not path:
return
env_path = Path(path).expanduser().resolve()
if not env_path.exists():
raise RuntimeError(f"env file not found: {env_path}")
for raw_line in env_path.read_text(encoding="utf-8", errors="replace").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
def load_inventory(path: str) -> dict[str, Any]:
inventory_path = Path(path).expanduser().resolve()
with inventory_path.open("r", encoding="utf-8") as handle:
inventory = json.load(handle)
validate_inventory(inventory)
return inventory
def validate_inventory(inventory: dict[str, Any]) -> None:
# 发布脚本只认明确的 host/service 映射,避免误操作同机已有 Java 项目。
if not isinstance(inventory.get("hosts"), dict) or not inventory["hosts"]:
raise RuntimeError("inventory.hosts is required")
if not isinstance(inventory.get("services"), dict) or not inventory["services"]:
raise RuntimeError("inventory.services is required")
for host_name, host in inventory["hosts"].items():
if not str(host.get("instance_id") or "").strip():
raise RuntimeError(f"hosts.{host_name}.instance_id is required")
if not str(host.get("private_ip") or "").strip():
raise RuntimeError(f"hosts.{host_name}.private_ip is required")
for service_name, service in inventory["services"].items():
for key in ("unit", "container", "env_file", "target_port", "hosts"):
if key not in service:
raise RuntimeError(f"services.{service_name}.{key} is required")
for host_name in service["hosts"]:
if host_name not in inventory["hosts"]:
raise RuntimeError(f"services.{service_name}.hosts contains unknown host: {host_name}")
def csv_values(text: str) -> list[str]:
return [item.strip() for item in str(text or "").split(",") if item.strip()]
def selected_service_names(inventory: dict[str, Any], raw_services: str) -> list[str]:
names = csv_values(raw_services)
if not names:
raise RuntimeError("--services is required")
unknown = [name for name in names if name not in inventory["services"]]
if unknown:
raise RuntimeError(f"unsupported services: {', '.join(unknown)}")
return list(dict.fromkeys(names))
def selected_host_names(service: dict[str, Any], raw_hosts: str) -> list[str]:
hosts = list(dict.fromkeys(str(item) for item in service.get("hosts") or []))
requested = csv_values(raw_hosts)
if not requested:
return hosts
missing = [host for host in requested if host not in hosts]
if missing:
raise RuntimeError(f"selected hosts are not assigned to this service: {', '.join(missing)}")
return requested
def image_overrides(items: list[str]) -> dict[str, str]:
overrides: dict[str, str] = {}
for item in items:
if "=" not in item:
raise RuntimeError("--image must use service=image")
service_name, image = item.split("=", 1)
service_name = service_name.strip()
image = image.strip()
if not service_name or not image:
raise RuntimeError("--image must use non-empty service=image")
overrides[service_name] = image
return overrides
def render_image(inventory: dict[str, Any], service_name: str, tag: str, overrides: dict[str, str]) -> str:
if service_name in overrides:
return overrides[service_name]
if not tag:
raise RuntimeError(f"--tag or --image {service_name}=... is required for deploy")
service = inventory["services"][service_name]
registry = str(inventory.get("registry") or "").rstrip("/")
template = str(service.get("image_template") or "${REGISTRY}/" + service_name + ":${TAG}")
return template.replace("${REGISTRY}", registry).replace("${TAG}", tag)
def build_plan(
inventory: dict[str, Any],
*,
action: str,
service_names: list[str],
raw_hosts: str,
tag: str,
overrides: dict[str, str],
no_clb: bool,
) -> list[dict[str, Any]]:
# 顺序是服务优先、实例串行;多服务发布不会同时摘掉同一组双机实例。
plan: list[dict[str, Any]] = []
for service_name in service_names:
service = inventory["services"][service_name]
image = render_image(inventory, service_name, tag, overrides) if action == "deploy" else ""
for host_name in selected_host_names(service, raw_hosts):
host = inventory["hosts"][host_name]
clb = service.get("clb") or {}
plan.append(
{
"action": action,
"service": service_name,
"host": host_name,
"instance_id": host["instance_id"],
"private_ip": host["private_ip"],
"target_port": int(service["target_port"]),
"unit": service["unit"],
"container": service["container"],
"image": image,
"clb_enabled": bool(clb.get("enabled")) and not no_clb,
"clb_load_balancer_id": str(clb.get("load_balancer_id") or ""),
"clb_listener_ids": list(clb.get("listener_ids") or []),
"drain_seconds": int(clb.get("drain_seconds") or 0),
}
)
return plan
def secret_id() -> str:
return os.environ.get("TENCENTCLOUD_SECRET_ID") or os.environ.get("TENCENT_SECRET_ID") or ""
def secret_key() -> str:
return os.environ.get("TENCENTCLOUD_SECRET_KEY") or os.environ.get("TENCENT_SECRET_KEY") or ""
def region(inventory: dict[str, Any]) -> str:
return os.environ.get("TENCENTCLOUD_REGION") or os.environ.get("DEPLOY_REGION") or str(inventory.get("region") or "")
def require_tencent_credentials(inventory: dict[str, Any]) -> tuple[str, str, str]:
sid = secret_id()
skey = secret_key()
reg = region(inventory)
missing = []
if not sid:
missing.append("TENCENTCLOUD_SECRET_ID")
if not skey:
missing.append("TENCENTCLOUD_SECRET_KEY")
if not reg:
missing.append("TENCENTCLOUD_REGION")
if missing:
raise RuntimeError("missing Tencent Cloud credentials: " + ", ".join(missing))
return sid, skey, reg
def build_tat_client(inventory: dict[str, Any]) -> Any:
sid, skey, reg = require_tencent_credentials(inventory)
try:
from tencentcloud.common import credential
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
from tencentcloud.tat.v20201028 import tat_client
except Exception as exc: # noqa: BLE001
raise RuntimeError(f"tencentcloud sdk unavailable: {exc}") from exc
return tat_client.TatClient(
credential.Credential(sid, skey),
reg,
ClientProfile(httpProfile=HttpProfile(endpoint="tat.tencentcloudapi.com")),
)
def build_clb_client(inventory: dict[str, Any]) -> Any:
sid, skey, reg = require_tencent_credentials(inventory)
try:
from tencentcloud.clb.v20180317 import clb_client
from tencentcloud.common import credential
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
except Exception as exc: # noqa: BLE001
raise RuntimeError(f"tencentcloud sdk unavailable: {exc}") from exc
return clb_client.ClbClient(
credential.Credential(sid, skey),
reg,
ClientProfile(httpProfile=HttpProfile(endpoint="clb.tencentcloudapi.com")),
)
def invoke_tencent(action: Any, label: str, retries: int = 3) -> Any:
try:
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
except Exception: # noqa: BLE001
TencentCloudSDKException = Exception # type: ignore[assignment]
last_error: Exception | None = None
for attempt in range(1, retries + 1):
try:
return action()
except TencentCloudSDKException as exc: # type: ignore[misc]
last_error = exc
if attempt == retries:
break
time.sleep(2 * attempt)
raise RuntimeError(f"{label} failed: {last_error}") from last_error
def decode_tat_output(output: str) -> str:
if not output:
return ""
return base64.b64decode(output).decode("utf-8", errors="replace")
def run_tat_shell(
client: Any,
*,
instance_id: str,
script: str,
command_name: str,
timeout_seconds: int,
poll_seconds: int,
) -> dict[str, Any]:
# TAT 是本方案唯一远端入口;脚本不会要求目标机开放 SSH。
from tencentcloud.tat.v20201028 import models as tat_models
request = tat_models.RunCommandRequest()
request.from_json_string(
json.dumps(
{
"CommandName": command_name,
"CommandType": "SHELL",
"Content": base64.b64encode(script.encode("utf-8")).decode("ascii"),
"InstanceIds": [instance_id],
"Timeout": timeout_seconds,
"WorkingDirectory": "/root",
}
)
)
response = json.loads(invoke_tencent(lambda: client.RunCommand(request), "RunCommand").to_json_string())
invocation_id = response["InvocationId"]
deadline = time.time() + timeout_seconds + 60
while time.time() < deadline:
time.sleep(max(poll_seconds, 1))
query = tat_models.DescribeInvocationTasksRequest()
query.from_json_string(
json.dumps(
{
"HideOutput": False,
"Limit": 50,
"Filters": [{"Name": "invocation-id", "Values": [invocation_id]}],
}
)
)
payload = json.loads(invoke_tencent(lambda: client.DescribeInvocationTasks(query), "DescribeInvocationTasks").to_json_string())
for task in payload.get("InvocationTaskSet") or []:
if str(task.get("InstanceId") or "") != instance_id:
continue
status = str(task.get("TaskStatus") or "")
if status in {"SUCCESS", "FAILED", "TIMEOUT", "CANCELLED", "START_FAILED"}:
return {
"status": status,
"output": decode_tat_output(str((task.get("TaskResult") or {}).get("Output") or "")),
}
return {"status": "TIMEOUT", "output": ""}
def call_clb(client: Any, method_name: str, payload: dict[str, Any]) -> dict[str, Any]:
from tencentcloud.clb.v20180317 import models as clb_models
request_class = getattr(clb_models, f"{method_name}Request")
request = request_class()
request.from_json_string(json.dumps(payload))
response = invoke_tencent(lambda: getattr(client, method_name)(request), method_name)
decoded = json.loads(response.to_json_string())
if isinstance(decoded.get("Response"), dict):
return dict(decoded["Response"])
return dict(decoded)
def wait_clb_task(client: Any, task_id: str, *, timeout_seconds: int, poll_seconds: int) -> None:
# ModifyTargetWeight 是异步任务,必须轮询到成功后才能重启实例。
deadline = time.time() + max(timeout_seconds, 1)
while time.time() < deadline:
payload = call_clb(client, "DescribeTaskStatus", {"TaskId": task_id})
status = int(payload.get("Status") if payload.get("Status") is not None else 2)
if status == 0:
return
if status == 1:
raise RuntimeError(f"CLB task failed: {payload.get('Message') or task_id}")
time.sleep(max(poll_seconds, 1))
raise RuntimeError(f"CLB task timed out: {task_id}")
def target_matches(step: dict[str, Any], target: dict[str, Any]) -> bool:
instance_id = str(target.get("InstanceId") or target.get("TargetId") or "").strip()
if instance_id and instance_id != step["instance_id"]:
return False
target_port = int(target.get("Port") or 0)
if target_port and target_port != int(step["target_port"]):
return False
ips = [str(item or "").strip() for item in (target.get("PrivateIpAddresses") or [target.get("IP")]) if str(item or "").strip()]
if ips and step["private_ip"] not in ips:
return False
return bool(instance_id or ips)
def binding_key(binding: dict[str, Any]) -> str:
return "|".join(
[
str(binding.get("listener_id") or ""),
str(binding.get("location_id") or ""),
str(binding.get("instance_id") or ""),
str(int(binding.get("port") or 0)),
]
)
def collect_bindings(step: dict[str, Any], listeners: list[dict[str, Any]]) -> list[dict[str, Any]]:
bindings: dict[str, dict[str, Any]] = {}
for listener in listeners:
listener_id = str(listener.get("ListenerId") or "").strip()
for target in listener.get("Targets") or []:
if target_matches(step, target):
binding = make_binding(step, listener_id, "", target)
bindings[binding_key(binding)] = binding
for rule in listener.get("Rules") or []:
location_id = str(rule.get("LocationId") or "").strip()
for target in rule.get("Targets") or []:
if target_matches(step, target):
binding = make_binding(step, listener_id, location_id, target)
bindings[binding_key(binding)] = binding
return list(bindings.values())
def make_binding(step: dict[str, Any], listener_id: str, location_id: str, target: dict[str, Any]) -> dict[str, Any]:
return {
"listener_id": listener_id,
"location_id": location_id,
"instance_id": str(target.get("InstanceId") or target.get("TargetId") or step["instance_id"]).strip(),
"port": int(target.get("Port") or step["target_port"]),
"weight": int(target.get("Weight") or 0),
}
def prepare_clb_snapshot(client: Any, step: dict[str, Any]) -> dict[str, Any]:
if should_auto_discover_clb(step):
return discover_clb_snapshot(client, step)
payload = call_clb(
client,
"DescribeTargets",
{
"LoadBalancerId": step["clb_load_balancer_id"],
"ListenerIds": step["clb_listener_ids"],
},
)
bindings = collect_bindings(step, list(payload.get("Listeners") or []))
if not bindings:
return discover_clb_snapshot(client, step)
return {"step": step, "bindings": bindings}
def should_auto_discover_clb(step: dict[str, Any]) -> bool:
load_balancer_id = str(step.get("clb_load_balancer_id") or "").strip()
listener_ids = [str(item or "").strip() for item in step.get("clb_listener_ids") or []]
if not load_balancer_id or "REPLACE" in load_balancer_id or load_balancer_id.upper() == "AUTO":
return True
return any((not item) or "REPLACE" in item or item.upper() == "AUTO" for item in listener_ids)
def discover_load_balancers_for_backend(client: Any, step: dict[str, Any]) -> list[dict[str, Any]]:
# 腾讯云 CLB 支持按后端私网 IP 反查负载均衡,避免手填内网 CLB ID。
offset = 0
found: list[dict[str, Any]] = []
while True:
payload = call_clb(
client,
"DescribeLoadBalancers",
{
"BackendPrivateIps": [step["private_ip"]],
"WithRs": 1,
"Limit": 100,
"Offset": offset,
},
)
items = list(payload.get("LoadBalancerSet") or [])
found.extend(items)
total = int(payload.get("TotalCount") or len(found))
if len(found) >= total or not items:
break
offset += len(items)
return found
def discover_clb_snapshot(client: Any, step: dict[str, Any]) -> dict[str, Any]:
load_balancers = discover_load_balancers_for_backend(client, step)
matched: list[dict[str, Any]] = []
for load_balancer in load_balancers:
load_balancer_id = str(load_balancer.get("LoadBalancerId") or "").strip()
if not load_balancer_id:
continue
payload = call_clb(client, "DescribeTargets", {"LoadBalancerId": load_balancer_id})
bindings = collect_bindings(step, list(payload.get("Listeners") or []))
if bindings:
matched.append({"load_balancer_id": load_balancer_id, "bindings": bindings})
if not matched:
raise RuntimeError(f"CLB target not found for {step['service']} on {step['host']} {step['private_ip']}:{step['target_port']}")
if len(matched) > 1:
summary = ", ".join(item["load_balancer_id"] for item in matched)
raise RuntimeError(f"multiple CLB bindings discovered for {step['service']} on {step['host']}: {summary}")
discovered = matched[0]
step["clb_load_balancer_id"] = discovered["load_balancer_id"]
step["clb_listener_ids"] = sorted({binding["listener_id"] for binding in discovered["bindings"] if binding["listener_id"]})
return {"step": step, "bindings": discovered["bindings"], "discovered": True}
def modify_binding_weight(client: Any, inventory: dict[str, Any], binding: dict[str, Any], weight: int, load_balancer_id: str) -> None:
payload: dict[str, Any] = {
"LoadBalancerId": load_balancer_id,
"ListenerId": binding["listener_id"],
"Targets": [{"InstanceId": binding["instance_id"], "Port": int(binding["port"])}],
"Weight": int(weight),
}
if binding.get("location_id"):
payload["LocationId"] = binding["location_id"]
response = call_clb(client, "ModifyTargetWeight", payload)
wait_clb_task(
client,
str(response.get("RequestId") or "").strip(),
timeout_seconds=int(inventory.get("clb_task_timeout_seconds") or 180),
poll_seconds=int(inventory.get("clb_poll_seconds") or 2),
)
def set_clb_snapshot_weight(client: Any, inventory: dict[str, Any], snapshot: dict[str, Any], weight: int | None) -> None:
step = snapshot["step"]
for binding in snapshot["bindings"]:
target_weight = int(binding["weight"]) if weight is None else int(weight)
modify_binding_weight(client, inventory, binding, target_weight, step["clb_load_balancer_id"])
def remote_restart_script(service: dict[str, Any], *, image: str, health_timeout_seconds: int) -> str:
# 远端只改当前 Go 服务的 docker.env 和 systemd unit避免碰同机 Java compose。
lines = [
"set -euo pipefail",
f"UNIT={shlex.quote(str(service['unit']))}",
f"CONTAINER={shlex.quote(str(service['container']))}",
f"ENV_FILE={shlex.quote(str(service['env_file']))}",
f"HEALTH_TIMEOUT={int(health_timeout_seconds)}",
]
if image:
lines.extend(
[
f"IMAGE={shlex.quote(image)}",
'test -f "$ENV_FILE"',
'TMP_FILE="$(mktemp)"',
'awk -v image="$IMAGE" \'BEGIN{done=0} /^IMAGE=/{print "IMAGE=" image; done=1; next} {print} END{if(!done) print "IMAGE=" image}\' "$ENV_FILE" > "$TMP_FILE"',
'install -m 0640 "$TMP_FILE" "$ENV_FILE"',
'rm -f "$TMP_FILE"',
'docker pull "$IMAGE"',
]
)
lines.extend(
[
'systemctl restart "$UNIT"',
'deadline=$((SECONDS + HEALTH_TIMEOUT))',
'while ! systemctl is-active --quiet "$UNIT"; do',
' if [ "$SECONDS" -ge "$deadline" ]; then',
' systemctl status "$UNIT" --no-pager || true',
' exit 20',
" fi",
" sleep 2",
"done",
'while [ "$SECONDS" -lt "$deadline" ]; do',
' state="$(docker inspect --format \'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}\' "$CONTAINER" 2>/dev/null || true)"',
' if [ "$state" = "healthy" ] || [ "$state" = "running" ]; then',
' docker ps --filter "name=$CONTAINER" --format "{{.Names}} {{.Status}}"',
" exit 0",
" fi",
" sleep 2",
"done",
'docker inspect "$CONTAINER" 2>/dev/null || true',
'journalctl -u "$UNIT" -n 80 --no-pager || true',
"exit 21",
]
)
return "\n".join(lines) + "\n"
def remote_discover_script() -> str:
# 只读取 hyapp 命名空间内的 unit/env/container不扫描或修改 Java 项目。
return r"""set -euo pipefail
echo "== hyapp units =="
systemctl list-units 'hyapp-*.service' --no-pager --all || true
echo "== hyapp env files =="
if [ -d /etc/hyapp ]; then
find /etc/hyapp -maxdepth 2 -name docker.env -print 2>/dev/null | sort | while read -r env_file; do
echo "-- $env_file"
sed -n 's/^\(IMAGE\|CONTAINER_NAME\|CONFIG_PATH\|STOP_TIMEOUT_SEC\)=/\1=/p' "$env_file" || true
done
else
echo "/etc/hyapp missing"
fi
echo "== hyapp containers =="
docker ps --filter 'name=hyapp-' --format '{{.Names}} {{.Image}} {{.Status}}' || true
echo "== listening ports =="
ss -lntp 2>/dev/null | awk 'NR==1 || /:13000|:13001|:13004|:13005|:13006|:13008|:13009|:13010|:13100|:13101|:13104|:13105|:13106|:13108|:13109|:13110/' || true
"""
def execute_discovery(inventory: dict[str, Any], plan: list[dict[str, Any]], *, dry_run: bool) -> dict[str, Any]:
if dry_run:
return {"ok": True, "dry_run": True, "steps": plan}
tat_client = build_tat_client(inventory)
clb_client = build_clb_client(inventory)
seen_hosts = list(dict.fromkeys(step["host"] for step in plan))
by_host = {name: next(step for step in plan if step["host"] == name) for name in seen_hosts}
hosts: list[dict[str, Any]] = []
for host_name, seed in by_host.items():
try:
result = run_tat_shell(
tat_client,
instance_id=seed["instance_id"],
script=remote_discover_script(),
command_name=f"hyapp-discover-{host_name}",
timeout_seconds=int(inventory.get("tat_timeout_seconds") or 900),
poll_seconds=int(inventory.get("tat_poll_seconds") or 2),
)
hosts.append(
{
"host": host_name,
"instance_id": seed["instance_id"],
"private_ip": seed["private_ip"],
"status": result["status"],
"output": str(result.get("output") or "")[-5000:],
}
)
except Exception as exc: # noqa: BLE001
hosts.append(
{
"host": host_name,
"instance_id": seed["instance_id"],
"private_ip": seed["private_ip"],
"status": "ERROR",
"error": str(exc),
}
)
clb_bindings: list[dict[str, Any]] = []
for step in plan:
if not step["clb_enabled"]:
continue
try:
snapshot = prepare_clb_snapshot(clb_client, dict(step))
clb_bindings.append(
{
"service": step["service"],
"host": step["host"],
"load_balancer_id": snapshot["step"]["clb_load_balancer_id"],
"listener_ids": snapshot["step"]["clb_listener_ids"],
"bindings": snapshot["bindings"],
"discovered": bool(snapshot.get("discovered")),
}
)
except Exception as exc: # noqa: BLE001
clb_bindings.append(
{
"service": step["service"],
"host": step["host"],
"error": str(exc),
}
)
return {"ok": True, "hosts": hosts, "clb": clb_bindings}
def execute_plan(inventory: dict[str, Any], plan: list[dict[str, Any]], *, dry_run: bool, assume_yes: bool) -> dict[str, Any]:
if dry_run:
return {"ok": True, "dry_run": True, "steps": plan}
if not assume_yes:
raise RuntimeError("real execution requires --yes")
tat_client = build_tat_client(inventory)
clb_client = build_clb_client(inventory) if any(step["clb_enabled"] for step in plan) else None
results: list[dict[str, Any]] = []
for step in plan:
started = time.time()
record = dict(step)
snapshot: dict[str, Any] | None = None
try:
if step["clb_enabled"]:
snapshot = prepare_clb_snapshot(clb_client, step)
set_clb_snapshot_weight(clb_client, inventory, snapshot, 0)
if step["drain_seconds"] > 0:
time.sleep(step["drain_seconds"])
record["clb_drained"] = True
service_cfg = inventory["services"][step["service"]]
script = remote_restart_script(
service_cfg,
image=step["image"] if step["action"] == "deploy" else "",
health_timeout_seconds=int(inventory.get("service_health_timeout_seconds") or 150),
)
remote = run_tat_shell(
tat_client,
instance_id=step["instance_id"],
script=script,
command_name=f"hyapp-{step['action']}-{step['service']}-{step['host']}",
timeout_seconds=int(inventory.get("tat_timeout_seconds") or 900),
poll_seconds=int(inventory.get("tat_poll_seconds") or 2),
)
record["remote_status"] = remote["status"]
record["remote_output"] = str(remote.get("output") or "")[-2000:]
if remote["status"] != "SUCCESS":
raise RuntimeError(f"TAT command failed: {remote['status']}")
if snapshot is not None:
# CLB 后端健康由 CLB 自己持续探测;服务本地健康通过后恢复原权重。
set_clb_snapshot_weight(clb_client, inventory, snapshot, None)
record["clb_restored"] = True
stabilize = int(inventory.get("stabilize_seconds") or 0)
if stabilize > 0:
time.sleep(stabilize)
record["status"] = "success"
record["duration_seconds"] = round(time.time() - started, 2)
results.append(record)
except Exception as exc: # noqa: BLE001
record["status"] = "failed"
record["error"] = str(exc)
record["duration_seconds"] = round(time.time() - started, 2)
results.append(record)
return {"ok": False, "steps": results, "error": str(exc)}
return {"ok": True, "steps": results}
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Deploy hyapp services through Tencent TAT with optional CLB drain.")
parser.add_argument("action", choices=["plan", "discover", "restart", "deploy"])
parser.add_argument("--inventory", default="deploy/tencent-tat/inventory.prod.example.json")
parser.add_argument("--env-file", default="", help="Optional .env file with Tencent Cloud credentials.")
parser.add_argument("--services", required=True, help="Comma separated service names.")
parser.add_argument("--hosts", default="", help="Optional comma separated host names for one-node maintenance.")
parser.add_argument("--tag", default="", help="Release tag used by image_template during deploy.")
parser.add_argument("--image", action="append", default=[], help="Override one image: service=image. Can be repeated.")
parser.add_argument("--no-clb", action="store_true", help="Skip CLB drain. Only use for isolated emergency maintenance.")
parser.add_argument("--dry-run", action="store_true", help="Print the execution plan without calling Tencent APIs.")
parser.add_argument("--yes", action="store_true", help="Required for real restart/deploy execution.")
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
try:
args = parse_args(argv)
load_env_file(args.env_file)
inventory = load_inventory(args.inventory)
service_names = selected_service_names(inventory, args.services)
overrides = image_overrides(args.image)
action = "restart" if args.action == "plan" else args.action
plan = build_plan(
inventory,
action=action,
service_names=service_names,
raw_hosts=args.hosts,
tag=args.tag,
overrides=overrides,
no_clb=args.no_clb,
)
if args.action == "plan":
print(json.dumps({"ok": True, "steps": plan}, ensure_ascii=False, indent=2))
return 0
if args.action == "discover":
result = execute_discovery(inventory, plan, dry_run=args.dry_run)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result.get("ok") else 1
result = execute_plan(inventory, plan, dry_run=args.dry_run, assume_yes=args.yes)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result.get("ok") else 1
except Exception as exc: # noqa: BLE001
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@ -1,218 +0,0 @@
{
"region": "me-saudi-arabia",
"registry": "10.2.1.3:18082/hyapp",
"tat_timeout_seconds": 900,
"tat_poll_seconds": 2,
"clb_task_timeout_seconds": 180,
"clb_poll_seconds": 2,
"service_health_timeout_seconds": 150,
"stabilize_seconds": 5,
"managed_dependencies": {
"mysql": "TencentDB for MySQL private endpoint; this deployment never starts local MySQL.",
"redis": "TencentDB for Redis private endpoint; room owner route and login/risk cache use this managed Redis.",
"mq": "Tencent Cloud managed MQ; future consumers only receive connection/topic config and are not deployed as local infra."
},
"deploy_server": {
"name": "deploy",
"instance_id": "ins-ntmpcd3a",
"public_ip": "43.164.75.199",
"private_ip": "10.2.1.3",
"role": "control plane only: build/push images and call Tencent TAT/CLB APIs; do not attach to business CLB."
},
"hosts": {
"gateway-1": {
"instance_id": "ins-aeyaoj40",
"private_ip": "10.2.1.8"
},
"gateway-2": {
"instance_id": "ins-irvi5b7u",
"private_ip": "10.2.2.17"
},
"new-app-1": {
"instance_id": "ins-fi5zufpk",
"private_ip": "10.2.1.4"
},
"new-app-2": {
"instance_id": "ins-hwhaoe8c",
"private_ip": "10.2.1.13"
},
"new-game-1": {
"instance_id": "ins-ir229jtw",
"private_ip": "10.2.1.10"
},
"new-game-2": {
"instance_id": "ins-460rw7gu",
"private_ip": "10.2.1.6"
},
"new-app-activity-1": {
"instance_id": "ins-n8wut6n8",
"private_ip": "10.2.1.11"
},
"new-app-activity-2": {
"instance_id": "ins-d1n303sa",
"private_ip": "10.2.1.7"
},
"pay-1": {
"instance_id": "ins-ceqfcxd2",
"private_ip": "10.2.11.14"
},
"pay-2": {
"instance_id": "ins-awhb74q6",
"private_ip": "10.2.12.5"
}
},
"services": {
"gateway-service": {
"unit": "hyapp-gateway-service",
"container": "hyapp-gateway-service",
"env_file": "/etc/hyapp/gateway-service/docker.env",
"image_template": "${REGISTRY}/gateway-service:${TAG}",
"target_port": 13000,
"hosts": [
"gateway-1",
"gateway-2"
],
"clb": {
"enabled": true,
"load_balancer_id": "lb-cbiabr1q",
"listener_ids": [
"lbl-ktvz8m6k"
],
"drain_seconds": 10
}
},
"room-service": {
"unit": "hyapp-room-service",
"container": "hyapp-room-service",
"env_file": "/etc/hyapp/room-service/docker.env",
"image_template": "${REGISTRY}/room-service:${TAG}",
"target_port": 13001,
"hosts": [
"new-app-1",
"new-app-2"
],
"clb": {
"enabled": true,
"load_balancer_id": "lb-epvnr4o0",
"listener_ids": [
"lbl-d2urlzba"
],
"drain_seconds": 5
}
},
"user-service": {
"unit": "hyapp-user-service",
"container": "hyapp-user-service",
"env_file": "/etc/hyapp/user-service/docker.env",
"image_template": "${REGISTRY}/user-service:${TAG}",
"target_port": 13005,
"hosts": [
"new-app-1",
"new-app-2"
],
"clb": {
"enabled": true,
"load_balancer_id": "lb-epvnr4o0",
"listener_ids": [
"lbl-honi8z3a"
],
"drain_seconds": 5
}
},
"wallet-service": {
"unit": "hyapp-wallet-service",
"container": "hyapp-wallet-service",
"env_file": "/etc/hyapp/wallet-service/docker.env",
"image_template": "${REGISTRY}/wallet-service:${TAG}",
"target_port": 13004,
"hosts": [
"pay-1",
"pay-2"
],
"clb": {
"enabled": true,
"load_balancer_id": "lb-4f5xi6p0",
"listener_ids": [
"lbl-9wi5mvu4"
],
"drain_seconds": 5
}
},
"activity-service": {
"unit": "hyapp-activity-service",
"container": "hyapp-activity-service",
"env_file": "/etc/hyapp/activity-service/docker.env",
"image_template": "${REGISTRY}/activity-service:${TAG}",
"target_port": 13006,
"hosts": [
"new-app-activity-1",
"new-app-activity-2"
],
"clb": {
"enabled": true,
"load_balancer_id": "lb-epvnr4o0",
"listener_ids": [
"lbl-j7oqtfhq"
],
"drain_seconds": 5
}
},
"notice-service": {
"unit": "hyapp-notice-service",
"container": "hyapp-notice-service",
"env_file": "/etc/hyapp/notice-service/docker.env",
"image_template": "${REGISTRY}/notice-service:${TAG}",
"target_port": 13009,
"hosts": [
"new-app-activity-1",
"new-app-activity-2"
],
"clb": {
"enabled": true,
"load_balancer_id": "lb-epvnr4o0",
"listener_ids": [
"lbl-aux9xsjq"
],
"drain_seconds": 5
}
},
"game-service": {
"unit": "hyapp-game-service",
"container": "hyapp-game-service",
"env_file": "/etc/hyapp/game-service/docker.env",
"image_template": "${REGISTRY}/game-service:${TAG}",
"target_port": 13008,
"hosts": [
"new-game-1",
"new-game-2"
],
"clb": {
"enabled": true,
"load_balancer_id": "lb-epvnr4o0",
"listener_ids": [
"lbl-ku138b4y"
],
"drain_seconds": 5
}
},
"statistics-service": {
"unit": "hyapp-statistics-service",
"container": "hyapp-statistics-service",
"env_file": "/etc/hyapp/statistics-service/docker.env",
"image_template": "${REGISTRY}/statistics-service:${TAG}",
"target_port": 13010,
"hosts": [
"new-app-1",
"new-app-2"
],
"clb": {
"enabled": true,
"load_balancer_id": "lb-epvnr4o0",
"listener_ids": [
"lbl-a0myhr6q"
],
"drain_seconds": 5
}
}
}
}

View File

@ -1,112 +0,0 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from deploy.tencent_tat import hyapp_tat_deploy as deploy
class TatDeployPlanTests(unittest.TestCase):
def load_inventory(self) -> dict:
return deploy.load_inventory("deploy/tencent-tat/inventory.prod.example.json")
def test_inventory_contains_current_cvm_topology(self) -> None:
inventory = self.load_inventory()
self.assertEqual(inventory["hosts"]["gateway-1"]["instance_id"], "ins-aeyaoj40")
self.assertEqual(inventory["hosts"]["gateway-2"]["private_ip"], "10.2.2.17")
self.assertEqual(inventory["services"]["wallet-service"]["hosts"], ["pay-1", "pay-2"])
self.assertEqual(inventory["services"]["game-service"]["target_port"], 13008)
self.assertEqual(inventory["services"]["notice-service"]["hosts"], ["new-app-activity-1", "new-app-activity-2"])
self.assertEqual(inventory["services"]["notice-service"]["target_port"], 13009)
self.assertIn("TencentDB for MySQL", inventory["managed_dependencies"]["mysql"])
def test_plan_is_service_first_and_keeps_clb_enabled(self) -> None:
inventory = self.load_inventory()
plan = deploy.build_plan(
inventory,
action="deploy",
service_names=["gateway-service", "wallet-service"],
raw_hosts="",
tag="20260513",
overrides={},
no_clb=False,
)
self.assertEqual(
[(step["service"], step["host"]) for step in plan],
[
("gateway-service", "gateway-1"),
("gateway-service", "gateway-2"),
("wallet-service", "pay-1"),
("wallet-service", "pay-2"),
],
)
self.assertTrue(all(step["clb_enabled"] for step in plan))
self.assertEqual(plan[0]["image"], "10.2.1.3:18082/hyapp/gateway-service:20260513")
def test_host_filter_must_belong_to_service(self) -> None:
inventory = self.load_inventory()
with self.assertRaisesRegex(RuntimeError, "not assigned"):
deploy.build_plan(
inventory,
action="restart",
service_names=["gateway-service"],
raw_hosts="pay-1",
tag="",
overrides={},
no_clb=False,
)
def test_remote_deploy_script_only_touches_hyapp_unit_and_env(self) -> None:
inventory = self.load_inventory()
service = inventory["services"]["gateway-service"]
script = deploy.remote_restart_script(service, image="registry/gateway:tag", health_timeout_seconds=30)
self.assertIn("systemctl restart \"$UNIT\"", script)
self.assertIn("ENV_FILE=/etc/hyapp/gateway-service/docker.env", script)
self.assertIn("docker pull \"$IMAGE\"", script)
self.assertNotIn("docker compose", script)
self.assertNotIn("java", script.lower())
def test_discover_plan_does_not_require_image_tag(self) -> None:
inventory = self.load_inventory()
plan = deploy.build_plan(
inventory,
action="discover",
service_names=["gateway-service"],
raw_hosts="",
tag="",
overrides={},
no_clb=False,
)
self.assertEqual([step["action"] for step in plan], ["discover", "discover"])
self.assertEqual([step["image"] for step in plan], ["", ""])
def test_real_clb_ids_disable_auto_discovery_but_placeholders_enable_it(self) -> None:
inventory = self.load_inventory()
step = deploy.build_plan(
inventory,
action="restart",
service_names=["gateway-service"],
raw_hosts="gateway-1",
tag="",
overrides={},
no_clb=False,
)[0]
self.assertFalse(deploy.should_auto_discover_clb(step))
step["clb_load_balancer_id"] = "lb-REPLACE_PUBLIC_GATEWAY"
self.assertTrue(deploy.should_auto_discover_clb(step))
def test_validate_inventory_rejects_unknown_service_host(self) -> None:
inventory = self.load_inventory()
inventory["services"]["gateway-service"]["hosts"] = ["missing-host"]
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "inventory.json"
path.write_text(json.dumps(inventory), encoding="utf-8")
with self.assertRaisesRegex(RuntimeError, "unknown host"):
deploy.load_inventory(str(path))
if __name__ == "__main__":
unittest.main()

View File

@ -1,124 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
import yaml
CONFIG_FILENAMES = ("config.yaml", "config.docker.yaml", "config.tencent.example.yaml")
def load_yaml(path: Path) -> dict[str, Any]:
if not path.exists():
raise RuntimeError(f"config file missing: {path}")
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
raise RuntimeError(f"config file must be a mapping: {path}")
return data
def load_inventory(path: Path) -> dict[str, Any]:
if not path.exists():
raise RuntimeError(f"inventory missing: {path}")
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data.get("services"), dict):
raise RuntimeError("inventory.services is required")
return data
def mapping_paths(value: Any, prefix: str = "") -> set[str]:
# 只比较 YAML object key不比较值密钥和环境差异不会被打印或误判。
if not isinstance(value, dict):
return set()
paths: set[str] = set()
for key, child in value.items():
current = f"{prefix}.{key}" if prefix else str(key)
paths.add(current)
paths.update(mapping_paths(child, current))
return paths
def selected_service_names(inventory: dict[str, Any], raw_services: str) -> list[str]:
names = [item.strip() for item in raw_services.split(",") if item.strip()]
if not names:
return sorted(str(name) for name in inventory["services"].keys())
unknown = [name for name in names if name not in inventory["services"]]
if unknown:
raise RuntimeError("unknown services: " + ", ".join(unknown))
return list(dict.fromkeys(names))
def selected_hosts(service: dict[str, Any], raw_hosts: str) -> list[str]:
hosts = [str(item) for item in service.get("hosts") or []]
requested = [item.strip() for item in raw_hosts.split(",") if item.strip()]
if not requested:
return hosts
invalid = [host for host in requested if host not in hosts]
if invalid:
raise RuntimeError("selected hosts are not assigned to this service: " + ", ".join(invalid))
return requested
def validate_service_templates(service_name: str) -> list[str]:
errors: list[str] = []
config_dir = Path("services") / service_name / "configs"
configs = {name: load_yaml(config_dir / name) for name in CONFIG_FILENAMES}
contract_paths = mapping_paths(configs["config.tencent.example.yaml"])
for filename in ("config.yaml", "config.docker.yaml"):
missing = sorted(mapping_paths(configs[filename]) - contract_paths)
if missing:
errors.append(
f"{service_name}: {filename} contains keys missing from config.tencent.example.yaml: "
+ ", ".join(missing)
)
return errors
def validate_rendered_config(service_name: str, host_name: str, rendered_dir: Path) -> list[str]:
contract = load_yaml(Path("services") / service_name / "configs" / "config.tencent.example.yaml")
rendered_path = rendered_dir / host_name / service_name / "config.yaml"
rendered = load_yaml(rendered_path)
missing = sorted(mapping_paths(contract) - mapping_paths(rendered))
if not missing:
return []
return [
f"{service_name}@{host_name}: rendered config missing keys required by config.tencent.example.yaml: "
+ ", ".join(missing)
]
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description="Validate service config contracts before TAT deployment.")
parser.add_argument("--inventory", default="deploy/tencent-tat/inventory.prod.example.json")
parser.add_argument("--rendered-dir", default="", help="Directory with <host>/<service>/config.yaml files.")
parser.add_argument("--services", default="", help="Comma separated service names. Defaults to all inventory services.")
parser.add_argument("--hosts", default="", help="Optional comma separated hosts assigned to the selected service(s).")
args = parser.parse_args(argv)
try:
inventory = load_inventory(Path(args.inventory))
rendered_dir = Path(args.rendered_dir).expanduser().resolve() if args.rendered_dir else None
errors: list[str] = []
for service_name in selected_service_names(inventory, args.services):
errors.extend(validate_service_templates(service_name))
if rendered_dir is None:
continue
for host_name in selected_hosts(inventory["services"][service_name], args.hosts):
errors.extend(validate_rendered_config(service_name, host_name, rendered_dir))
if errors:
print(json.dumps({"ok": False, "errors": errors}, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
print(json.dumps({"ok": True}, ensure_ascii=False))
return 0
except Exception as exc: # noqa: BLE001
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

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

View File

@ -1,16 +0,0 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
_SOURCE = Path(__file__).resolve().parents[1] / "tencent-tat" / "hyapp_tat_deploy.py"
_SPEC = importlib.util.spec_from_file_location("_hyapp_tat_deploy_impl", _SOURCE)
if _SPEC is None or _SPEC.loader is None:
raise ImportError(f"cannot load deployment helper: {_SOURCE}")
_MODULE = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(_MODULE)
for _name, _value in vars(_MODULE).items():
if not _name.startswith("__"):
globals()[_name] = _value

View File

@ -26,7 +26,7 @@ services:
- "19009:10909"
- "19011:10911"
volumes:
- ./deploy/rocketmq/broker.conf:/home/rocketmq/broker.conf:ro
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/rocketmq/broker.conf:/home/rocketmq/broker.conf:ro
- rocketmq-store:/home/rocketmq/store
depends_on:
- rocketmq-namesrv
@ -284,15 +284,15 @@ services:
- ./services/user-service/deploy/mysql/initdb/001_user_service.sql:/docker-entrypoint-initdb.d/002_user_service.sql:ro
- ./services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql:/docker-entrypoint-initdb.d/003_wallet_service.sql:ro
- ./services/activity-service/deploy/mysql/initdb/001_activity_service.sql:/docker-entrypoint-initdb.d/004_activity_service.sql:ro
- ./deploy/mysql/initdb/005_utf8mb4_chinese_support.sql:/docker-entrypoint-initdb.d/005_utf8mb4_chinese_support.sql:ro
- ./deploy/mysql/initdb/006_admin_database.sql:/docker-entrypoint-initdb.d/006_admin_database.sql:ro
- ./deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql:/docker-entrypoint-initdb.d/007_resource_group_wallet_asset_items.sql:ro
- ./deploy/mysql/initdb/008_remove_taiwan_country.sql:/docker-entrypoint-initdb.d/008_remove_taiwan_country.sql:ro
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/005_utf8mb4_chinese_support.sql:/docker-entrypoint-initdb.d/005_utf8mb4_chinese_support.sql:ro
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/006_admin_database.sql:/docker-entrypoint-initdb.d/006_admin_database.sql:ro
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql:/docker-entrypoint-initdb.d/007_resource_group_wallet_asset_items.sql:ro
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/008_remove_taiwan_country.sql:/docker-entrypoint-initdb.d/008_remove_taiwan_country.sql:ro
- ./services/cron-service/deploy/mysql/initdb/001_cron_service.sql:/docker-entrypoint-initdb.d/009_cron_service.sql:ro
- ./services/game-service/deploy/mysql/initdb/001_game_service.sql:/docker-entrypoint-initdb.d/010_game_service.sql:ro
- ./services/notice-service/deploy/mysql/initdb/001_notice_service.sql:/docker-entrypoint-initdb.d/011_notice_service.sql:ro
- ./services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql:/docker-entrypoint-initdb.d/012_statistics_service.sql:ro
- ./deploy/mysql/initdb/999_local_grants.sql:/docker-entrypoint-initdb.d/999_local_grants.sql:ro
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/999_local_grants.sql:/docker-entrypoint-initdb.d/999_local_grants.sql:ro
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"]
interval: 5s

View File

@ -0,0 +1,404 @@
# 创建房间与 IM 入群 Flutter 对接
本文描述 Flutter App 从创建语音房到加入腾讯云 IM 房间群的调用顺序。当前服务端已改为:`CreateRoom` 返回前同步创建腾讯 IM 群;创建成功后,`data.room.im_group_id` 对应的腾讯 IM 群已经存在。
## 接口地址
涉及接口:
| 方法 | 接口 | 说明 |
| --- | --- | --- |
| `GET` | `/api/v1/im/usersig` | 获取腾讯 IM SDK 登录票据。 |
| `POST` | `/api/v1/rooms/create` | 创建房间;成功前同步创建腾讯 IM 群。 |
| `POST` | `/api/v1/rooms/join` | 建立 room-service 业务 presence成功后才调用腾讯 IM `joinGroup`。 |
## 请求头
| Header | 必填 | 示例 | 说明 |
| --- | --- | --- | --- |
| `Authorization` | 是 | `Bearer <access_token>` | 登录 access token服务端从 token 读取当前用户 ID。 |
| `X-App-Code` | 否 | `lalu` | App 编码;登录态接口最终以 token 内的 `app_code` 为准。 |
| `Content-Type` | POST 必填 | `application/json` | JSON 请求体。 |
## 正确调用顺序
```text
1. App 登录后获取 access_token
2. GET /api/v1/im/usersig
3. 腾讯 IM SDK login(user_id, user_sig)
4. POST /api/v1/rooms/create
5. POST /api/v1/rooms/join
6. 腾讯 IM SDK joinGroup(join_response.data.im.group_id)
7. 成功后进入房间页并启动 heartbeat / RTC
```
关键规则:
| 规则 | 说明 |
| --- | --- |
| `CreateRoom` 成功代表 IM 群已创建 | 服务端同步调用腾讯 IM `create_group`,失败时创建接口直接失败,不落房间。 |
| `CreateRoom` 后不要直接 `joinGroup` | 腾讯 IM 回调守卫依赖 room-service presence必须先 `JoinRoom`。 |
| `im_group_id` 当前等于 `room_id` | 客户端仍然必须使用接口返回字段,不能硬编码映射规则。 |
| `joinGroup` 成功或“已在群内”都按成功处理 | 页面重进、SDK 重连或重复 join 时可能出现已在群内。 |
| `command_id` 同一次用户动作重试必须复用 | 创建房间和进房分别使用不同 `command_id`。 |
## 获取 IM UserSig
请求:
```http
GET /api/v1/im/usersig
Authorization: Bearer <access_token>
X-App-Code: lalu
```
成功响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"sdk_app_id": 20036101,
"user_id": "10001",
"user_sig": "xxx",
"expire_at_ms": 1778007200000
}
}
```
客户端行为:
- `user_id` 是腾讯 IM identifier直接传给腾讯 IM SDK。
- `user_sig``expire_at_ms` 缓存;过期前可以复用。
- `UNAUTHORIZED``PROFILE_REQUIRED` 时不进入房间流程。
## 创建房间
请求:
```http
POST /api/v1/rooms/create
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
{
"command_id": "cmd_create_room_10001_1778000000000",
"seat_count": 10,
"mode": "voice",
"room_name": "Live Room",
"room_avatar": "https://cdn.example/room.png",
"room_description": "welcome"
}
```
请求字段:
| 字段 | 必填 | 类型 | 说明 |
| --- | --- | --- | --- |
| `command_id` | 是 | string | 创建房间动作幂等键;同一次点击超时重试必须复用。 |
| `seat_count` | 否 | int | 不传或传 `0` 使用后台默认麦位数;正数必须命中后台启用座位数。 |
| `mode` | 是 | string | 当前传 `voice`。 |
| `room_name` | 是 | string | 房间标题,最长 128 个字符。 |
| `room_avatar` | 否 | string | 房间头像 URL为空时服务端写默认头像。 |
| `room_description` | 否 | string | 房间简介,最长 512 个字符。 |
成功响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"result": {
"applied": true,
"room_version": 1,
"server_time_ms": 1778000000123
},
"room": {
"room_id": "lalu_abc123",
"im_group_id": "lalu_abc123",
"room_short_id": "10001",
"title": "Live Room",
"cover_url": "https://cdn.example/room.png",
"description": "welcome",
"owner_user_id": "10001",
"mode": "voice",
"status": "active",
"chat_enabled": true,
"locked": false,
"heat": 0,
"online_count": 1,
"seat_count": 10,
"occupied_seat_count": 0,
"visible_region_id": 1001,
"version": 1
},
"seats": [
{
"seat_no": 1,
"locked": false
}
],
"im": {
"group_id": "lalu_abc123",
"need_join_group": true
},
"server_time_ms": 1778000000123
}
}
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `data.room.room_id` | 房间 ID后续 JoinRoom、heartbeat、RTC 都使用它。 |
| `data.room.im_group_id` | 腾讯 IM 房间群 ID创建成功时群已存在。 |
| `data.im.group_id` | 同一个 IM 群 ID客户端最终以 JoinRoom 响应里的 `data.im.group_id` 入群。 |
| `data.result.applied` | `true` 表示本次创建写入;同一 `command_id` 重试可能为 `false`。 |
创建失败处理:
| code | HTTP | 客户端处理 |
| --- | --- | --- |
| `INVALID_ARGUMENT` | 400 | 检查必填字段、座位数、标题长度。 |
| `UNAUTHORIZED` | 401 | 回登录。 |
| `PROFILE_REQUIRED` | 403 | 引导补全资料。 |
| `CONFLICT` | 409 | 当前用户已经有房间;调用 `/api/v1/rooms/me` 恢复已有房间。 |
| `UPSTREAM_ERROR` | 502 | 可能是腾讯 IM 建群失败或 room-service 不可用;不要进入房间页,可提示稍后重试。 |
## JoinRoom 后加入 IM 群
创建房间成功后,房主仍然要调用 `JoinRoom`。原因是 IM 入群回调会检查 room-service presence只创建房间不能替代进房动作。
请求:
```http
POST /api/v1/rooms/join
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
{
"room_id": "lalu_abc123",
"command_id": "cmd_join_room_lalu_abc123_10001_1778000001000",
"role": "audience"
}
```
成功响应只展示 IM 相关字段:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_def",
"data": {
"room": {
"room_id": "lalu_abc123",
"im_group_id": "lalu_abc123",
"version": 2
},
"viewer": {
"user_id": "10001",
"role": "owner",
"joined_at_ms": 1778000001000,
"last_seen_at_ms": 1778000001000
},
"im": {
"group_id": "lalu_abc123",
"need_join_group": true
},
"rtc": {
"need_token": true,
"available": true
},
"server_time_ms": 1778000001000
}
}
```
客户端行为:
| 条件 | 行为 |
| --- | --- |
| `data.im.need_join_group=true` | 调腾讯 IM SDK `joinGroup(data.im.group_id)`。 |
| `joinGroup` 成功 | 开始监听房间群 `TIMCustomElem`,继续 RTC 进房。 |
| `joinGroup` 返回已在群内 | 按成功处理。 |
| `joinGroup` 返回群不存在 | 视为异常;当前服务端 CreateRoom 已同步建群,记录 `request_id``group_id` 上报。 |
| `joinGroup` 返回被拒绝/无权限 | 重新调用 `JoinRoom`;仍失败则退出房间页。 |
## Flutter 示例
下面示例只展示调用边界,项目内可替换为现有 Dio 封装、错误码枚举和腾讯 IM SDK 适配层。
```dart
class VoiceRoomApi {
VoiceRoomApi(this._dio);
final Dio _dio;
Future<TencentIMUserSig> fetchIMUserSig() async {
final resp = await _dio.get('/api/v1/im/usersig');
final data = resp.data['data'] as Map<String, dynamic>;
return TencentIMUserSig.fromJson(data);
}
Future<CreateRoomResult> createRoom({
required String commandId,
required String roomName,
String mode = 'voice',
int seatCount = 0,
String roomAvatar = '',
String roomDescription = '',
}) async {
final resp = await _dio.post(
'/api/v1/rooms/create',
data: {
'command_id': commandId,
'seat_count': seatCount,
'mode': mode,
'room_name': roomName,
'room_avatar': roomAvatar,
'room_description': roomDescription,
},
);
return CreateRoomResult.fromJson(resp.data['data'] as Map<String, dynamic>);
}
Future<JoinRoomResult> joinRoom({
required String roomId,
required String commandId,
String role = 'audience',
String password = '',
}) async {
final resp = await _dio.post(
'/api/v1/rooms/join',
data: {
'room_id': roomId,
'command_id': commandId,
'role': role,
if (password.isNotEmpty) 'password': password,
},
);
return JoinRoomResult.fromJson(resp.data['data'] as Map<String, dynamic>);
}
}
```
IM 登录和入群:
```dart
class VoiceRoomIMBridge {
Future<void> ensureLoggedIn(TencentIMUserSig sig) async {
// 根据项目使用的腾讯云 IM Flutter SDK 版本适配。
// 常见入参是 sdkAppID、userID、userSig。
await TencentIMSDK.login(
sdkAppId: sig.sdkAppId,
userId: sig.userId,
userSig: sig.userSig,
);
}
Future<void> joinRoomGroup(String groupId) async {
try {
await TencentIMSDK.joinGroup(groupId: groupId);
} on TencentIMException catch (error) {
if (_isAlreadyInGroup(error)) {
return;
}
throw error;
}
}
bool _isAlreadyInGroup(TencentIMException error) {
// 按项目实际 SDK 错误码收敛,例如已在群内、重复入群等。
return error.code == TencentIMErrors.alreadyInGroup;
}
}
```
创建并进入自己房间:
```dart
Future<void> createAndEnterRoom({
required VoiceRoomApi api,
required VoiceRoomIMBridge im,
required String roomName,
}) async {
final sig = await api.fetchIMUserSig();
await im.ensureLoggedIn(sig);
final create = await api.createRoom(
commandId: newCommandId('create_room'),
roomName: roomName,
);
final roomId = create.room.roomId;
final join = await api.joinRoom(
roomId: roomId,
commandId: newCommandId('join_room_$roomId'),
);
if (join.im.needJoinGroup) {
await im.joinRoomGroup(join.im.groupId);
}
// join.rtc.available=true 时继续 enterRoom无论是否进 RTC业务 presence 已经建立。
openVoiceRoomPage(join);
}
```
`command_id` 生成建议:
```dart
String newCommandId(String action) {
final now = DateTime.now().millisecondsSinceEpoch;
final nonce = randomBase32(8);
return 'cmd_${action}_${now}_$nonce';
}
```
同一次点击因网络超时重试时,必须复用原 `command_id`;用户重新点击创建或进房时再生成新的 `command_id`
## 状态机建议
| 状态 | 入口 | 成功下一步 | 失败处理 |
| --- | --- | --- | --- |
| `imLogin` | `/im/usersig` + IM `login` | `createRoom` | 回登录或提示 IM 初始化失败。 |
| `createRoom` | `/rooms/create` | `joinRoom` | 不进入房间页;`CONFLICT` 时查询我的房间。 |
| `joinRoom` | `/rooms/join` | `joinIMGroup` | 不调用 `joinGroup`;展示进房失败。 |
| `joinIMGroup` | IM SDK `joinGroup` | `enterRTC` / 打开房间页 | 已在群内按成功;其他错误退出房间页或重试 JoinRoom。 |
| `inRoom` | 房间页 active | heartbeat loop | heartbeat 失败按重连策略处理。 |
## IM 消息监听
房间系统消息通过腾讯 IM 群 `TIMCustomElem` 下发:
| 字段 | 说明 |
| --- | --- |
| `MsgType` | `TIMCustomElem` |
| `Ext` | `room_system_message` |
| `Data` | JSON 字符串,包含 `event_id``room_id``event_type``room_version` 等。 |
| `CloudCustomData` | 与 `Data` 相同,便于漫游和排障。 |
客户端处理规则:
- 按 `event_id` 去重。
- 按 `room_version` 丢弃旧事件。
- 收到无法解析或版本断层时,调用 `GET /api/v1/rooms/snapshot?room_id={room_id}` 拉最新快照。
## 常见错误处理
| 场景 | 客户端动作 |
| --- | --- |
| `/rooms/create` 返回 `UPSTREAM_ERROR` | 不调用 JoinRoom不进入房间页提示稍后重试。 |
| `/rooms/create` 返回 `CONFLICT` | 用户已有房间,调用 `/api/v1/rooms/me` 恢复。 |
| `/rooms/join` 返回 `PERMISSION_DENIED` | 可能被 ban 或锁房密码错误;不调用 `joinGroup`。 |
| IM `joinGroup` 群不存在 | 当前不应出现;记录 `request_id``room_id``group_id` 上报。 |
| IM 登录票据过期 | 重新调用 `/api/v1/im/usersig``login`。 |

View File

@ -503,7 +503,7 @@ services/user-service/deploy/mysql/initdb/001_user_service.sql
services/room-service/deploy/mysql/initdb/001_room_service.sql
services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql
services/activity-service/deploy/mysql/initdb/001_activity_service.sql
deploy/mysql/initdb/006_admin_database.sql
../deploy-platform/deploy/mysql/initdb/006_admin_database.sql
```
本地开发库可以整体清空后重放 initdb线上 schema 迁移必须用专门 migration不允许依赖容器 init hook。

View File

@ -7,6 +7,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-root}"
DEPLOY_PLATFORM_ROOT="${DEPLOY_PLATFORM_ROOT:-${PROJECT_ROOT}/../deploy-platform}"
cd "${PROJECT_ROOT}"
@ -15,15 +16,15 @@ SQL_FILES=(
"services/user-service/deploy/mysql/initdb/001_user_service.sql"
"services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql"
"services/activity-service/deploy/mysql/initdb/001_activity_service.sql"
"deploy/mysql/initdb/005_utf8mb4_chinese_support.sql"
"deploy/mysql/initdb/006_admin_database.sql"
"deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql"
"deploy/mysql/initdb/008_remove_taiwan_country.sql"
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/005_utf8mb4_chinese_support.sql"
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/006_admin_database.sql"
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql"
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/008_remove_taiwan_country.sql"
"services/cron-service/deploy/mysql/initdb/001_cron_service.sql"
"services/game-service/deploy/mysql/initdb/001_game_service.sql"
"services/notice-service/deploy/mysql/initdb/001_notice_service.sql"
"services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql"
"deploy/mysql/initdb/999_local_grants.sql"
"${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/999_local_grants.sql"
)
# Keep MySQL as the only required dependency for this script. Business services

View File

@ -862,6 +862,53 @@ func TestHandleZeeOneAccessTokenIgnoresStaleLaunchSessionGameID(t *testing.T) {
}
}
func TestHandleZeeOneAllowsDebitAndCreditWithSameProviderOrderID(t *testing.T) {
secret := "zeeone-test-secret"
token := leaderccTestAccessToken(t, "lalu", 123456, "123456", 1700003600)
repo := &fakeRepository{
platform: gamedomain.Platform{
PlatformCode: "zeeone",
AdapterType: gamedomain.AdapterZeeOneV1,
CallbackSecretCiphertext: secret,
AdapterConfigJSON: `{"merchant_id":307715,"platform_id":2032,"uid_mode":"display_user_id"}`,
},
}
wallet := &fakeWallet{balanceAfter: 900}
svc := New(Config{}, repo, wallet, &fakeUser{})
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
base := `{"appId":"M307715_P2032","userId":"123456","sessionId":"` + token + `","diff_msg":"settle","game_id":1021,"room_id":"6","change_time_at":1700000000,"order_id":"zee_round_order_1"`
for _, item := range []struct {
name string
diff string
wantOp string
wantID string
wantCoins int64
}{
{name: "debit", diff: "-500", wantOp: "debit", wantID: "game:zeeone:zee_round_order_1:debit", wantCoins: 500},
{name: "credit", diff: "750", wantOp: "credit", wantID: "game:zeeone:zee_round_order_1:credit", wantCoins: 750},
} {
raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
Meta: &gamev1.RequestMeta{RequestId: "req-zeeone-" + item.name, AppCode: "lalu"},
PlatformCode: "zeeone",
Operation: "change_balance",
RawBody: zeeoneTestEnvelope(t, secret, base+`,"currency_diff":`+item.diff+`}`, 1700000000000),
})
if err != nil {
t.Fatalf("HandleCallback %s failed: %v", item.name, err)
}
if !strings.Contains(string(raw), `"code":0`) {
t.Fatalf("zeeone %s response mismatch: %s", item.name, raw)
}
if wallet.lastApply.GetCommandId() != item.wantID || wallet.lastApply.GetOpType() != item.wantOp || wallet.lastApply.GetCoinAmount() != item.wantCoins {
t.Fatalf("zeeone %s wallet command mismatch: %+v", item.name, wallet.lastApply)
}
}
if wallet.applyCount != 2 {
t.Fatalf("debit and credit should both reach wallet, got %d applies", wallet.applyCount)
}
}
func TestHandleBaishunSSTokenUserInfoUpdateAndChangeBalance(t *testing.T) {
secret := "baishun-test-app-key"
token := "app_access_token_baishun"
@ -1058,6 +1105,7 @@ type fakeRepository struct {
platform gamedomain.Platform
session gamedomain.LaunchSession
order gamedomain.GameOrder
orders []gamedomain.GameOrder
repair gamedomain.RepairOrder
levelEvents []gamedomain.LevelEventOutbox
deliveredEvents []string
@ -1086,10 +1134,20 @@ func (f *fakeRepository) GetLaunchSessionByToken(_ context.Context, _ string, to
}
func (f *fakeRepository) InsertCallbackLog(context.Context, gamedomain.CallbackLog) error { return nil }
func (f *fakeRepository) CreateGameOrder(_ context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) {
if f.order.OrderID != "" {
return f.order, true, nil
for _, existing := range f.orders {
if existing.OrderID == order.OrderID {
f.order = existing
return existing, true, nil
}
}
if f.order.OrderID != "" && len(f.orders) == 0 {
if f.order.OrderID == order.OrderID {
return f.order, true, nil
}
f.orders = append(f.orders, f.order)
}
f.order = order
f.orders = append(f.orders, order)
return order, false, nil
}
func (f *fakeRepository) MarkOrderSucceeded(_ context.Context, _ string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error {
@ -1098,6 +1156,12 @@ func (f *fakeRepository) MarkOrderSucceeded(_ context.Context, _ string, orderID
f.order.WalletTransactionID = walletTransactionID
f.order.WalletBalanceAfter = balanceAfter
f.order.UpdatedAtMS = nowMs
for index := range f.orders {
if f.orders[index].OrderID == orderID {
f.orders[index] = f.order
break
}
}
if strings.EqualFold(f.order.OpType, "debit") && f.order.CoinAmount > 0 && len(f.levelEvents) == 0 {
f.levelEvents = append(f.levelEvents, gamedomain.LevelEventOutbox{
AppCode: f.order.AppCode,
@ -1122,6 +1186,12 @@ func (f *fakeRepository) MarkOrderFailed(_ context.Context, _ string, orderID st
f.order.FailureCode = code
f.order.FailureMessage = message
f.order.UpdatedAtMS = nowMs
for index := range f.orders {
if f.orders[index].OrderID == orderID {
f.orders[index] = f.order
break
}
}
return nil
}
func (f *fakeRepository) CreateRepairOrder(_ context.Context, order gamedomain.RepairOrder) (gamedomain.RepairOrder, bool, error) {
@ -1165,11 +1235,13 @@ func (f *fakeRepository) DeleteCatalog(context.Context, string, string) error {
type fakeWallet struct {
lastApply *walletv1.ApplyGameCoinChangeRequest
applyCount int
balanceAfter int64
}
func (f *fakeWallet) ApplyGameCoinChange(_ context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) {
f.lastApply = req
f.applyCount++
return &walletv1.ApplyGameCoinChangeResponse{WalletTransactionId: "wtx-game-1", BalanceAfter: f.balanceAfter}, nil
}

View File

@ -276,7 +276,8 @@ func (s *Service) handleZeeOneChangeBalance(ctx context.Context, app string, con
session.GameID = session.ProviderGameID
}
opType, amount := zeeoneOpType(body.CurrencyDiff)
result, err := s.applyYomiCoinChange(ctx, app, req, session, body.OrderID, strconv.FormatInt(body.ChangeTimeAt, 10), opType, amount, body.RoomID, requestHash)
providerOrderID := zeeoneCoinChangeProviderOrderID(body.OrderID, opType)
result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, strconv.FormatInt(body.ChangeTimeAt, 10), opType, amount, body.RoomID, requestHash)
if err != nil {
code := zeeoneCodeFromError(err)
balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
@ -359,6 +360,15 @@ func zeeoneOpType(diff int64) (string, int64) {
return "credit", diff
}
func zeeoneCoinChangeProviderOrderID(orderID string, opType string) string {
orderID = strings.TrimSpace(orderID)
opType = strings.ToLower(strings.TrimSpace(opType))
if orderID == "" || opType == "" {
return orderID
}
return orderID + ":" + opType
}
func zeeoneProviderRequestID(reqBody string) string {
var body struct {
OrderID string `json:"order_id"`

View File

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