diff --git a/deploy/README.md b/deploy/README.md deleted file mode 100644 index bbdaf75b..00000000 --- a/deploy/README.md +++ /dev/null @@ -1,164 +0,0 @@ -# 多机部署与无感发布 - -本文定义不使用 Kubernetes 时的生产部署边界。目标是让 gateway 和内部微服务可以多机滚动发布,并让 `room-service` 在普通内网负载均衡命中非 owner 节点时自动转发到当前 Room Cell owner。 - -## 网络拓扑 - -推荐拓扑: - -```text -公网 CLB - -> gateway-service 多实例 - -> room-service 内网入口 - -> user-service 内网入口 - -> wallet-service 内网入口 - -> activity-service 内网入口 - -> game-service 内网入口 - -> notice-service 内网入口 - -room-service 实例之间通过 Redis owner route 和 node registry 直连转发。 -``` - -规则: - -- 公网只暴露 `gateway-service`。 -- 内部 gRPC 服务只监听私网,安全组只允许服务网段访问 `13xxx` 端口。 -- `user-service`、`wallet-service`、`activity-service`、`game-service`、`notice-service` 可以直接挂内网 CLB 或 DNS。 -- `room-service` 可以挂内网 CLB/DNS 给 gateway 使用,但 owner 转发必须使用实例级 `advertise_addr`,不能使用 CLB 地址。 -- MySQL、Redis、MQ 使用腾讯云托管资源,不在业务 CVM 上部署;服务只通过内网 endpoint 访问这些托管依赖。 - -当前生产入口: - -| 层级 | 地址 | 说明 | -| --- | --- | --- | -| 公网 API | `https://api.global-interaction.com` | HTTPS 443 由公网 CLB 转发到两台 `gateway-service:13000` | -| 后台前端 | `http://1.14.164.2:7001` | 广州 CVM `172.16.0.10` 上 nginx 托管 `admin-platform` 静态资源,`/api` 反代内网后台服务 | -| 后台 API | `172.16.0.6:13100` | 广州 CVM `ins-0x5mjwmc` 运行 `hyapp-admin-server`,通过 `wg-quick@hyapp-admin` 访问生产内网依赖 | -| room/user/activity/game/notice 内网入口 | `10.2.1.16:13001/13005/13006/13008/13009` | `lb-epvnr4o0` 的 Go 服务监听器 | -| wallet 内网入口 | `10.2.1.15:13004` | `lb-4f5xi6p0` 的 Go 服务监听器 | - -当前生产镜像仓库前缀是 `10.2.1.3:18082/hyapp`。部署脚本和 systemd env 只允许使用 `hyapp` 项目路径。 - -## Room Owner 路由 - -`room-service` 使用 Redis 维护两个短租约: - -```text -room:route:{app_code + "\0" + room_id} -> owner node_id / lease_token / expires_at_ms -room:node:{node_id} -> 实例 advertise_addr / expires_at_ms -``` - -写命令、IM guard 和完整房间快照查询的处理顺序: - -1. 请求先到任意 `room-service` 节点。 -2. 节点按 `app_code + "\0" + room_id` 查询 Redis owner route。 -3. 如果当前节点就是 owner,直接进入本地 Room Cell。 -4. 如果 owner 是其他节点,按 `room:node:{owner_node_id}` 找到实例私网地址并转发 gRPC。 -5. 如果 route 不存在或已过期,当前节点按原有 `EnsureOwner` 逻辑接管并从 MySQL snapshot + command log 恢复。 - -转发只做一跳。`advertise_addr` 写错成 CLB 地址时,请求会在一跳后停止递归,并暴露真实 owner 冲突或 unavailable。 - -## room-service 配置 - -每个实例必须有唯一配置: - -```yaml -node_id: "room-node-prod-1" -grpc_addr: ":13001" -advertise_addr: "10.0.1.12:13001" -health_http_addr: ":13101" -node_registry_ttl: "30s" -node_registry_heartbeat_interval: "10s" -owner_forward_timeout: "2s" -lease_ttl: "10s" -``` - -配置边界: - -- `grpc_addr` 是本进程监听地址,可以是 `:13001`。 -- `advertise_addr` 是其他 room 节点能直连的实例私网地址,必须是单实例地址。 -- `node_id` 参与 room owner lease、节点注册和日志排障,不能在多个实例间复用。 -- `node_registry_ttl` 必须大于心跳周期;默认 `30s/10s`。 -- `lease_ttl` 是节点故障后的自动接管窗口;正常滚动发布会在 gRPC drain 后主动释放本节点已加载房间 lease,减少等待 TTL 的窗口。 - -## 无感发布步骤 - -单个服务滚动发布顺序: - -1. 在新机器或新端口启动新版本实例。 -2. 等 `health_http_addr` 的 `/readyz` 或 gRPC health 通过。 -3. 把新实例加入对应 CLB 后端或内部 DNS。 -4. 观察错误率、延迟和日志。 -5. 从 CLB 摘除一个旧实例,等待 CLB 健康检查和连接排空周期。 -6. 对旧实例发送 `SIGTERM`。 -7. 旧实例进入 draining,gRPC 完成 in-flight 请求后释放已加载房间 lease,并删除 `room:node:{node_id}`。 -8. 新请求命中新节点后,如果 Redis route 已释放,会立即接管并从 MySQL 恢复;如果旧实例异常退出,则等待 `lease_ttl` 到期后接管。 - -发布约束: - -- 不要先杀进程再摘 CLB。 -- 不要把 `room-service` 的 `advertise_addr` 写成内网 CLB。 -- 不要多个实例复用同一个 `node_id`。 -- 数据库变更要先执行兼容当前发布窗口的迁移,再滚动服务。 - -## 配置拆分 - -不用统一配置中心也可以上线。推荐发布系统渲染最终 `config.yaml`: - -- 公共环境配置:腾讯云 MySQL/Redis/MQ 私网地址、日志等级、腾讯云 endpoint。 -- 服务配置:端口、worker 周期、批量大小、超时。 -- 实例配置:`node_id`、`advertise_addr`、`user-service.id_generator.node_id`。 -- Secret:MySQL/Redis 密码、JWT secret、腾讯 IM/RTC/COS secret、callback token。 - -只有当需要动态配置、灰度下发、审计回滚时,再引入 Consul/Nacos/Apollo。当前代码的启动配置仍以单服务 YAML 为边界。 - -线上字段契约以 `services//configs/config.tencent.example.yaml` 为准。开发时新增配置字段的动作必须同时更新三处: - -1. `services//configs/config.yaml`:本地直跑默认配置。 -2. `services//configs/config.docker.yaml`:本地 Docker/Compose 配置。 -3. `services//configs/config.tencent.example.yaml`:线上渲染契约。 - -部署前运行: - -```bash -python3 deploy/tencent-tat/validate_rendered_configs.py \ - --inventory deploy/tencent-tat/inventory.prod.example.json \ - --rendered-dir /tmp/hyapp-rendered-prod -``` - -这个检查只比较 YAML 字段路径,不比较也不输出配置值。只要本地新增字段没有进入线上契约,或者最终渲染 YAML 漏字段,部署前会直接失败。 - -托管依赖边界: - -- 腾讯云 MySQL 是业务事实和恢复来源;DSN 必须保留 `loc=UTC`。 -- 腾讯云 Redis 保存 owner route、node registry、登录风控和短 TTL 缓存;Redis 不能作为业务恢复事实来源。 -- 腾讯云 MQ 只作为 outbox/event bus 的托管消息通道;创建 topic、consumer group 和权限变更走云产品流程,不混进服务滚动发布。 - -当前 RocketMQ 资源约定: - -| Topic | Producer Group | Consumer Group | 说明 | -| --- | --- | --- | --- | -| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-activity-room-outbox` | activity-service 消费房间事实,处理贵重礼物播报、宝箱播报、成长等活动逻辑 | -| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-notice-room-outbox` | notice-service 消费私有通知相关房间事实 | -| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-room-im-bridge` | room-service IM bridge 消费房间群系统消息和群成员控制事实 | -| `hyapp_room_treasure_open` | `hyapp-room-treasure-open-producer` | `hyapp-room-treasure-open` | room-service 消费宝箱延迟开箱唤醒消息 | - -线上推荐配置: - -- `services/room-service/configs/config.tencent.example.yaml` 保持 `outbox_worker.publish_mode: "mq"`。 -- `rocketmq.room_outbox.enabled: true` 后,`room_outbox.status=delivered` 只表示事件已进入 RocketMQ;activity、notice、IM bridge 的成功状态由各自消费位点负责。 -- `rocketmq.treasure_open.enabled: true` 后,宝箱倒计时使用毫秒级 `open_at_ms` 投递延迟消息;消息到达后 room-service 仍要重新校验 Room Cell 状态,不能把 MQ 当成宝箱状态来源。 -- 本地 Docker 默认关闭 MQ,使用 direct publisher 维持无云依赖的可运行形态。 - -## 真实数据验证 - -本地真实 MySQL/Redis 验证: - -```bash -docker compose up -d mysql redis -ROOM_SERVICE_MYSQL_TEST_DSN='hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC' \ -ROOM_SERVICE_REDIS_TEST_ADDR='127.0.0.1:13379' \ -go test -count=1 ./services/room-service/internal/transport/grpc -run TestOwnerForwardingUsesRedisRouteAndNodeRegistry -``` - -该测试会启动两个真实 gRPC `room-service` 实例,共用真实 MySQL schema 和 Redis route/node registry:在 node A 创建房间,通过 node B 调 `JoinRoom`、`CheckSpeakPermission` 和 `GetRoomSnapshot`,验证请求会转发到当前 owner。 diff --git a/deploy/__init__.py b/deploy/__init__.py deleted file mode 100644 index b5b1475f..00000000 --- a/deploy/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Package marker for deployment helper tests. diff --git a/deploy/mysql/initdb/005_utf8mb4_chinese_support.sql b/deploy/mysql/initdb/005_utf8mb4_chinese_support.sql deleted file mode 100644 index f2d57532..00000000 --- a/deploy/mysql/initdb/005_utf8mb4_chinese_support.sql +++ /dev/null @@ -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; diff --git a/deploy/mysql/initdb/006_admin_database.sql b/deploy/mysql/initdb/006_admin_database.sql deleted file mode 100644 index d6d5b924..00000000 --- a/deploy/mysql/initdb/006_admin_database.sql +++ /dev/null @@ -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; diff --git a/deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql b/deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql deleted file mode 100644 index 77b1300a..00000000 --- a/deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql +++ /dev/null @@ -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; diff --git a/deploy/mysql/initdb/008_remove_taiwan_country.sql b/deploy/mysql/initdb/008_remove_taiwan_country.sql deleted file mode 100644 index 6f5c9eb3..00000000 --- a/deploy/mysql/initdb/008_remove_taiwan_country.sql +++ /dev/null @@ -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'; diff --git a/deploy/mysql/initdb/999_local_grants.sql b/deploy/mysql/initdb/999_local_grants.sql deleted file mode 100644 index 1af9b242..00000000 --- a/deploy/mysql/initdb/999_local_grants.sql +++ /dev/null @@ -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; diff --git a/deploy/rocketmq/broker.conf b/deploy/rocketmq/broker.conf deleted file mode 100644 index e6cf93e0..00000000 --- a/deploy/rocketmq/broker.conf +++ /dev/null @@ -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 diff --git a/deploy/standalone/README.md b/deploy/standalone/README.md deleted file mode 100644 index ec1023dd..00000000 --- a/deploy/standalone/README.md +++ /dev/null @@ -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//configs/config.tencent.example.yaml` 作为线上模板,渲染后放到 `/etc/hyapp//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//docker.env` 和 `/etc/hyapp//config.yaml`。 -2. `systemctl start hyapp-`,等待 Docker health 进入 `healthy`。 -3. 将新实例加入对应 CLB 或内部服务入口。 -4. 观察错误率、延迟和服务日志。 -5. 从 CLB 摘除旧实例,等待连接保护或业务 drain 窗口结束。 -6. `systemctl stop hyapp-`,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,变更走对应云产品流程。 diff --git a/deploy/standalone/docker/activity-service.env.example b/deploy/standalone/docker/activity-service.env.example deleted file mode 100644 index ff6e626e..00000000 --- a/deploy/standalone/docker/activity-service.env.example +++ /dev/null @@ -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 - diff --git a/deploy/standalone/docker/admin-platform.env.example b/deploy/standalone/docker/admin-platform.env.example deleted file mode 100644 index e95acbb2..00000000 --- a/deploy/standalone/docker/admin-platform.env.example +++ /dev/null @@ -1,3 +0,0 @@ -IMAGE=10.2.1.3:18082/hyapp/admin-platform:REPLACE_TAG -CONTAINER_NAME=hyapp-admin-platform -STOP_TIMEOUT_SEC=15 diff --git a/deploy/standalone/docker/admin-server.env.example b/deploy/standalone/docker/admin-server.env.example deleted file mode 100644 index cea565c3..00000000 --- a/deploy/standalone/docker/admin-server.env.example +++ /dev/null @@ -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 diff --git a/deploy/standalone/docker/cron-service.env.example b/deploy/standalone/docker/cron-service.env.example deleted file mode 100644 index bf3b2773..00000000 --- a/deploy/standalone/docker/cron-service.env.example +++ /dev/null @@ -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 - diff --git a/deploy/standalone/docker/game-service.env.example b/deploy/standalone/docker/game-service.env.example deleted file mode 100644 index f6ad9abb..00000000 --- a/deploy/standalone/docker/game-service.env.example +++ /dev/null @@ -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 - diff --git a/deploy/standalone/docker/gateway-service.env.example b/deploy/standalone/docker/gateway-service.env.example deleted file mode 100644 index 9ce0cc72..00000000 --- a/deploy/standalone/docker/gateway-service.env.example +++ /dev/null @@ -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 - diff --git a/deploy/standalone/docker/notice-service.env.example b/deploy/standalone/docker/notice-service.env.example deleted file mode 100644 index ee8b4347..00000000 --- a/deploy/standalone/docker/notice-service.env.example +++ /dev/null @@ -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 diff --git a/deploy/standalone/docker/room-service.env.example b/deploy/standalone/docker/room-service.env.example deleted file mode 100644 index 27d1e326..00000000 --- a/deploy/standalone/docker/room-service.env.example +++ /dev/null @@ -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 - diff --git a/deploy/standalone/docker/statistics-service.env.example b/deploy/standalone/docker/statistics-service.env.example deleted file mode 100644 index 9c94427b..00000000 --- a/deploy/standalone/docker/statistics-service.env.example +++ /dev/null @@ -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 diff --git a/deploy/standalone/docker/user-service.env.example b/deploy/standalone/docker/user-service.env.example deleted file mode 100644 index c7209721..00000000 --- a/deploy/standalone/docker/user-service.env.example +++ /dev/null @@ -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 - diff --git a/deploy/standalone/docker/wallet-service.env.example b/deploy/standalone/docker/wallet-service.env.example deleted file mode 100644 index 44f1ef3d..00000000 --- a/deploy/standalone/docker/wallet-service.env.example +++ /dev/null @@ -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 - diff --git a/deploy/standalone/systemd/hyapp-activity-service.service b/deploy/standalone/systemd/hyapp-activity-service.service deleted file mode 100644 index 3e2761d3..00000000 --- a/deploy/standalone/systemd/hyapp-activity-service.service +++ /dev/null @@ -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 - diff --git a/deploy/standalone/systemd/hyapp-admin-platform.service b/deploy/standalone/systemd/hyapp-admin-platform.service deleted file mode 100644 index cd3e9ca8..00000000 --- a/deploy/standalone/systemd/hyapp-admin-platform.service +++ /dev/null @@ -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 diff --git a/deploy/standalone/systemd/hyapp-admin-server.service b/deploy/standalone/systemd/hyapp-admin-server.service deleted file mode 100644 index 80d7e78d..00000000 --- a/deploy/standalone/systemd/hyapp-admin-server.service +++ /dev/null @@ -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 diff --git a/deploy/standalone/systemd/hyapp-cron-service.service b/deploy/standalone/systemd/hyapp-cron-service.service deleted file mode 100644 index c2fa3bfc..00000000 --- a/deploy/standalone/systemd/hyapp-cron-service.service +++ /dev/null @@ -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 - diff --git a/deploy/standalone/systemd/hyapp-game-service.service b/deploy/standalone/systemd/hyapp-game-service.service deleted file mode 100644 index 37f0f8c9..00000000 --- a/deploy/standalone/systemd/hyapp-game-service.service +++ /dev/null @@ -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 - diff --git a/deploy/standalone/systemd/hyapp-gateway-service.service b/deploy/standalone/systemd/hyapp-gateway-service.service deleted file mode 100644 index 499fd7a7..00000000 --- a/deploy/standalone/systemd/hyapp-gateway-service.service +++ /dev/null @@ -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 - diff --git a/deploy/standalone/systemd/hyapp-notice-service.service b/deploy/standalone/systemd/hyapp-notice-service.service deleted file mode 100644 index bab6fcd6..00000000 --- a/deploy/standalone/systemd/hyapp-notice-service.service +++ /dev/null @@ -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 diff --git a/deploy/standalone/systemd/hyapp-room-service.service b/deploy/standalone/systemd/hyapp-room-service.service deleted file mode 100644 index 08abcb74..00000000 --- a/deploy/standalone/systemd/hyapp-room-service.service +++ /dev/null @@ -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 - diff --git a/deploy/standalone/systemd/hyapp-statistics-service.service b/deploy/standalone/systemd/hyapp-statistics-service.service deleted file mode 100644 index 0be1a37c..00000000 --- a/deploy/standalone/systemd/hyapp-statistics-service.service +++ /dev/null @@ -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 diff --git a/deploy/standalone/systemd/hyapp-user-service.service b/deploy/standalone/systemd/hyapp-user-service.service deleted file mode 100644 index 1670e1f3..00000000 --- a/deploy/standalone/systemd/hyapp-user-service.service +++ /dev/null @@ -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 - diff --git a/deploy/standalone/systemd/hyapp-wallet-service.service b/deploy/standalone/systemd/hyapp-wallet-service.service deleted file mode 100644 index 381c86d0..00000000 --- a/deploy/standalone/systemd/hyapp-wallet-service.service +++ /dev/null @@ -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 diff --git a/deploy/tencent-tat/README.md b/deploy/tencent-tat/README.md deleted file mode 100644 index 36ee98f5..00000000 --- a/deploy/tencent-tat/README.md +++ /dev/null @@ -1,307 +0,0 @@ -# 腾讯云 TAT 滚动部署方案 - -本方案只部署本仓库 Go 微服务,不接管同机已有 Java 项目。远端连接使用腾讯云 TAT,发布时只操作 `hyapp-*` systemd unit、`hyapp-*` Docker 容器和 `/etc/hyapp//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-`。 -4. 目标业务机只保存 `/etc/hyapp//config.yaml` 和 `docker.env`,不拉代码、不编译、不保存 Git 凭据。 - -不要让每台业务服务器自己 `git pull && build`。那会把代码权限、构建环境、版本一致性和回滚都扩散到业务机,且多服务发布时无法统一做 CLB 摘流。 - -## 前置条件 - -1. 每台目标机已安装腾讯云 TAT agent,控制台能执行命令。 -2. 每个 Go 服务已按 `deploy/standalone/systemd` 安装对应 systemd unit。 -3. 每个 Go 服务已有 `/etc/hyapp//config.yaml` 和 `/etc/hyapp//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 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//config.yaml`。腾讯云 MySQL、Redis、MQ、JWT、IM/RTC/COS 等密钥不要提交到 Git。 - -配置字段契约以 `services//configs/config.tencent.example.yaml` 为准。开发时如果在本地 `config.yaml` 或 `config.docker.yaml` 新增字段,必须同时补到 `config.tencent.example.yaml`;生产渲染前运行校验,避免线上漏字段: - -```bash -python3 deploy/tencent-tat/validate_rendered_configs.py \ - --inventory deploy/tencent-tat/inventory.prod.example.json -``` - -如果已经渲染了生产最终配置,还要校验每台机器的最终 YAML: - -```bash -python3 deploy/tencent-tat/validate_rendered_configs.py \ - --inventory deploy/tencent-tat/inventory.prod.example.json \ - --rendered-dir /tmp/hyapp-rendered-prod -``` - -该脚本只比较 YAML key,不打印配置值,避免把生产密钥带到日志里。 - -## 构建并推送镜像 - -在 `deploy` 服务器上执行。2 核 2GiB 机器可以作为调度器;如果全量构建 OOM 或太慢,把构建放到 CI,deploy 服务器只负责调度发布。 - -```bash -cd /opt/deploy/sources/hyapp-server -TAG="$(date -u +%Y%m%d%H%M%S)-$(git rev-parse --short HEAD)" -REGISTRY="10.2.1.3:18082/hyapp" - -docker build -t "$REGISTRY/gateway-service:$TAG" -f services/gateway-service/Dockerfile . -docker build -t "$REGISTRY/room-service:$TAG" -f services/room-service/Dockerfile . -docker build -t "$REGISTRY/user-service:$TAG" -f services/user-service/Dockerfile . -docker build -t "$REGISTRY/wallet-service:$TAG" -f services/wallet-service/Dockerfile . -docker build -t "$REGISTRY/activity-service:$TAG" -f services/activity-service/Dockerfile . -docker build -t "$REGISTRY/game-service:$TAG" -f services/game-service/Dockerfile . -docker build -t "$REGISTRY/notice-service:$TAG" -f services/notice-service/Dockerfile . - -docker push "$REGISTRY/gateway-service:$TAG" -docker push "$REGISTRY/room-service:$TAG" -docker push "$REGISTRY/user-service:$TAG" -docker push "$REGISTRY/wallet-service:$TAG" -docker push "$REGISTRY/activity-service:$TAG" -docker push "$REGISTRY/game-service:$TAG" -docker push "$REGISTRY/notice-service:$TAG" -``` - -如果实际用 Harbor,把 `REGISTRY` 换成 `${HARBOR_REGISTRY}/${HARBOR_PROJECT}`,并先 `docker login`。 - -## 配置 inventory - -复制模板后填真实 CLB: - -```bash -cp deploy/tencent-tat/inventory.prod.example.json deploy/tencent-tat/inventory.prod.json -vi deploy/tencent-tat/inventory.prod.json -``` - -必须替换: - -- `gateway-service.clb.load_balancer_id`:公网 gateway CLB。 -- `gateway-service.clb.listener_ids`:公网 HTTP/HTTPS 监听器。 -- 内部服务各自的 `load_balancer_id` 和 `listener_ids`:room/user/wallet/activity/game 内网 CLB。 - -脚本通过实例 ID、私网 IP、端口三者匹配 CLB target。发布前会把当前实例在相关 listener 下的权重改成 `0`,服务健康后恢复原权重。 - -`managed_dependencies` 只记录部署边界:腾讯云 MySQL/Redis/MQ 是外部托管依赖,不纳入 TAT 发布动作。生产变更这些资源时单独走云数据库、Redis 或 MQ 的变更流程。 - -当前生产 inventory 已写入真实绑定: - -| 服务 | CLB | 监听器 | 对外/内网入口 | -| --- | --- | --- | --- | -| `gateway-service` | `lb-cbiabr1q` | `lbl-ktvz8m6k` / rule `loc-pwedmsmm` | `https://api.global-interaction.com` -> `13000` | -| `room-service` | `lb-epvnr4o0` | `lbl-d2urlzba` | `10.2.1.16:13001` | -| `user-service` | `lb-epvnr4o0` | `lbl-honi8z3a` | `10.2.1.16:13005` | -| `activity-service` | `lb-epvnr4o0` | `lbl-j7oqtfhq` | `10.2.1.16:13006` | -| `game-service` | `lb-epvnr4o0` | `lbl-ku138b4y` | `10.2.1.16:13008` | -| `notice-service` | `lb-epvnr4o0` | `lbl-aux9xsjq` | `10.2.1.16:13009` | -| `wallet-service` | `lb-4f5xi6p0` | `lbl-9wi5mvu4` | `10.2.1.15:13004` | - -Harbor 项目使用 `hyapp`,当前生产镜像前缀是 `10.2.1.3:18082/hyapp`。不要使用其他业务项目名前缀。 - -## 线上服务配置边界 - -这些机器上只部署 Go 服务进程: - -- `gateway-service`:`room_service_addr/user_service_addr/wallet_service_addr/activity_service_addr/game_service_addr` 指向各服务内网 CLB/DNS。 -- `room-service`:`mysql_dsn` 指向腾讯云 MySQL,`redis_addr` 指向腾讯云 Redis;`advertise_addr` 指向本机私网 IP。 -- `user-service`:`mysql_dsn`、worker 里引用的 wallet/room MySQL DSN、登录风控 Redis 都指向腾讯云托管实例。 -- `wallet-service`、`activity-service`、`game-service`:MySQL 和下游 gRPC 地址都使用腾讯云托管资源或内网 CLB/DNS。 -- `notice-service`:`mysql_dsn` 指向 `hyapp_notice`,`wallet_database` 指向 `hyapp_wallet`;它只读取 wallet outbox 并写自己的投递位点,不修改钱包事实表。 -- MQ 当前只作为 outbox/event bus 方向的外部托管依赖;本脚本不会创建 topic、consumer group 或本地 broker。 - -腾讯云 MySQL DSN 必须保留 `loc=UTC`。Redis 使用同一托管实例时,用 `redis_db` 或 key prefix 区分业务域,不要把 Redis 当恢复事实来源。 - -## 常用命令 - -先看计划,不执行远端动作: - -```bash -python3 deploy/tencent-tat/hyapp_tat_deploy.py plan \ - --inventory deploy/tencent-tat/inventory.prod.json \ - --services gateway-service,room-service -``` - -用 TAT 连接目标机并自动发现真实部署状态和 CLB 绑定: - -```bash -python3 deploy/tencent-tat/hyapp_tat_deploy.py discover \ - --env-file /opt/hy-app-monitor/.env \ - --inventory /opt/deploy/inventory/hyapp.prod.json \ - --services gateway-service,room-service,user-service,wallet-service,activity-service,game-service,notice-service -``` - -`discover` 会读取每台目标机的 `hyapp-*` systemd unit、`/etc/hyapp/*/docker.env`、`hyapp-*` 容器和监听端口,并通过 CLB API 按后端私网 IP/端口反查负载均衡绑定。 - -单服务重启: - -```bash -python3 deploy/tencent-tat/hyapp_tat_deploy.py restart \ - --env-file /opt/hy-app-monitor/.env \ - --inventory /opt/deploy/inventory/hyapp.prod.json \ - --services wallet-service \ - --yes -``` - -多服务重启: - -```bash -python3 deploy/tencent-tat/hyapp_tat_deploy.py restart \ - --env-file /opt/hy-app-monitor/.env \ - --inventory /opt/deploy/inventory/hyapp.prod.json \ - --services room-service,user-service,wallet-service,activity-service,game-service,notice-service \ - --yes -``` - -单服务更新部署: - -```bash -python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy \ - --env-file /opt/hy-app-monitor/.env \ - --inventory /opt/deploy/inventory/hyapp.prod.json \ - --services gateway-service \ - --tag 20260513-1349 \ - --yes -``` - -多服务更新部署: - -```bash -python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy \ - --env-file /opt/hy-app-monitor/.env \ - --inventory /opt/deploy/inventory/hyapp.prod.json \ - --services gateway-service,room-service,user-service,wallet-service,activity-service,game-service,notice-service \ - --tag 20260513-1349 \ - --yes -``` - -如果某个服务镜像 tag 不同,可以显式传镜像: - -```bash -python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy \ - --env-file /opt/hy-app-monitor/.env \ - --inventory /opt/deploy/inventory/hyapp.prod.json \ - --services gateway-service,wallet-service \ - --image gateway-service=10.2.1.3:18082/hyapp/gateway-service:gw-20260513 \ - --image wallet-service=10.2.1.3:18082/hyapp/wallet-service:pay-20260513 \ - --yes -``` - -只维护某一台机器时使用 `--hosts`,例如只重启 `gateway-1`: - -```bash -python3 deploy/tencent-tat/hyapp_tat_deploy.py restart \ - --env-file /opt/hy-app-monitor/.env \ - --inventory /opt/deploy/inventory/hyapp.prod.json \ - --services gateway-service \ - --hosts gateway-1 \ - --yes -``` - -## 无感更新顺序 - -对每个服务实例,脚本固定执行: - -1. 读取该实例当前 CLB 绑定和原权重。 -2. 将该实例在所有匹配 listener/rule 下的权重改为 `0`。 -3. 等待 `drain_seconds`,让 CLB 停止分配新请求。 -4. 通过 TAT 在目标机更新 `docker.env` 的 `IMAGE`,执行 `docker pull`。 -5. `systemctl restart hyapp-`,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 目录里执行本仓库部署命令。 diff --git a/deploy/tencent-tat/hyapp_tat_deploy.py b/deploy/tencent-tat/hyapp_tat_deploy.py deleted file mode 100644 index ee43b4f0..00000000 --- a/deploy/tencent-tat/hyapp_tat_deploy.py +++ /dev/null @@ -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:])) diff --git a/deploy/tencent-tat/inventory.prod.example.json b/deploy/tencent-tat/inventory.prod.example.json deleted file mode 100644 index 113191f3..00000000 --- a/deploy/tencent-tat/inventory.prod.example.json +++ /dev/null @@ -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 - } - } - } -} diff --git a/deploy/tencent-tat/tests/test_hyapp_tat_deploy.py b/deploy/tencent-tat/tests/test_hyapp_tat_deploy.py deleted file mode 100644 index 4b7d141f..00000000 --- a/deploy/tencent-tat/tests/test_hyapp_tat_deploy.py +++ /dev/null @@ -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() diff --git a/deploy/tencent-tat/validate_rendered_configs.py b/deploy/tencent-tat/validate_rendered_configs.py deleted file mode 100644 index ed7272bd..00000000 --- a/deploy/tencent-tat/validate_rendered_configs.py +++ /dev/null @@ -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 //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:])) diff --git a/deploy/tencent_tat/__init__.py b/deploy/tencent_tat/__init__.py deleted file mode 100644 index e2ecf810..00000000 --- a/deploy/tencent_tat/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Package marker for Tencent TAT deployment helper tests. diff --git a/deploy/tencent_tat/hyapp_tat_deploy.py b/deploy/tencent_tat/hyapp_tat_deploy.py deleted file mode 100644 index f3729a9a..00000000 --- a/deploy/tencent_tat/hyapp_tat_deploy.py +++ /dev/null @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 9b36b36a..c170cf88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/flutter对接/创建房间与IM入群Flutter对接.md b/docs/flutter对接/创建房间与IM入群Flutter对接.md new file mode 100644 index 00000000..3a3546ab --- /dev/null +++ b/docs/flutter对接/创建房间与IM入群Flutter对接.md @@ -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;服务端从 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 +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 +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 +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 fetchIMUserSig() async { + final resp = await _dio.get('/api/v1/im/usersig'); + final data = resp.data['data'] as Map; + return TencentIMUserSig.fromJson(data); + } + + Future 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); + } + + Future 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); + } +} +``` + +IM 登录和入群: + +```dart +class VoiceRoomIMBridge { + Future ensureLoggedIn(TencentIMUserSig sig) async { + // 根据项目使用的腾讯云 IM Flutter SDK 版本适配。 + // 常见入参是 sdkAppID、userID、userSig。 + await TencentIMSDK.login( + sdkAppId: sig.sdkAppId, + userId: sig.userId, + userSig: sig.userSig, + ); + } + + Future 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 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`。 | + diff --git a/docs/服务基础技术设计.md b/docs/服务基础技术设计.md index f85de1a5..3241ead3 100644 --- a/docs/服务基础技术设计.md +++ b/docs/服务基础技术设计.md @@ -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。 diff --git a/scripts/apply-local-mysql-initdb.sh b/scripts/apply-local-mysql-initdb.sh index 45444e59..af9deb96 100755 --- a/scripts/apply-local-mysql-initdb.sh +++ b/scripts/apply-local-mysql-initdb.sh @@ -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 diff --git a/services/game-service/internal/service/game/service_test.go b/services/game-service/internal/service/game/service_test.go index 7cc6e30f..5e5485d1 100644 --- a/services/game-service/internal/service/game/service_test.go +++ b/services/game-service/internal/service/game/service_test.go @@ -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 } diff --git a/services/game-service/internal/service/game/zeeone_adapter.go b/services/game-service/internal/service/game/zeeone_adapter.go index 3f0127f3..2692757c 100644 --- a/services/game-service/internal/service/game/zeeone_adapter.go +++ b/services/game-service/internal/service/game/zeeone_adapter.go @@ -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"` diff --git a/services/statistics-service/configs/config.tencent.example.yaml b/services/statistics-service/configs/config.tencent.example.yaml index 50f8bd4c..d8529993 100644 --- a/services/statistics-service/configs/config.tencent.example.yaml +++ b/services/statistics-service/configs/config.tencent.example.yaml @@ -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