From fb1789f97fed4c948f635284a65e03caf7231735 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 13 May 2026 16:07:25 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 5 +- Makefile | 12 +- README.md | 24 +- deploy/__init__.py | 1 + deploy/mysql/initdb/999_local_grants.sql | 1 + deploy/standalone/README.md | 30 +- .../docker/activity-service.env.example | 2 +- .../docker/cron-service.env.example | 2 +- .../docker/game-service.env.example | 2 +- .../docker/gateway-service.env.example | 2 +- .../docker/room-service.env.example | 2 +- .../docker/user-service.env.example | 2 +- .../docker/wallet-service.env.example | 2 +- .../systemd/hyapp-activity-service.service | 6 +- .../systemd/hyapp-cron-service.service | 6 +- .../systemd/hyapp-game-service.service | 6 +- .../systemd/hyapp-gateway-service.service | 6 +- .../systemd/hyapp-room-service.service | 6 +- .../systemd/hyapp-user-service.service | 6 +- .../systemd/hyapp-wallet-service.service | 6 +- deploy/tencent-tat/README.md | 286 +++++++ deploy/tencent-tat/hyapp_tat_deploy.py | 703 ++++++++++++++++++ .../tencent-tat/inventory.prod.example.json | 180 +++++ .../tests/test_hyapp_tat_deploy.py | 110 +++ deploy/tencent_tat/__init__.py | 1 + deploy/tencent_tat/hyapp_tat_deploy.py | 16 + docker-compose.yml | 22 + docs/api-test-harness.html | 36 - docs/notice-service架构.md | 161 ++++ docs/多机部署与无感发布.md | 19 +- docs/接口清单.md | 4 - docs/用户区域与国家开发.md | 15 +- pkg/tencentim/rest_client.go | 67 ++ pkg/tencentim/rest_client_test.go | 44 ++ scripts/apply-local-mysql-initdb.sh | 1 + scripts/print-compose-addresses.sh | 2 + server/admin/cmd/server/main.go | 2 - server/admin/docs/权限管理.md | 21 +- server/admin/internal/model/models.go | 14 - .../admin/internal/modules/coinledger/dto.go | 3 + .../internal/modules/coinledger/service.go | 29 +- .../modules/coinledger/service_test.go | 18 + .../internal/modules/notification/handler.go | 64 -- .../internal/modules/notification/response.go | 13 - .../internal/modules/notification/routes.go | 14 - .../internal/modules/notification/service.go | 30 - .../admin/internal/modules/upload/handler.go | 16 +- .../internal/modules/upload/handler_test.go | 20 + .../admin/internal/modules/upload/request.go | 1 - .../repository/dashboard_repository.go | 40 +- .../repository/notification_repository.go | 34 - .../admin/internal/repository/repository.go | 26 +- server/admin/internal/repository/seed.go | 30 +- server/admin/internal/router/router.go | 3 - .../008_remove_notification_center.sql | 13 + services/notice-service/Dockerfile | 36 + services/notice-service/cmd/server/main.go | 55 ++ .../notice-service/configs/config.docker.yaml | 32 + .../configs/config.tencent.example.yaml | 32 + services/notice-service/configs/config.yaml | 32 + .../mysql/initdb/001_notice_service.sql | 28 + services/notice-service/internal/app/app.go | 173 +++++ .../notice-service/internal/config/config.go | 181 +++++ .../walletnotice/mysql_integration_test.go | 129 ++++ .../modules/walletnotice/mysql_repository.go | 359 +++++++++ .../internal/modules/walletnotice/service.go | 147 ++++ .../modules/walletnotice/service_test.go | 126 ++++ .../internal/modules/walletnotice/worker.go | 92 +++ .../internal/platform/mysql/db.go | 48 ++ .../deploy/mysql/initdb/001_user_service.sql | 470 ++++++------ .../user-service/internal/domain/user/user.go | 4 +- .../internal/domain/user/user_test.go | 21 + .../internal/storage/mysql/region/regions.go | 13 +- .../internal/service/wallet/service.go | 3 - .../internal/service/wallet/service_test.go | 35 + .../storage/mysql/app_wallet_repository.go | 4 +- .../internal/storage/mysql/repository.go | 19 +- .../storage/mysql/resource_repository.go | 4 +- 78 files changed, 3590 insertions(+), 640 deletions(-) create mode 100644 deploy/__init__.py create mode 100644 deploy/tencent-tat/README.md create mode 100644 deploy/tencent-tat/hyapp_tat_deploy.py create mode 100644 deploy/tencent-tat/inventory.prod.example.json create mode 100644 deploy/tencent-tat/tests/test_hyapp_tat_deploy.py create mode 100644 deploy/tencent_tat/__init__.py create mode 100644 deploy/tencent_tat/hyapp_tat_deploy.py create mode 100644 docs/notice-service架构.md delete mode 100644 server/admin/internal/modules/notification/handler.go delete mode 100644 server/admin/internal/modules/notification/response.go delete mode 100644 server/admin/internal/modules/notification/routes.go delete mode 100644 server/admin/internal/modules/notification/service.go delete mode 100644 server/admin/internal/repository/notification_repository.go create mode 100644 server/admin/migrations/008_remove_notification_center.sql create mode 100644 services/notice-service/Dockerfile create mode 100644 services/notice-service/cmd/server/main.go create mode 100644 services/notice-service/configs/config.docker.yaml create mode 100644 services/notice-service/configs/config.tencent.example.yaml create mode 100644 services/notice-service/configs/config.yaml create mode 100644 services/notice-service/deploy/mysql/initdb/001_notice_service.sql create mode 100644 services/notice-service/internal/app/app.go create mode 100644 services/notice-service/internal/config/config.go create mode 100644 services/notice-service/internal/modules/walletnotice/mysql_integration_test.go create mode 100644 services/notice-service/internal/modules/walletnotice/mysql_repository.go create mode 100644 services/notice-service/internal/modules/walletnotice/service.go create mode 100644 services/notice-service/internal/modules/walletnotice/service_test.go create mode 100644 services/notice-service/internal/modules/walletnotice/worker.go create mode 100644 services/notice-service/internal/platform/mysql/db.go create mode 100644 services/user-service/internal/domain/user/user_test.go diff --git a/AGENTS.md b/AGENTS.md index bcd0e714..ce7b5bdc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ - 腾讯云 IM 负责客户端长连接、房间群消息、公屏、单聊、离线/漫游和全球接入; - `wallet-service` 负责账务语义。`SendGift` 必须先扣费成功,再落房间表现。 - `activity-service` 通过 room outbox 事件消费,不侵入 Room Cell 主链路。 +- `notice-service` 负责从各 owner service 的 outbox/事件源读取事实并投递私有通知、腾讯云 IM 单聊、后续 push/inbox;钱包、活动和房间不能直接耦合私有通知投递实现。 - `user-service` 负责用户主数据,不应该成为房间高频流程的同步中心。 - `cron-service` 只负责后台调度、任务运行记录和通过内部 gRPC 触发 owner service 批处理;不能直接拥有房间、账务、用户或活动业务状态。 - `server/admin` 是后台管理服务独立 module,负责管理员、RBAC、菜单、审计和后台 HTTP API;访问 app 后端只能通过 `hyapp.local/api` 生成的 protobuf client 或明确的集成接口。 @@ -62,6 +63,8 @@ - `13005`: user gRPC - `13006`: activity gRPC - `13007`: cron gRPC +- `13008`: game gRPC +- `13009`: notice gRPC - `23306`: local Docker MySQL host port - `13379`: local Docker Redis host port @@ -73,7 +76,7 @@ - 手写代码保持高密度有效注释,注释解释工程语义和失败分支,不复述语法。 - 新增配置必须同时考虑本地 `config.yaml`、Docker `config.docker.yaml`、以及线上配置示例。 - 新增服务依赖必须在 `docker-compose.yml` 中体现本地可运行形态。 -- 数据库表结构变更必须同步更新 `services/room-service/deploy/mysql/initdb`。 +- 数据库表结构变更必须同步更新对应 owner service 的 `deploy/mysql/initdb`。 - 不要把线上密钥、云数据库密码、Redis 密码写进真实配置。只能写占位示例。 - 保持 `gofmt`,不要手调 Go import 顺序。 diff --git a/Makefile b/Makefile index 2ecb8f4a..73eafc5f 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ -COMPOSE_SERVICES := mysql redis gateway-service room-service wallet-service user-service activity-service cron-service game-service -SERVICE_ALIASES := gs gateway rs room ws wallet us user as activity cs cron game games mysql redis gateway-service room-service wallet-service user-service activity-service cron-service game-service +COMPOSE_SERVICES := mysql redis gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service +SERVICE_ALIASES := gs gateway rs room ws wallet us user as activity cs cron game games ns notice mysql redis gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service SERVICE_TOKEN := $(firstword $(filter $(SERVICE_ALIASES),$(MAKECMDGOALS))) -SERVICE_ID := $(strip $(or $(SERVICE),$(if $(filter gs gateway,$(SERVICE_TOKEN)),gateway-service,$(if $(filter rs room,$(SERVICE_TOKEN)),room-service,$(if $(filter ws wallet,$(SERVICE_TOKEN)),wallet-service,$(if $(filter us user,$(SERVICE_TOKEN)),user-service,$(if $(filter as activity,$(SERVICE_TOKEN)),activity-service,$(if $(filter cs cron,$(SERVICE_TOKEN)),cron-service,$(if $(filter game games,$(SERVICE_TOKEN)),game-service,$(SERVICE_TOKEN)))))))))) +SERVICE_ID := $(strip $(or $(SERVICE),$(if $(filter gs gateway,$(SERVICE_TOKEN)),gateway-service,$(if $(filter rs room,$(SERVICE_TOKEN)),room-service,$(if $(filter ws wallet,$(SERVICE_TOKEN)),wallet-service,$(if $(filter us user,$(SERVICE_TOKEN)),user-service,$(if $(filter as activity,$(SERVICE_TOKEN)),activity-service,$(if $(filter cs cron,$(SERVICE_TOKEN)),cron-service,$(if $(filter game games,$(SERVICE_TOKEN)),game-service,$(if $(filter ns notice,$(SERVICE_TOKEN)),notice-service,$(SERVICE_TOKEN))))))))))) ADMIN_CONFIG ?= configs/config.yaml .PHONY: proto vet test build admin admin-up admin-deps admin-bootstrap admin-test admin-build up db-init rebuild restart stop logs ps addr down up-service check-service resolve-compose-conflicts $(SERVICE_ALIASES) @@ -78,7 +78,7 @@ up: @if [ -z "$(SERVICE_ID)" ]; then \ docker compose up -d mysql redis; \ ./scripts/apply-local-mysql-initdb.sh; \ - docker compose up --build -d gateway-service room-service wallet-service user-service activity-service cron-service game-service; \ + docker compose up --build -d gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service; \ elif [ "$(SERVICE_ID)" = "mysql" ] || [ "$(SERVICE_ID)" = "redis" ]; then \ docker compose up -d $(SERVICE_ID); \ else \ @@ -140,13 +140,13 @@ down: check-service: @if [ -z "$(SERVICE_ID)" ]; then \ echo "Service is required. Example: make restart rs"; \ - echo "Aliases: gs/gateway rs/room ws/wallet us/user as/activity cs/cron game/games mysql redis"; \ + echo "Aliases: gs/gateway rs/room ws/wallet us/user as/activity cs/cron game/games ns/notice mysql redis"; \ echo "Services: $(COMPOSE_SERVICES)"; \ exit 1; \ fi @if ! printf '%s\n' $(COMPOSE_SERVICES) | grep -qx "$(SERVICE_ID)"; then \ echo "Unknown service: $(SERVICE_ID)"; \ - echo "Aliases: gs/gateway rs/room ws/wallet us/user as/activity cs/cron game/games mysql redis"; \ + echo "Aliases: gs/gateway rs/room ws/wallet us/user as/activity cs/cron game/games ns/notice mysql redis"; \ echo "Services: $(COMPOSE_SERVICES)"; \ exit 1; \ fi diff --git a/README.md b/README.md index 8251b889..f946e0da 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # HY Voice Room -HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连接服务:房间业务状态由 `room-service` 承担,客户端实时消息接入腾讯云 IM,服务端只负责签发 UserSig、创建房间群组、发送房间系统消息。 +HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连接服务:房间业务状态由 `room-service` 承担,客户端实时消息接入腾讯云 IM;服务端负责签发 UserSig、房间群消息桥接,以及通过 `notice-service` 异步投递用户私有通知。 ## Architecture @@ -14,6 +14,7 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连 - `user-service` 当前承接登录、三方注册、密码、refresh session、用户主数据、默认短号和临时靓号基础能力。 - `activity-service` 当前承接每日任务、活动/系统消息基础能力和房间事件 consumer 边界;`room_outbox` 到 activity/audit 的实际消费链路按独立消费位点接入。 - `cron-service` 当前负责后台调度、任务运行记录,以及通过内部 gRPC 触发 owner service 批处理;它不直接拥有房间、账务、用户或活动业务状态。 +- `notice-service` 当前消费 `wallet_outbox` 的余额变更事件,异步投递腾讯云 IM 单聊私有通知;后续 push、站内 inbox 和其它 owner outbox 消费按模块继续扩展。 - 腾讯云 IM 承担客户端长连接、群消息、公屏、单聊、离线/漫游和全球接入能力;本仓库不再部署 `im-service`。 - 多 App 共用同一套后端服务,入口由 `gateway-service` 通过包名或 `app_code` 解析租户;所有业务库表按 `app_code` 隔离,设计细节见 `docs/多App租户架构.md`。 @@ -46,6 +47,8 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连 | `user-service` | `13005` | gRPC | | `activity-service` | `13006` | gRPC | | `cron-service` | `13007` | gRPC | +| `game-service` | `13008` | gRPC | +| `notice-service` | `13009` | gRPC | | MySQL | `23306 -> 3306` | local Docker only | | Redis | `13379 -> 6379` | local Docker only | @@ -57,7 +60,7 @@ Use Docker for local and test environments: make up ``` -Service aliases: `gs/gateway`, `rs/room`, `ws/wallet`, `us/user`, `as/activity`, `cs/cron`, `mysql`, `redis`, plus full service names. +Service aliases: `gs/gateway`, `rs/room`, `ws/wallet`, `us/user`, `as/activity`, `cs/cron`, `game/games`, `ns/notice`, `mysql`, `redis`, plus full service names. Common commands: @@ -74,7 +77,7 @@ make down `make admin-up` 会先启动后台本地依赖 MySQL、Redis 和 `user-service`,再以前台进程启动 `server/admin`。依赖已经存在时,可以直接运行 `make admin`。 -For direct host execution, each service reads its own `services//configs/config.yaml`; `room-service`、`user-service`、`wallet-service`、`activity-service` 和 `cron-service` 都要求 MySQL 可用。 +For direct host execution, each service reads its own `services//configs/config.yaml`; `room-service`、`user-service`、`wallet-service`、`activity-service`、`cron-service`、`game-service` 和 `notice-service` 都要求 MySQL 可用。 ## Tencent IM @@ -102,8 +105,9 @@ Authorization: Bearer - 客户端再用腾讯云 IM SDK 登录并加入 `room_id` 对应的群组。 - `gateway-service` 创建房间时从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id`;`room-service` 由该 actor 收敛 owner,外部 CreateRoom 请求体不接收 `owner_user_id`。 - `X-Request-ID`/`request_id` 由后端生成,只做链路追踪;写操作的 `command_id` 由前端按用户动作生成,用于服务端幂等。 -- `room-service` 在 `CreateRoom` 时通过腾讯云 IM REST API 创建同名群组。 -- `room-service` 在上下麦、禁言、踢人、送礼等命令提交后,通过腾讯云 IM REST API 发送房间系统自定义消息。 +- `room-service` 在 `CreateRoom` 成功后写 `room_outbox`,由 outbox worker 异步创建或补偿同名腾讯云 IM 群组。 +- `room-service` 在上下麦、禁言、踢人、送礼等命令提交后只写房间状态、command log 和 outbox;腾讯云 IM 房间系统自定义消息由 outbox worker 异步投递。 +- `notice-service` 消费钱包余额变更 outbox,向单个用户发送 `wallet_notice` 私有自定义消息;钱包不会直接调用腾讯云 IM。 - `gateway-service` 已提供 `/api/v1/tencent-im/callback`,支持腾讯云 IM 入群前和发言前回调,并分别回查 `VerifyRoomPresence` 和 `CheckSpeakPermission`。 腾讯云 IM 配置分别在: @@ -112,14 +116,17 @@ Authorization: Bearer services/gateway-service/configs/config.yaml services/room-service/configs/config.yaml services/room-service/configs/config.tencent.example.yaml +services/notice-service/configs/config.yaml +services/notice-service/configs/config.tencent.example.yaml ``` -本地联调腾讯云 IM 时,`gateway-service` 和 `room-service` 必须使用同一组 `sdk_app_id`、`secret_key`,`room-service` 还必须配置匹配的 `admin_identifier` 和区域 `endpoint`。 +本地联调腾讯云 IM 时,`gateway-service`、`room-service` 和 `notice-service` 必须使用同一组 `sdk_app_id`、`secret_key`;所有服务端 REST 调用方还必须配置匹配的 `admin_identifier` 和区域 `endpoint`。 基础闭环实现顺序见: ```text docs/语音房基础闭环实现.md +docs/notice-service架构.md ``` ## Storage Model @@ -131,6 +138,8 @@ MySQL 是本地和线上运行的持久化底座: - `wallet-service`: `hyapp_wallet`,保存余额、账务流水、礼物价格和扣费幂等记录,业务行按 `app_code` 隔离。 - `activity-service`: `hyapp_activity`,保存任务定义、任务进度、奖励领取、活动状态、事件消费幂等和 activity outbox,业务行按 `app_code` 隔离。 - `cron-service`: `hyapp_cron`,保存后台任务定义、运行记录和调度锁;实际业务批处理仍由各 owner service 执行。 +- `game-service`: `hyapp_game`,保存游戏平台、房间游戏会话和游戏交易映射。 +- `notice-service`: `hyapp_notice`,保存外部通知投递位点、重试锁和死信状态;业务事实仍留在 owner service outbox。 - `hyapp_admin`: 后台管理后端独立库,只保存后台账号、权限和审计;不要把后台审计耦合进 App 业务库。 `room-service` uses MySQL as the room recovery durable source: @@ -178,6 +187,8 @@ services/wallet-service/ wallet DebitGift and ledger foundation services/user-service/ user service services/activity-service/ activity foundation and room event consumer boundary services/cron-service/ background scheduling and owner-service batch trigger +services/game-service/ game session and wallet integration +services/notice-service/ async user notice delivery workers ``` ## Current Limits @@ -185,6 +196,7 @@ services/cron-service/ background scheduling and owner-service batch trigger - 腾讯云 IM 回调入口已经落在 gateway;线上仍必须在腾讯云 IM 控制台启用 `Group.CallbackBeforeApplyJoinGroup` 和 `Group.CallbackBeforeSendMsg`,并配置公网 callback URL 与鉴权 token。 - 前端对同一次用户写动作的 HTTP 重试必须复用同一个 `command_id`;`X-Request-ID` 不需要也不应该由前端传入。 - `wallet-service` App 运行路径使用 MySQL 扣费事务;本地需要先通过 compose initdb 建好 `hyapp_wallet`。 +- `notice-service` 本地默认不启用腾讯云 IM worker;线上启用 `wallet_notice_worker.enabled=true` 时必须同时启用 `tencent_im.enabled=true`。 - `activity-service` 已承接每日任务和消息/活动基础能力;`room_outbox` 到 activity/audit 的实际消费链路仍需按独立消费位点补齐。 - `room-service` App 已启动 `room_outbox` 补偿 worker;该状态只表示腾讯云 IM 系统消息补偿结果,activity/audit 后续必须使用独立消费位点。 - JWT、腾讯云 IM secret 等本地配置只能用开发值,线上必须走安全配置源。 diff --git a/deploy/__init__.py b/deploy/__init__.py new file mode 100644 index 00000000..b5b1475f --- /dev/null +++ b/deploy/__init__.py @@ -0,0 +1 @@ +# Package marker for deployment helper tests. diff --git a/deploy/mysql/initdb/999_local_grants.sql b/deploy/mysql/initdb/999_local_grants.sql index ff7c6833..c6d01e6c 100644 --- a/deploy/mysql/initdb/999_local_grants.sql +++ b/deploy/mysql/initdb/999_local_grants.sql @@ -9,5 +9,6 @@ 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'@'%'; FLUSH PRIVILEGES; diff --git a/deploy/standalone/README.md b/deploy/standalone/README.md index a32d1aff..9410526a 100644 --- a/deploy/standalone/README.md +++ b/deploy/standalone/README.md @@ -2,6 +2,8 @@ 本目录提供不依赖 Kubernetes 的生产部署模板。目标形态是 systemd 托管 Docker 容器,公网 CLB 只接 gateway,内部服务通过内网 CLB/DNS 或固定私网地址访问。 +生产环境 MySQL、Redis、MQ 使用腾讯云托管资源。本目录只管理本仓库 Go 服务容器,不在业务 CVM 上安装或重启 MySQL/Redis/MQ。 + ## Layout - `systemd/*.service`: 每个后端服务一份 systemd unit,负责启动、停止和故障重启 Docker 容器。 @@ -53,7 +55,32 @@ sudo docker inspect --format '{{json .State.Health}}' hyapp-gateway-service 5. 从 CLB 摘除旧实例,等待连接保护或业务 drain 窗口结束。 6. `systemctl stop hyapp-`,Docker 会先发送 SIGTERM,服务内的 graceful shutdown 会进入 draining 并等待已进入请求结束。 -`room-service` 当前还没有 gateway owner-routing 或非 owner 自动转发能力。没有补完该能力前,不要把多个 `room-service` active 实例直接放到普通负载均衡后面承接同一批房间命令;生产先保持单 active,或者只把备用实例作为故障接管目标。 +`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 \ + --tag 20260513-1349 \ + --yes +``` + +详细步骤和无感发布边界见 `deploy/tencent-tat/README.md`。 ## Service Discovery @@ -69,3 +96,4 @@ game_service_addr: "game.service.internal:13008" 如果后续引入 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 index 6ecd04d5..ff6e626e 100644 --- a/deploy/standalone/docker/activity-service.env.example +++ b/deploy/standalone/docker/activity-service.env.example @@ -1,5 +1,5 @@ # Image is immutable per release; publish scripts pull this tag before restarting systemd. -IMAGE=ccr.ccs.tencentyun.com/hyapp/activity-service:REPLACE_WITH_RELEASE_TAG +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/cron-service.env.example b/deploy/standalone/docker/cron-service.env.example index cc526a93..bf3b2773 100644 --- a/deploy/standalone/docker/cron-service.env.example +++ b/deploy/standalone/docker/cron-service.env.example @@ -1,5 +1,5 @@ # cron-service already uses MySQL task leases, but keep one replica until every task is verified under multi-node scheduling. -IMAGE=ccr.ccs.tencentyun.com/hyapp/cron-service:REPLACE_WITH_RELEASE_TAG +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 index 6b17afa4..f6ad9abb 100644 --- a/deploy/standalone/docker/game-service.env.example +++ b/deploy/standalone/docker/game-service.env.example @@ -1,4 +1,4 @@ -IMAGE=ccr.ccs.tencentyun.com/hyapp/game-service:REPLACE_WITH_RELEASE_TAG +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 index 2cd909c3..9ce0cc72 100644 --- a/deploy/standalone/docker/gateway-service.env.example +++ b/deploy/standalone/docker/gateway-service.env.example @@ -1,5 +1,5 @@ # gateway instances are stateless HTTP entries and should be attached behind the public CLB only after /healthz/ready passes. -IMAGE=ccr.ccs.tencentyun.com/hyapp/gateway-service:REPLACE_WITH_RELEASE_TAG +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/room-service.env.example b/deploy/standalone/docker/room-service.env.example index 69a5dcd6..27d1e326 100644 --- a/deploy/standalone/docker/room-service.env.example +++ b/deploy/standalone/docker/room-service.env.example @@ -1,5 +1,5 @@ # Keep room-service single-active until owner routing or non-owner forwarding is implemented. -IMAGE=ccr.ccs.tencentyun.com/hyapp/room-service:REPLACE_WITH_RELEASE_TAG +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/user-service.env.example b/deploy/standalone/docker/user-service.env.example index 0e96d2d2..c7209721 100644 --- a/deploy/standalone/docker/user-service.env.example +++ b/deploy/standalone/docker/user-service.env.example @@ -1,5 +1,5 @@ # id_generator.node_id in config.yaml must be unique for every active user-service instance. -IMAGE=ccr.ccs.tencentyun.com/hyapp/user-service:REPLACE_WITH_RELEASE_TAG +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 index 16cafd35..44f1ef3d 100644 --- a/deploy/standalone/docker/wallet-service.env.example +++ b/deploy/standalone/docker/wallet-service.env.example @@ -1,4 +1,4 @@ -IMAGE=ccr.ccs.tencentyun.com/hyapp/wallet-service:REPLACE_WITH_RELEASE_TAG +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 index ca3c5887..3e2761d3 100644 --- a/deploy/standalone/systemd/hyapp-activity-service.service +++ b/deploy/standalone/systemd/hyapp-activity-service.service @@ -9,9 +9,9 @@ StartLimitBurst=3 [Service] Type=simple EnvironmentFile=/etc/hyapp/activity-service/docker.env -ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME} -ExecStart=/usr/bin/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/docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME} +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 diff --git a/deploy/standalone/systemd/hyapp-cron-service.service b/deploy/standalone/systemd/hyapp-cron-service.service index 5cddf897..c2fa3bfc 100644 --- a/deploy/standalone/systemd/hyapp-cron-service.service +++ b/deploy/standalone/systemd/hyapp-cron-service.service @@ -9,9 +9,9 @@ StartLimitBurst=3 [Service] Type=simple EnvironmentFile=/etc/hyapp/cron-service/docker.env -ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME} -ExecStart=/usr/bin/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/docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME} +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 diff --git a/deploy/standalone/systemd/hyapp-game-service.service b/deploy/standalone/systemd/hyapp-game-service.service index 9845d694..37f0f8c9 100644 --- a/deploy/standalone/systemd/hyapp-game-service.service +++ b/deploy/standalone/systemd/hyapp-game-service.service @@ -9,9 +9,9 @@ StartLimitBurst=3 [Service] Type=simple EnvironmentFile=/etc/hyapp/game-service/docker.env -ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME} -ExecStart=/usr/bin/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/docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME} +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 diff --git a/deploy/standalone/systemd/hyapp-gateway-service.service b/deploy/standalone/systemd/hyapp-gateway-service.service index 71eaf1b1..499fd7a7 100644 --- a/deploy/standalone/systemd/hyapp-gateway-service.service +++ b/deploy/standalone/systemd/hyapp-gateway-service.service @@ -9,9 +9,9 @@ StartLimitBurst=3 [Service] Type=simple EnvironmentFile=/etc/hyapp/gateway-service/docker.env -ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME} -ExecStart=/usr/bin/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/docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME} +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 diff --git a/deploy/standalone/systemd/hyapp-room-service.service b/deploy/standalone/systemd/hyapp-room-service.service index 09eb1104..08abcb74 100644 --- a/deploy/standalone/systemd/hyapp-room-service.service +++ b/deploy/standalone/systemd/hyapp-room-service.service @@ -9,9 +9,9 @@ StartLimitBurst=3 [Service] Type=simple EnvironmentFile=/etc/hyapp/room-service/docker.env -ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME} -ExecStart=/usr/bin/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/docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME} +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 diff --git a/deploy/standalone/systemd/hyapp-user-service.service b/deploy/standalone/systemd/hyapp-user-service.service index c83c1d7d..1670e1f3 100644 --- a/deploy/standalone/systemd/hyapp-user-service.service +++ b/deploy/standalone/systemd/hyapp-user-service.service @@ -9,9 +9,9 @@ StartLimitBurst=3 [Service] Type=simple EnvironmentFile=/etc/hyapp/user-service/docker.env -ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME} -ExecStart=/usr/bin/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/docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME} +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 diff --git a/deploy/standalone/systemd/hyapp-wallet-service.service b/deploy/standalone/systemd/hyapp-wallet-service.service index a49dbc97..4a0ecbc9 100644 --- a/deploy/standalone/systemd/hyapp-wallet-service.service +++ b/deploy/standalone/systemd/hyapp-wallet-service.service @@ -9,9 +9,9 @@ StartLimitBurst=3 [Service] Type=simple EnvironmentFile=/etc/hyapp/wallet-service/docker.env -ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME} -ExecStart=/usr/bin/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:13004 -service=wallet-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE} -ExecStop=/usr/bin/docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME} +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: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 diff --git a/deploy/tencent-tat/README.md b/deploy/tencent-tat/README.md new file mode 100644 index 00000000..b5eb9331 --- /dev/null +++ b/deploy/tencent-tat/README.md @@ -0,0 +1,286 @@ +# 腾讯云 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` | +| `new-app-activity-2` | `ins-d1n303sa` | `10.2.1.7` | `activity-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: + +```bash +python3 -m pip install tencentcloud-sdk-python +``` + +凭据通过环境变量提供: + +```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 +``` + +拉取本仓库: + +```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。 + +## 构建并推送镜像 + +在 `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 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" +``` + +如果实际用 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` | +| `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。 +- 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 +``` + +`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 \ + --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 \ + --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 new file mode 100644 index 00000000..52305b5f --- /dev/null +++ b/deploy/tencent-tat/hyapp_tat_deploy.py @@ -0,0 +1,703 @@ +#!/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|:13100|:13101|:13104|:13105|:13106|:13108/' || 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 new file mode 100644 index 00000000..818719e1 --- /dev/null +++ b/deploy/tencent-tat/inventory.prod.example.json @@ -0,0 +1,180 @@ +{ + "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": "ns-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 + } + }, + "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 + } + } + } +} diff --git a/deploy/tencent-tat/tests/test_hyapp_tat_deploy.py b/deploy/tencent-tat/tests/test_hyapp_tat_deploy.py new file mode 100644 index 00000000..df6b486a --- /dev/null +++ b/deploy/tencent-tat/tests/test_hyapp_tat_deploy.py @@ -0,0 +1,110 @@ +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.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/__init__.py b/deploy/tencent_tat/__init__.py new file mode 100644 index 00000000..e2ecf810 --- /dev/null +++ b/deploy/tencent_tat/__init__.py @@ -0,0 +1 @@ +# 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 new file mode 100644 index 00000000..f3729a9a --- /dev/null +++ b/deploy/tencent_tat/hyapp_tat_deploy.py @@ -0,0 +1,16 @@ +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 3a17a524..889646cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -173,6 +173,27 @@ services: retries: 20 start_period: 10s + notice-service: + build: + context: . + dockerfile: services/notice-service/Dockerfile + args: *go-build-args + container_name: notice-service + environment: + TZ: UTC + ports: + - "13009:13009" + - "13109:13109" + depends_on: + mysql: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13109/healthz/ready >/dev/null"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + mysql: image: mysql:8.4 container_name: hyapp-mysql @@ -199,6 +220,7 @@ services: - ./deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql:/docker-entrypoint-initdb.d/007_resource_group_wallet_asset_items.sql:ro - ./services/cron-service/deploy/mysql/initdb/001_cron_service.sql:/docker-entrypoint-initdb.d/008_cron_service.sql:ro - ./services/game-service/deploy/mysql/initdb/001_game_service.sql:/docker-entrypoint-initdb.d/009_game_service.sql:ro + - ./services/notice-service/deploy/mysql/initdb/001_notice_service.sql:/docker-entrypoint-initdb.d/010_notice_service.sql:ro - ./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"] diff --git a/docs/api-test-harness.html b/docs/api-test-harness.html index ac4c6773..5b4c4d32 100644 --- a/docs/api-test-harness.html +++ b/docs/api-test-harness.html @@ -1403,42 +1403,6 @@ "summary": "navigationMenus", "tag": "admin" }, - { - "source": "admin", - "executable": true, - "method": "GET", - "path": "/api/v1/notifications", - "operationId": "listNotifications", - "summary": "listNotifications", - "tag": "admin" - }, - { - "source": "admin", - "executable": true, - "method": "DELETE", - "path": "/api/v1/notifications/{id}", - "operationId": "deleteNotification", - "summary": "deleteNotification", - "tag": "admin" - }, - { - "source": "admin", - "executable": true, - "method": "PATCH", - "path": "/api/v1/notifications/{id}/read", - "operationId": "markNotificationRead", - "summary": "markNotificationRead", - "tag": "admin" - }, - { - "source": "admin", - "executable": true, - "method": "PATCH", - "path": "/api/v1/notifications/read-all", - "operationId": "markAllNotificationsRead", - "summary": "markAllNotificationsRead", - "tag": "admin" - }, { "source": "admin", "executable": true, diff --git a/docs/notice-service架构.md b/docs/notice-service架构.md new file mode 100644 index 00000000..c07d6d59 --- /dev/null +++ b/docs/notice-service架构.md @@ -0,0 +1,161 @@ +# notice-service 架构 + +## 目标 + +`notice-service` 是外部通知投递服务,不是新的业务状态 owner。它从各 owner service 的 outbox 或只读事件源读取已经提交的事实,再投递到用户私有实时通道、后续 push 或站内 inbox。 + +当前已实现的第一条链路是: + +- `wallet-service` 在同一账务事务里写 `wallet_outbox.WalletBalanceChanged`。 +- `notice-service` 抢占该事件,写自己的 `notice_delivery_events` 投递位点。 +- `notice-service` 通过腾讯云 IM C2C `TIMCustomElem` 给用户发送 `wallet_notice`。 +- 客户端收到通知后按 `asset_type + balance_version` 更新本地余额;乱序或重复事件直接丢弃。 + +## 服务边界 + +| 服务 | 职责 | +| --- | --- | +| `wallet-service` | 余额、流水、交易幂等、`wallet_outbox` 事实源;不调用 IM、不关心客户端在线状态 | +| `notice-service` | 通知投递位点、抢占锁、退避重试、死信、腾讯云 IM 单聊投递 | +| `room-service` | 房间状态和房间群系统消息 outbox;不发送钱包私有余额 | +| `activity-service` | 活动、任务、系统消息业务事实;后续可新增 activity notice 模块 | +| 腾讯云 IM | 客户端长连接、单聊/群消息、离线/漫游投递 | + +`notice-service` 不直接修改钱包、房间、用户或活动业务表。它只读取 owner outbox,并写自己的投递状态表。 + +## 当前目录结构 + +```text +services/notice-service/ + cmd/server/ 进程入口 + configs/ 本地、Docker、腾讯云示例配置 + deploy/mysql/initdb/ notice-service 自己的库表 + internal/app/ 进程装配、health、worker 生命周期 + internal/config/ 配置解析和默认值 + internal/modules/walletnotice/ 钱包余额通知模块 + internal/platform/mysql/ notice-service MySQL 连接池 +``` + +后续新增通知类型时按模块扩展,例如: + +```text +internal/modules/activitynotice/ +internal/modules/systeminbox/ +internal/modules/pushnotice/ +``` + +不要把不同业务域的 SQL、payload 和 worker 都塞进一个通用目录。 + +## 钱包余额通知链路 + +```mermaid +sequenceDiagram + participant Client as App Client + participant Gateway as gateway-service + participant Room as room-service + participant Wallet as wallet-service + participant WalletDB as hyapp_wallet + participant Notice as notice-service + participant NoticeDB as hyapp_notice + participant TencentIM as Tencent IM + + Client->>Gateway: POST /api/v1/rooms/gift/send + Gateway->>Room: SendGift(command_id) + Room->>Wallet: DebitGift(command_id) + Wallet->>WalletDB: tx: debit/credit entries + wallet_outbox + Wallet-->>Room: balance_after, heat_value + Room->>Room: Room Cell command + command log + room outbox + Room-->>Gateway: success + Gateway-->>Client: success response + Notice->>WalletDB: claim WalletBalanceChanged + Notice->>NoticeDB: notice_delivery_events=delivering + Notice->>TencentIM: C2C TIMCustomElem(wallet_notice) + TencentIM-->>Client: private balance notice + Notice->>NoticeDB: delivered / retryable / failed +``` + +主请求链路只等待钱包扣费和房间状态提交,不等待腾讯云 IM 私有通知。因此送礼请求不会因为 IM 抖动变慢。IM 投递失败时,用户仍可以通过钱包接口拉取最终余额;notice worker 会按退避策略补偿。 + +## Payload 约定 + +`WalletBalanceChanged` 发送给客户端的核心字段: + +```json +{ + "event_type": "WalletBalanceChanged", + "event_id": "tx_xxx:WalletBalanceChanged:10001:COIN", + "app_code": "lalu", + "transaction_id": "tx_xxx", + "command_id": "gift_cmd_001", + "user_id": "10001", + "asset_type": "COIN", + "available_delta": -9900, + "frozen_delta": 0, + "available_after": 1123123, + "frozen_after": 0, + "balance_version": 18, + "created_at_ms": 1710000000000, + "source_created_at_ms": 1710000000000, + "metadata": {} +} +``` + +客户端处理规则: + +- 按 `event_id` 去重。 +- 按 `asset_type` 分桶维护本地余额。 +- 如果本地已有该资产 `balance_version >= event.balance_version`,丢弃这条通知。 +- 如果发现版本跳跃较大或本地余额不可确认,调用钱包摘要接口重新拉取权威余额。 + +这套规则同时覆盖普通送礼扣费、幸运礼物中奖返奖、VIP 购买扣费、资源组发金币、币商转币等余额变化。 + +## 多实例和重启语义 + +`notice_delivery_events` 是 worker 的投递位点表: + +- 主键:`source_name + app_code + source_event_id + channel`。 +- `delivering` 通过 `locked_by + lock_until_ms` 表示抢占锁。 +- worker 重启后,锁过期的 `delivering` 会被其它实例重新抢占。 +- 投递失败进入 `retryable`,按 `next_retry_at_ms` 退避重试。 +- 超过 `max_retry_count` 进入 `failed`,不再自动重试,需要后台或运维手动查看死信。 + +因此部署两台 `notice-service` 不会重复抢同一条事件;即使腾讯云 IM 返回成功后本地标记 delivered 失败,下一轮最多重复投递一次,客户端用 `event_id` 去重。 + +## 配置 + +本地默认关闭 worker,避免误发真实腾讯云 IM: + +```yaml +tencent_im: + enabled: false + +wallet_notice_worker: + enabled: false +``` + +线上启用钱包余额通知必须同时开启: + +```yaml +tencent_im: + enabled: true + sdk_app_id: 1400000000 + secret_key: "${TENCENT_IM_SECRET_KEY}" + admin_identifier: "administrator" + +wallet_notice_worker: + enabled: true + poll_interval: 500ms + batch_size: 100 + lock_ttl: 30s + max_retry_count: 10 +``` + +`notice-service` 使用 `13009` 作为 gRPC health 端口,`13109` 作为 HTTP health 端口。 + +## 后续扩展顺序 + +1. 钱包余额通知:已按 `walletnotice` 模块落地。 +2. 幸运礼物中奖:仍由 `wallet-service` 写余额 outbox;notice 不需要新增特殊账务逻辑,只要客户端按 `balance_version` 更新。 +3. 活动/系统私有通知:新增 `activitynotice` 模块,读取 activity outbox,复用 `notice_delivery_events` 或新增同语义位点表。 +4. 站内 inbox:新增 inbox 模块,写 notice-service 自己的 inbox 表,再按客户端分页接口读取。 +5. Push:新增 push 模块,和 IM 投递使用不同 `channel`,独立重试和死信。 diff --git a/docs/多机部署与无感发布.md b/docs/多机部署与无感发布.md index b9d050b5..83cd62a5 100644 --- a/docs/多机部署与无感发布.md +++ b/docs/多机部署与无感发布.md @@ -24,6 +24,17 @@ room-service 实例之间通过 Redis owner route 和 node registry 直连转发 - 内部 gRPC 服务只监听私网,安全组只允许服务网段访问 `13xxx` 端口。 - `user-service`、`wallet-service`、`activity-service`、`game-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` | +| room/user/activity/game 内网入口 | `10.2.1.16:13001/13005/13006/13008` | `lb-epvnr4o0` 的 Go 服务监听器 | +| wallet 内网入口 | `10.2.1.15:13004` | `lb-4f5xi6p0` 的 Go 服务监听器 | + +当前生产镜像仓库前缀是 `10.2.1.3:18082/hyapp`。部署脚本和 systemd env 只允许使用 `hyapp` 项目路径。 ## Room Owner 路由 @@ -91,13 +102,19 @@ lease_ttl: "10s" 不用统一配置中心也可以上线。推荐发布系统渲染最终 `config.yaml`: -- 公共环境配置:MySQL/Redis 私网地址、日志等级、腾讯云 endpoint。 +- 公共环境配置:腾讯云 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 为边界。 +托管依赖边界: + +- 腾讯云 MySQL 是业务事实和恢复来源;DSN 必须保留 `loc=UTC`。 +- 腾讯云 Redis 保存 owner route、node registry、登录风控和短 TTL 缓存;Redis 不能作为业务恢复事实来源。 +- 腾讯云 MQ 只作为 outbox/event bus 的托管消息通道;创建 topic、consumer group 和权限变更走云产品流程,不混进服务滚动发布。 + ## 真实数据验证 本地真实 MySQL/Redis 验证: diff --git a/docs/接口清单.md b/docs/接口清单.md index db7c89f9..1e264e5a 100644 --- a/docs/接口清单.md +++ b/docs/接口清单.md @@ -200,10 +200,6 @@ | GET | `/api/v1/logs/operations` | admin | `listOperationLogs` | listOperationLogs | | GET | `/api/v1/logs/operations/export` | admin | `exportOperationLogs` | exportOperationLogs | | GET | `/api/v1/navigation/menus` | admin | `navigationMenus` | navigationMenus | -| GET | `/api/v1/notifications` | admin | `listNotifications` | listNotifications | -| DELETE | `/api/v1/notifications/{id}` | admin | `deleteNotification` | deleteNotification | -| PATCH | `/api/v1/notifications/{id}/read` | admin | `markNotificationRead` | markNotificationRead | -| PATCH | `/api/v1/notifications/read-all` | admin | `markAllNotificationsRead` | markAllNotificationsRead | | GET | `/api/v1/permissions` | admin | `listPermissions` | listPermissions | | POST | `/api/v1/permissions` | admin | `createPermission` | createPermission | | DELETE | `/api/v1/permissions/{id}` | admin | `deletePermission` | deletePermission | diff --git a/docs/用户区域与国家开发.md b/docs/用户区域与国家开发.md index 9edf6069..6ec62e2e 100644 --- a/docs/用户区域与国家开发.md +++ b/docs/用户区域与国家开发.md @@ -92,9 +92,9 @@ | `flag` | yes | App 展示用 emoji flag | | `status` | yes | 默认 `active` | | `sort_order` | yes | App 注册页排序 | -| `region` | no | 地理大区,例如 `Asia`、`Africa`,只用于后续国家到业务区域的初始映射建议 | +| `region` | no | 地理大区,例如 `Asia`、`Africa`,只用于运营筛选和后续人工调整参考 | | `region_display_name` | no | `region` 的中文展示名,例如 `亚洲`、`非洲`,只用于配置和运营展示 | -| `subregion` | no | 地理子区域,例如 `South-Eastern Asia`,只用于后续映射规则或运营筛选 | +| `subregion` | no | 地理子区域,例如 `South-Eastern Asia`,只用于运营筛选和后续人工调整参考 | | `subregion_display_name` | no | `subregion` 的中文展示名,例如 `东南亚`、`西亚`,只用于配置和运营展示 | 不再保留这些源数据字段: @@ -110,10 +110,9 @@ source_assignment_status 当前开发态初始化会把种子数据导入三张表: - `countries` 写入国家主数据字段,不写 `region/subregion` 文本。 -- `regions.region_code` 使用 `subregion` 原值,`regions.name` 使用 `subregion_display_name`。 -- `region_countries` 按每个国家的 `subregion` 建立 active 映射。 -- 一级 `region/region_display_name` 不入库,不参与业务区域判断。 -- 没有 `subregion` 的少数地区统一落到 `UNSPECIFIED`,避免注册和用户投影出现无区域主记录。 +- `regions` 只初始化三个后台可管理业务区:`MIDDLE_EAST`/中东区、`INDIA`/印度区、`EURASIA_AMERICA`/欧亚美区。 +- `region_countries` 按产品分桶写 active 映射:中东国家进入中东区,印度相关国家进入印度区,其余已开放国家进入欧亚美区。 +- `GLOBAL` 只作为用户没有显式区域时的内部兜底,不出现在后台区域管理列表。 ### Region @@ -124,7 +123,7 @@ source_assignment_status | Field | Rule | | --- | --- | | `region_id` | 服务端生成的内部主键,不给客户端提交 | -| `region_code` | 稳定业务编码;当前初始化直接使用 `countries.json.subregion`,例如 `South-Eastern Asia` | +| `region_code` | 稳定业务编码;当前初始化使用 `MIDDLE_EAST`、`INDIA`、`EURASIA_AMERICA` | | `name` | 管理端展示名 | | `status` | `active` 或 `disabled` | | `countries` | 国家码列表,元素必须存在于 `countries.country_code` | @@ -135,7 +134,7 @@ source_assignment_status - 只要国家记录存在即可配置到区域,`enabled=false` 只影响 App 用户选择。 - 一个国家最多只能属于一个 active 区域。 -- 国家不属于任何区域时,用户 `region_id` 为空。 +- 种子内的开放国家都会归属到一个 active 业务区;没有显式映射时才使用 `GLOBAL` 内部兜底。 - 区域停用后,该区域下国家不再产生归属。 - 区域停用必须释放 active 国家归属,或把映射标记为 inactive,确保这些国家可以重新配置到其他 active 区域。 - 管理端修改国家归属后,后续注册和改国家立即按新映射生效。 diff --git a/pkg/tencentim/rest_client.go b/pkg/tencentim/rest_client.go index e08fc16d..543ae289 100644 --- a/pkg/tencentim/rest_client.go +++ b/pkg/tencentim/rest_client.go @@ -24,6 +24,8 @@ const ( createGroupCommand = "v4/group_open_http_svc/create_group" // sendGroupMsgCommand 是服务端发群消息 REST 命令;播报只走服务端管理员账号,不开放客户端直发。 sendGroupMsgCommand = "v4/group_open_http_svc/send_group_msg" + // sendC2CMsgCommand 是服务端给单个用户发自定义消息的 REST 命令;钱包余额变更这类私有事件只能走单聊面。 + sendC2CMsgCommand = "v4/openim/sendmsg" // destroyGroupCommand 用于真实 IM smoke test 或明确运维动作的临时群清理,业务代码不应自动解散播报群。 destroyGroupCommand = "v4/group_open_http_svc/destroy_group" // deleteGroupMemberCommand 用于把已离房用户从腾讯群里移除,防止 IM 群成员残留扩大消息面。 @@ -70,6 +72,17 @@ type CustomGroupMessage struct { PayloadJSON json.RawMessage } +// CustomUserMessage 表示一条服务端发给单个用户的 TIMCustomElem。 +// PayloadJSON 同时写入 Data 和 CloudCustomData;客户端必须按 event_id 去重。 +type CustomUserMessage struct { + ToAccount string + EventID string + Desc string + Ext string + FromAccount string + PayloadJSON json.RawMessage +} + // RoomEvent 是 room-service 发给腾讯 IM 的房间系统消息负载。 type RoomEvent struct { EventID string `json:"event_id"` @@ -236,6 +249,50 @@ func (c *RESTClient) PublishGroupCustomMessage(ctx context.Context, message Cust return response.err() } +// PublishUserCustomMessage 向单个腾讯云 IM identifier 发送 TIMCustomElem。 +// 该方法只负责投递协议;业务幂等、隐私字段和重试策略必须由调用方在 outbox 中保证。 +func (c *RESTClient) PublishUserCustomMessage(ctx context.Context, message CustomUserMessage) error { + message.ToAccount = strings.TrimSpace(message.ToAccount) + message.EventID = strings.TrimSpace(message.EventID) + message.Desc = strings.TrimSpace(message.Desc) + message.Ext = strings.TrimSpace(message.Ext) + message.FromAccount = strings.TrimSpace(message.FromAccount) + if message.FromAccount == "" { + // C2C 服务端消息默认从管理员账号发出,避免调用方每个业务模块复制同一配置。 + message.FromAccount = c.cfg.AdminIdentifier + } + payload := bytes.TrimSpace(message.PayloadJSON) + if message.ToAccount == "" || message.EventID == "" || len(payload) == 0 { + return fmt.Errorf("user custom message is incomplete") + } + + request := sendC2CMsgRequest{ + SyncOtherMachine: 2, + ToAccount: message.ToAccount, + MsgRandom: randomUint32(), + CloudCustomData: string(payload), + FromAccount: message.FromAccount, + MsgBody: []messageElement{ + { + MsgType: "TIMCustomElem", + MsgContent: customMsgContent{ + Data: string(payload), + Desc: message.Desc, + Ext: message.Ext, + Sound: "", + }, + }, + }, + } + + var response restResponse + if err := c.post(ctx, sendC2CMsgCommand, request, &response); err != nil { + return err + } + + return response.err() +} + // DestroyGroup 解散一个腾讯云 IM 群。 // 生产播报群不应该在业务流里自动调用该方法;它主要用于真实 IM 测试后的临时群清理或显式运维操作。 func (c *RESTClient) DestroyGroup(ctx context.Context, groupID string) error { @@ -396,6 +453,16 @@ type sendGroupMsgRequest struct { FromAccount string `json:"From_Account,omitempty"` } +type sendC2CMsgRequest struct { + SyncOtherMachine int `json:"SyncOtherMachine"` + FromAccount string `json:"From_Account,omitempty"` + ToAccount string `json:"To_Account"` + MsgLifeTime int `json:"MsgLifeTime,omitempty"` + MsgRandom uint32 `json:"MsgRandom"` + CloudCustomData string `json:"CloudCustomData,omitempty"` + MsgBody []messageElement `json:"MsgBody"` +} + type destroyGroupRequest struct { GroupID string `json:"GroupId"` } diff --git a/pkg/tencentim/rest_client_test.go b/pkg/tencentim/rest_client_test.go index 015f6824..1446acde 100644 --- a/pkg/tencentim/rest_client_test.go +++ b/pkg/tencentim/rest_client_test.go @@ -122,6 +122,50 @@ func TestRESTClientPublishGroupCustomMessageBuildsBroadcastPayload(t *testing.T) } } +func TestRESTClientPublishUserCustomMessageBuildsC2CPayload(t *testing.T) { + var capturedPath string + var capturedBody map[string]any + client := newTestRESTClient(t, func(request *http.Request) (*http.Response, error) { + capturedPath = request.URL.Path + if err := json.NewDecoder(request.Body).Decode(&capturedBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + return okRESTResponse(), nil + }) + + err := client.PublishUserCustomMessage(context.Background(), CustomUserMessage{ + ToAccount: "10001", + EventID: "evt-wallet-1", + Desc: "WalletBalanceChanged", + Ext: "wallet_notice", + PayloadJSON: json.RawMessage(`{"event_id":"evt-wallet-1"}`), + }) + if err != nil { + t.Fatalf("PublishUserCustomMessage failed: %v", err) + } + if capturedPath != "/"+sendC2CMsgCommand { + t.Fatalf("unexpected command path: %s", capturedPath) + } + if capturedBody["To_Account"] != "10001" || capturedBody["SyncOtherMachine"].(float64) != 2 { + t.Fatalf("unexpected c2c target: %+v", capturedBody) + } + if capturedBody["From_Account"] != "administrator" { + t.Fatalf("unexpected c2c sender: %+v", capturedBody) + } + body, ok := capturedBody["MsgBody"].([]any) + if !ok || len(body) != 1 { + t.Fatalf("unexpected msg body: %+v", capturedBody["MsgBody"]) + } + element := body[0].(map[string]any) + if element["MsgType"] != "TIMCustomElem" { + t.Fatalf("unexpected msg type: %+v", element) + } + content := element["MsgContent"].(map[string]any) + if content["Desc"] != "WalletBalanceChanged" || content["Ext"] != "wallet_notice" || content["Data"] != `{"event_id":"evt-wallet-1"}` { + t.Fatalf("unexpected custom content: %+v", content) + } +} + // TestRESTClientDestroyGroupBuildsTencentRequest 锁定临时群清理的 REST 请求。 func TestRESTClientDestroyGroupBuildsTencentRequest(t *testing.T) { var capturedPath string diff --git a/scripts/apply-local-mysql-initdb.sh b/scripts/apply-local-mysql-initdb.sh index bc36b59f..acaf36b5 100755 --- a/scripts/apply-local-mysql-initdb.sh +++ b/scripts/apply-local-mysql-initdb.sh @@ -20,6 +20,7 @@ SQL_FILES=( "deploy/mysql/initdb/007_resource_group_wallet_asset_items.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" "deploy/mysql/initdb/999_local_grants.sql" ) diff --git a/scripts/print-compose-addresses.sh b/scripts/print-compose-addresses.sh index 3952f655..17d26e11 100755 --- a/scripts/print-compose-addresses.sh +++ b/scripts/print-compose-addresses.sh @@ -98,6 +98,8 @@ print_row "wallet-service" "gRPC" "grpc" "wallet-service" "13004" "13004" print_row "user-service" "gRPC" "grpc" "user-service" "13005" "13005" print_row "activity-service" "gRPC" "grpc" "activity-service" "13006" "13006" print_row "cron-service" "gRPC" "grpc" "cron-service" "13007" "13007" +print_row "game-service" "gRPC" "grpc" "game-service" "13008" "13008" +print_row "notice-service" "gRPC" "grpc" "notice-service" "13009" "13009" print_row "mysql" "MySQL" "tcp" "mysql" "3306" "23306" print_row "redis" "Redis" "tcp" "redis" "6379" "13379" diff --git a/server/admin/cmd/server/main.go b/server/admin/cmd/server/main.go index 4ee70123..8765bcf6 100644 --- a/server/admin/cmd/server/main.go +++ b/server/admin/cmd/server/main.go @@ -37,7 +37,6 @@ import ( hostorgmodule "hyapp-admin-server/internal/modules/hostorg" jobmodule "hyapp-admin-server/internal/modules/job" menumodule "hyapp-admin-server/internal/modules/menu" - notificationmodule "hyapp-admin-server/internal/modules/notification" paymentmodule "hyapp-admin-server/internal/modules/payment" rbacmodule "hyapp-admin-server/internal/modules/rbac" registrationrewardmodule "hyapp-admin-server/internal/modules/registrationreward" @@ -218,7 +217,6 @@ func main() { HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler), Job: jobmodule.New(store, cfg, auditHandler), Menu: menumodule.New(store, auditHandler), - Notification: notificationmodule.New(store, auditHandler), Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler), RBAC: rbacmodule.New(store, auditHandler), RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), diff --git a/server/admin/docs/权限管理.md b/server/admin/docs/权限管理.md index 82a5a1b9..e1a27af1 100644 --- a/server/admin/docs/权限管理.md +++ b/server/admin/docs/权限管理.md @@ -186,23 +186,6 @@ | 导出 | `log:export` | 未实现 | 新增 | | 查看详情 | `log:view` | 未实现 | 新增详情抽屉 | -### 通知中心 - -| 权限码 | kind | 控制范围 | 后端接口 | -| --- | --- | --- | --- | -| `notification:view` | `menu` | 通知中心页面、通知列表 | `GET /api/v1/notifications` | -| `notification:read` | `button` | 单条标记已读 | `PATCH /api/v1/notifications/:id/read` | -| `notification:read-all` | `button` | 批量已读 | 待新增 | -| `notification:delete` | `button` | 删除通知 | 待新增 | - -按钮: - -| 按钮 | 权限码 | 当前状态 | 目标 | -| --- | --- | --- | --- | -| 已读 | `notification:read` | 当前使用 `notification:view` | 拆独立权限 | -| 全部已读 | `notification:read-all` | 未实现 | 新增 | -| 删除 | `notification:delete` | 未实现 | 新增 | - ### 个人账户 个人账户操作不走角色授权,登录用户默认可操作自己的会话和密码。 @@ -357,8 +340,6 @@ - `user:view` - `user:status` - `log:view` -- `notification:view` -- `notification:read` ### 审计员 @@ -549,7 +530,7 @@ ## 实施顺序 -1. 拆分并补齐权限种子:新增 `permission:*`、`menu:*`、`role:create/update/delete/permission`、`user:export`、`notification:read/read-all/delete`、`log:export`。 +1. 拆分并补齐权限种子:新增 `permission:*`、`menu:*`、`role:create/update/delete/permission`、`user:export`、`log:export`。 2. 后端补权限定义和菜单管理接口。 3. 后端把现有接口从宽权限调整到按钮级权限。 4. 前端补角色授权抽屉和菜单权限页编辑能力。 diff --git a/server/admin/internal/model/models.go b/server/admin/internal/model/models.go index 5db99f9f..009a06a2 100644 --- a/server/admin/internal/model/models.go +++ b/server/admin/internal/model/models.go @@ -183,20 +183,6 @@ func (OperationLog) TableName() string { return "admin_operation_logs" } -type Notification struct { - ID uint `gorm:"primaryKey" json:"id"` - Title string `gorm:"size:120;not null" json:"title"` - Content string `gorm:"size:500" json:"content"` - Level string `gorm:"size:24;index;not null;default:info" json:"level"` - ReadAtMS *int64 `gorm:"column:read_at_ms" json:"readAtMs"` - CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"` - UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"` -} - -func (Notification) TableName() string { - return "admin_notifications" -} - type DataScope struct { ID uint `gorm:"primaryKey" json:"id"` RoleID uint `gorm:"index;not null" json:"roleId"` diff --git a/server/admin/internal/modules/coinledger/dto.go b/server/admin/internal/modules/coinledger/dto.go index 8870b334..a3349981 100644 --- a/server/admin/internal/modules/coinledger/dto.go +++ b/server/admin/internal/modules/coinledger/dto.go @@ -10,6 +10,8 @@ type coinLedgerUserDTO struct { type coinLedgerEntryDTO struct { EntryID int64 `json:"entryId"` TransactionID string `json:"transactionId"` + CommandID string `json:"commandId"` + ExternalRef string `json:"externalRef"` UserID string `json:"userId"` User coinLedgerUserDTO `json:"user"` BizType string `json:"bizType"` @@ -19,5 +21,6 @@ type coinLedgerEntryDTO struct { AvailableAfter int64 `json:"availableAfter"` CounterpartyUserID string `json:"counterpartyUserId"` RoomID string `json:"roomId"` + Metadata map[string]any `json:"metadata"` CreatedAtMS int64 `json:"createdAtMs"` } diff --git a/server/admin/internal/modules/coinledger/service.go b/server/admin/internal/modules/coinledger/service.go index fb06587c..dc24485b 100644 --- a/server/admin/internal/modules/coinledger/service.go +++ b/server/admin/internal/modules/coinledger/service.go @@ -3,6 +3,7 @@ package coinledger import ( "context" "database/sql" + "encoding/json" "fmt" "strconv" "strings" @@ -58,9 +59,9 @@ func (s *Service) ListCoinLedger(ctx context.Context, appCode string, query list } rows, err := s.walletDB.QueryContext(ctx, ` - SELECT e.entry_id, e.transaction_id, e.user_id, wt.biz_type, + SELECT e.entry_id, e.transaction_id, wt.command_id, wt.external_ref, e.user_id, wt.biz_type, e.available_delta, e.available_after, e.counterparty_user_id, - e.room_id, e.created_at_ms + e.room_id, COALESCE(CAST(wt.metadata_json AS CHAR), '{}'), e.created_at_ms FROM wallet_entries e JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id `+whereSQL+` @@ -79,22 +80,31 @@ func (s *Service) ListCoinLedger(ctx context.Context, appCode string, query list var item coinLedgerEntryDTO var userID int64 var counterpartyUserID int64 + var metadataJSON string if err := rows.Scan( &item.EntryID, &item.TransactionID, + &item.CommandID, + &item.ExternalRef, &userID, &item.BizType, &item.AvailableDelta, &item.AvailableAfter, &counterpartyUserID, &item.RoomID, + &metadataJSON, &item.CreatedAtMS, ); err != nil { return nil, 0, err } + metadata, err := parseMetadataJSON(metadataJSON) + if err != nil { + return nil, 0, err + } item.UserID = strconv.FormatInt(userID, 10) item.User = coinLedgerUserDTO{UserID: item.UserID} item.CounterpartyUserID = formatOptionalID(counterpartyUserID) + item.Metadata = metadata item.Direction = directionForDelta(item.AvailableDelta) item.Amount = absInt64(item.AvailableDelta) items = append(items, item) @@ -259,6 +269,21 @@ func formatOptionalID(value int64) string { return strconv.FormatInt(value, 10) } +func parseMetadataJSON(value string) (map[string]any, error) { + value = strings.TrimSpace(value) + if value == "" || value == "null" { + return map[string]any{}, nil + } + var metadata map[string]any + if err := json.Unmarshal([]byte(value), &metadata); err != nil { + return nil, err + } + if metadata == nil { + return map[string]any{}, nil + } + return metadata, nil +} + func offset(page int, pageSize int) int { if page < 1 { page = 1 diff --git a/server/admin/internal/modules/coinledger/service_test.go b/server/admin/internal/modules/coinledger/service_test.go index 4c12b2ed..c02cbf27 100644 --- a/server/admin/internal/modules/coinledger/service_test.go +++ b/server/admin/internal/modules/coinledger/service_test.go @@ -28,3 +28,21 @@ func TestDirectionAndAmountForDelta(t *testing.T) { t.Fatalf("income projection mismatch") } } + +func TestParseMetadataJSON(t *testing.T) { + metadata, err := parseMetadataJSON(`{"gift_name":"Rose","gift_count":2,"target_user_id":1002}`) + if err != nil { + t.Fatalf("parse metadata failed: %v", err) + } + if metadata["gift_name"] != "Rose" || metadata["gift_count"] != float64(2) || metadata["target_user_id"] != float64(1002) { + t.Fatalf("metadata mismatch: %#v", metadata) + } + + empty, err := parseMetadataJSON("null") + if err != nil { + t.Fatalf("parse empty metadata failed: %v", err) + } + if len(empty) != 0 { + t.Fatalf("empty metadata mismatch: %#v", empty) + } +} diff --git a/server/admin/internal/modules/notification/handler.go b/server/admin/internal/modules/notification/handler.go deleted file mode 100644 index b7b04bc8..00000000 --- a/server/admin/internal/modules/notification/handler.go +++ /dev/null @@ -1,64 +0,0 @@ -package notification - -import ( - "fmt" - "github.com/gin-gonic/gin" - "hyapp-admin-server/internal/modules/shared" - "hyapp-admin-server/internal/repository" - "hyapp-admin-server/internal/response" -) - -type Handler struct { - service *NotificationService - audit shared.OperationLogger -} - -func New(store *repository.Store, audit shared.OperationLogger) *Handler { - return &Handler{service: NewService(store), audit: audit} -} - -func (h *Handler) ListNotifications(c *gin.Context) { - items, err := h.service.ListNotifications() - if err != nil { - response.ServerError(c, "获取通知失败") - return - } - response.OK(c, gin.H{"items": items, "unread": unreadCount(items)}) -} - -func (h *Handler) MarkNotificationRead(c *gin.Context) { - id, ok := shared.ParseID(c, "id") - if !ok { - return - } - item, err := h.service.MarkNotificationRead(id) - if err != nil { - response.BadRequest(c, err.Error()) - return - } - shared.OperationLog(c, h.audit, "mark-notification-read", "admin_notifications", "success", fmt.Sprintf("notification_id=%d", id)) - response.OK(c, item) -} - -func (h *Handler) MarkAllNotificationsRead(c *gin.Context) { - updated, err := h.service.MarkAllNotificationsRead() - if err != nil { - response.ServerError(c, "标记通知失败") - return - } - shared.OperationLog(c, h.audit, "mark-all-notifications-read", "admin_notifications", "success", fmt.Sprintf("%d notifications", updated)) - response.OK(c, gin.H{"updated": updated}) -} - -func (h *Handler) DeleteNotification(c *gin.Context) { - id, ok := shared.ParseID(c, "id") - if !ok { - return - } - if err := h.service.DeleteNotification(id); err != nil { - response.BadRequest(c, err.Error()) - return - } - shared.OperationLog(c, h.audit, "delete-notification", "admin_notifications", "success", fmt.Sprintf("notification_id=%d", id)) - response.OK(c, gin.H{"deleted": true}) -} diff --git a/server/admin/internal/modules/notification/response.go b/server/admin/internal/modules/notification/response.go deleted file mode 100644 index b261526e..00000000 --- a/server/admin/internal/modules/notification/response.go +++ /dev/null @@ -1,13 +0,0 @@ -package notification - -import "hyapp-admin-server/internal/model" - -func unreadCount(items []model.Notification) int { - count := 0 - for _, item := range items { - if item.ReadAtMS == nil { - count++ - } - } - return count -} diff --git a/server/admin/internal/modules/notification/routes.go b/server/admin/internal/modules/notification/routes.go deleted file mode 100644 index 0650c8ca..00000000 --- a/server/admin/internal/modules/notification/routes.go +++ /dev/null @@ -1,14 +0,0 @@ -package notification - -import ( - "hyapp-admin-server/internal/middleware" - - "github.com/gin-gonic/gin" -) - -func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { - protected.GET("/notifications", middleware.RequirePermission("notification:view"), h.ListNotifications) - protected.PATCH("/notifications/read-all", middleware.RequirePermission("notification:read-all"), h.MarkAllNotificationsRead) - protected.PATCH("/notifications/:id/read", middleware.RequirePermission("notification:read"), h.MarkNotificationRead) - protected.DELETE("/notifications/:id", middleware.RequirePermission("notification:delete"), h.DeleteNotification) -} diff --git a/server/admin/internal/modules/notification/service.go b/server/admin/internal/modules/notification/service.go deleted file mode 100644 index 39fd2f75..00000000 --- a/server/admin/internal/modules/notification/service.go +++ /dev/null @@ -1,30 +0,0 @@ -package notification - -import ( - "hyapp-admin-server/internal/model" - "hyapp-admin-server/internal/repository" -) - -type NotificationService struct { - store *repository.Store -} - -func NewService(store *repository.Store) *NotificationService { - return &NotificationService{store: store} -} - -func (s *NotificationService) ListNotifications() ([]model.Notification, error) { - return s.store.ListNotifications() -} - -func (s *NotificationService) MarkNotificationRead(id uint) (*model.Notification, error) { - return s.store.MarkNotificationRead(id) -} - -func (s *NotificationService) MarkAllNotificationsRead() (int64, error) { - return s.store.MarkAllNotificationsRead() -} - -func (s *NotificationService) DeleteNotification(id uint) error { - return s.store.DeleteNotification(id) -} diff --git a/server/admin/internal/modules/upload/handler.go b/server/admin/internal/modules/upload/handler.go index aec6076e..c3f27e2c 100644 --- a/server/admin/internal/modules/upload/handler.go +++ b/server/admin/internal/modules/upload/handler.go @@ -20,24 +20,19 @@ import ( ) const ( - uploadMultipartOverheadLimit = 1 << 20 - uploadSniffBytes = 512 - imageUploadMaxBytes int64 = 5 << 20 - fileUploadMaxBytes int64 = 20 << 20 + uploadSniffBytes = 512 ) var ( imageUploadPolicy = uploadPolicy{ kind: "images", - maxBytes: imageUploadMaxBytes, requireImage: true, allowedExtensions: map[string]struct{}{ ".jpg": {}, ".jpeg": {}, ".png": {}, ".webp": {}, ".svga": {}, ".pag": {}, }, } fileUploadPolicy = uploadPolicy{ - kind: "files", - maxBytes: fileUploadMaxBytes, + kind: "files", } ) @@ -72,7 +67,7 @@ func (h *Handler) uploadObject(c *gin.Context, policy uploadPolicy) { response.ServerError(c, "文件上传未配置") return } - file, header, ok := parseUploadFile(c, policy.maxBytes) + file, header, ok := parseUploadFile(c) if !ok { return } @@ -83,7 +78,7 @@ func (h *Handler) uploadObject(c *gin.Context, policy uploadPolicy) { } }() - if header.Size <= 0 || header.Size > policy.maxBytes { + if header.Size <= 0 { response.BadRequest(c, "文件大小不正确") return } @@ -117,8 +112,7 @@ func (h *Handler) uploadObject(c *gin.Context, policy uploadPolicy) { }) } -func parseUploadFile(c *gin.Context, maxBytes int64) (multipart.File, *multipart.FileHeader, bool) { - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes+uploadMultipartOverheadLimit) +func parseUploadFile(c *gin.Context) (multipart.File, *multipart.FileHeader, bool) { if err := c.Request.ParseMultipartForm(1 << 20); err != nil { response.BadRequest(c, "文件参数不正确") return nil, nil, false diff --git a/server/admin/internal/modules/upload/handler_test.go b/server/admin/internal/modules/upload/handler_test.go index b87b208f..464ea163 100644 --- a/server/admin/internal/modules/upload/handler_test.go +++ b/server/admin/internal/modules/upload/handler_test.go @@ -118,6 +118,26 @@ func TestUploadImageAllowsAnimatedAssetFormats(t *testing.T) { } } +func TestUploadImageAllowsPayloadOverPreviousImageLimit(t *testing.T) { + uploader := &fakeUploader{} + handler := New(uploader, "admin", fakeAudit{}) + router := ginRouter(handler) + payload := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, bytes.Repeat([]byte{1}, (5<<20)+1)...) + body, contentType := multipartUploadBody(t, "file", "large.png", "image/png", payload) + req := httptest.NewRequest(http.MethodPost, "/upload-image", body) + req.Header.Set("Content-Type", contentType) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + if uploader.sizeBytes != int64(len(payload)) { + t.Fatalf("unexpected upload size: %d", uploader.sizeBytes) + } +} + func ginRouter(handler *Handler) http.Handler { router := gin.New() router.Use(func(c *gin.Context) { diff --git a/server/admin/internal/modules/upload/request.go b/server/admin/internal/modules/upload/request.go index f71e0b5b..a6c0e423 100644 --- a/server/admin/internal/modules/upload/request.go +++ b/server/admin/internal/modules/upload/request.go @@ -3,7 +3,6 @@ package upload type uploadPolicy struct { allowedExtensions map[string]struct{} kind string - maxBytes int64 requireImage bool } diff --git a/server/admin/internal/repository/dashboard_repository.go b/server/admin/internal/repository/dashboard_repository.go index 1d24c6e1..50c46098 100644 --- a/server/admin/internal/repository/dashboard_repository.go +++ b/server/admin/internal/repository/dashboard_repository.go @@ -20,7 +20,7 @@ func (s *Store) DashboardOverview() (*DashboardOverview, error) { if err := s.db.Model(&model.User{}).Where("status = ?", model.UserStatusDisabled).Count(&disabledUsers).Error; err != nil { return nil, err } - var rolesTotal, menuTotal, logsToday, unreadNotifications int64 + var rolesTotal, menuTotal, logsToday int64 if err := s.db.Model(&model.Role{}).Count(&rolesTotal).Error; err != nil { return nil, err } @@ -32,39 +32,19 @@ func (s *Store) DashboardOverview() (*DashboardOverview, error) { if err := s.db.Model(&model.OperationLog{}).Where("created_at_ms >= ?", todayMS).Count(&logsToday).Error; err != nil { return nil, err } - if err := s.db.Model(&model.Notification{}).Where("read_at_ms IS NULL").Count(&unreadNotifications).Error; err != nil { - return nil, err - } - - var notifications []model.Notification - if err := s.db.Where("read_at_ms IS NULL").Order("created_at_ms DESC").Limit(3).Find(¬ifications).Error; err != nil { - return nil, err - } - alerts := make([]map[string]string, 0, len(notifications)) - for _, item := range notifications { - alerts = append(alerts, map[string]string{ - "type": item.Level, - "name": item.Title, - "desc": item.Content, - "time": time.UnixMilli(item.CreatedAtMS).UTC().Format("15:04"), - }) - } return &DashboardOverview{ - UsersTotal: int(usersTotal), - ActiveUsers: int(activeUsers), - LockedUsers: int(lockedUsers), - DisabledUsers: int(disabledUsers), - RolesTotal: int(rolesTotal), - MenuTotal: int(menuTotal), - LogsToday: int(logsToday), - UnreadNotifications: int(unreadNotifications), + UsersTotal: int(usersTotal), + ActiveUsers: int(activeUsers), + LockedUsers: int(lockedUsers), + DisabledUsers: int(disabledUsers), + RolesTotal: int(rolesTotal), + MenuTotal: int(menuTotal), + LogsToday: int(logsToday), Series: map[string][]int{ - "users": {int(usersTotal), int(activeUsers), int(lockedUsers), int(disabledUsers)}, - "operations": {int(logsToday)}, - "notifications": {int(unreadNotifications)}, + "users": {int(usersTotal), int(activeUsers), int(lockedUsers), int(disabledUsers)}, + "operations": {int(logsToday)}, }, - Alerts: alerts, UpdatedAtMS: now.UnixMilli(), }, nil } diff --git a/server/admin/internal/repository/notification_repository.go b/server/admin/internal/repository/notification_repository.go deleted file mode 100644 index 59f002b3..00000000 --- a/server/admin/internal/repository/notification_repository.go +++ /dev/null @@ -1,34 +0,0 @@ -package repository - -import ( - "hyapp-admin-server/internal/model" - "time" -) - -func (s *Store) ListNotifications() ([]model.Notification, error) { - var items []model.Notification - err := s.db.Order("created_at_ms DESC").Find(&items).Error - return items, err -} - -func (s *Store) MarkNotificationRead(id uint) (*model.Notification, error) { - nowMS := time.Now().UTC().UnixMilli() - if err := s.db.Model(&model.Notification{}).Where("id = ?", id).Update("read_at_ms", &nowMS).Error; err != nil { - return nil, err - } - var item model.Notification - if err := s.db.First(&item, id).Error; err != nil { - return nil, err - } - return &item, nil -} - -func (s *Store) MarkAllNotificationsRead() (int64, error) { - nowMS := time.Now().UTC().UnixMilli() - result := s.db.Model(&model.Notification{}).Where("read_at_ms IS NULL").Update("read_at_ms", &nowMS) - return result.RowsAffected, result.Error -} - -func (s *Store) DeleteNotification(id uint) error { - return s.db.Delete(&model.Notification{}, id).Error -} diff --git a/server/admin/internal/repository/repository.go b/server/admin/internal/repository/repository.go index 70b20667..7c5b3a96 100644 --- a/server/admin/internal/repository/repository.go +++ b/server/admin/internal/repository/repository.go @@ -28,17 +28,15 @@ type SearchResult struct { } type DashboardOverview struct { - UsersTotal int `json:"usersTotal"` - ActiveUsers int `json:"activeUsers"` - LockedUsers int `json:"lockedUsers"` - DisabledUsers int `json:"disabledUsers"` - RolesTotal int `json:"rolesTotal"` - MenuTotal int `json:"menuTotal"` - LogsToday int `json:"logsToday"` - UnreadNotifications int `json:"unreadNotifications"` - Alerts []map[string]string `json:"alerts"` - Series map[string][]int `json:"series"` - UpdatedAtMS int64 `json:"updatedAtMs"` + UsersTotal int `json:"usersTotal"` + ActiveUsers int `json:"activeUsers"` + LockedUsers int `json:"lockedUsers"` + DisabledUsers int `json:"disabledUsers"` + RolesTotal int `json:"rolesTotal"` + MenuTotal int `json:"menuTotal"` + LogsToday int `json:"logsToday"` + Series map[string][]int `json:"series"` + UpdatedAtMS int64 `json:"updatedAtMs"` } type MenuSortItem struct { @@ -70,7 +68,6 @@ func (s *Store) AutoMigrate() error { &model.RefreshToken{}, &model.LoginLog{}, &model.OperationLog{}, - &model.Notification{}, &model.DataScope{}, &model.AdminJob{}, ) @@ -104,8 +101,5 @@ func (s *Store) Seed(cfg config.Config) error { if err := s.seedUsers(cfg); err != nil { return err } - if !cfg.Bootstrap.SeedDemoData { - return nil - } - return s.seedNotifications() + return nil } diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index e7a95b78..7b011c64 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -98,18 +98,17 @@ var defaultPermissions = []model.Permission{ {Name: "菜单显隐", Code: "menu:visible", Kind: "button"}, {Name: "日志查看", Code: "log:view", Kind: "menu"}, {Name: "日志导出", Code: "log:export", Kind: "button"}, - {Name: "通知查看", Code: "notification:view", Kind: "menu"}, - {Name: "通知已读", Code: "notification:read", Kind: "button"}, - {Name: "通知全部已读", Code: "notification:read-all", Kind: "button"}, - {Name: "通知删除", Code: "notification:delete", Kind: "button"}, {Name: "任务查看", Code: "job:view", Kind: "menu"}, {Name: "任务取消", Code: "job:cancel", Kind: "button"}, {Name: "导出任务创建", Code: "export:create", Kind: "button"}, {Name: "文件上传", Code: "upload:create", Kind: "button"}, } -var deprecatedPermissionCodes = []string{"service:view", "service:create", "service:update", "service:operate"} -var deprecatedMenuCodes = []string{"services"} +var deprecatedPermissionCodes = []string{ + "service:view", "service:create", "service:update", "service:operate", + "notification:view", "notification:read", "notification:read-all", "notification:delete", +} +var deprecatedMenuCodes = []string{"services", "notifications"} func (s *Store) seedPermissions() error { return s.syncDefaultPermissions(s.db) @@ -131,7 +130,6 @@ func (s *Store) seedMenus() error { {Title: "总览", Code: "overview", Path: "/overview", Icon: "dashboard", PermissionCode: "overview:view", Sort: 10, Visible: true}, {Title: "后台设置", Code: "system", Path: "", Icon: "settings", PermissionCode: "", Sort: 30, Visible: true}, {Title: "日志审计", Code: "logs", Path: "", Icon: "history", PermissionCode: "", Sort: 40, Visible: false}, - {Title: "通知中心", Code: "notifications", Path: "/notifications", Icon: "notifications", PermissionCode: "notification:view", Sort: 50, Visible: true}, {Title: "用户管理", Code: "app-users", Path: "", Icon: "users", PermissionCode: "", Sort: 60, Visible: true}, {Title: "房间管理", Code: "rooms", Path: "", Icon: "room", PermissionCode: "", Sort: 65, Visible: true}, {Title: "APP配置", Code: "app-config", Path: "", Icon: "settings", PermissionCode: "", Sort: 66, Visible: true}, @@ -298,22 +296,6 @@ func (s *Store) seedUsers(cfg config.Config) error { return s.db.Model(&user).Association("Roles").Replace([]model.Role{role}) } -func (s *Store) seedNotifications() error { - var count int64 - if err := s.db.Model(&model.Notification{}).Count(&count).Error; err != nil { - return err - } - if count > 0 { - return nil - } - items := []model.Notification{ - {Title: "权限种子已同步", Content: "默认权限和菜单已写入后台库", Level: "info"}, - {Title: "审计日志已启用", Content: "写操作会记录操作日志", Level: "info"}, - {Title: "导出任务已启用", Content: "任务中心会处理后台导出任务", Level: "info"}, - } - return s.db.Create(&items).Error -} - func (s *Store) pruneDeprecatedDefaults() error { return s.db.Transaction(func(tx *gorm.DB) error { if err := tx.Where("code IN ? OR permission_code IN ?", deprecatedMenuCodes, deprecatedPermissionCodes).Delete(&model.Menu{}).Error; err != nil { @@ -458,7 +440,6 @@ func defaultRolePermissionCodes(code string) []string { "game:view", "game:create", "game:update", "game:status", "daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status", "log:view", - "notification:view", "notification:read", "job:view", "job:cancel", "export:create", "upload:create", } @@ -492,7 +473,6 @@ func defaultRolePermissionCodes(code string) []string { "permission:view", "menu:view", "log:view", - "notification:view", "job:view", } default: diff --git a/server/admin/internal/router/router.go b/server/admin/internal/router/router.go index 1e0ef2d2..de851c0f 100644 --- a/server/admin/internal/router/router.go +++ b/server/admin/internal/router/router.go @@ -18,7 +18,6 @@ import ( "hyapp-admin-server/internal/modules/hostorg" "hyapp-admin-server/internal/modules/job" "hyapp-admin-server/internal/modules/menu" - "hyapp-admin-server/internal/modules/notification" "hyapp-admin-server/internal/modules/payment" "hyapp-admin-server/internal/modules/rbac" "hyapp-admin-server/internal/modules/registrationreward" @@ -48,7 +47,6 @@ type Handlers struct { HostOrg *hostorg.Handler Job *job.Handler Menu *menu.Handler - Notification *notification.Handler Payment *payment.Handler RBAC *rbac.Handler RegistrationReward *registrationreward.Handler @@ -86,7 +84,6 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine { dashboard.RegisterRoutes(protected, h.Dashboard) hostorg.RegisterRoutes(protected, h.HostOrg) audit.RegisterRoutes(protected, h.Audit) - notification.RegisterRoutes(protected, h.Notification) payment.RegisterRoutes(protected, h.Payment) job.RegisterRoutes(protected, h.Job) upload.RegisterRoutes(protected, h.Upload) diff --git a/server/admin/migrations/008_remove_notification_center.sql b/server/admin/migrations/008_remove_notification_center.sql new file mode 100644 index 00000000..42db1ace --- /dev/null +++ b/server/admin/migrations/008_remove_notification_center.sql @@ -0,0 +1,13 @@ +DELETE arp +FROM admin_role_permissions arp +JOIN admin_permissions p ON p.id = arp.permission_id +WHERE p.code IN ('notification:view', 'notification:read', 'notification:read-all', 'notification:delete'); + +DELETE FROM admin_menus +WHERE code = 'notifications' + OR permission_code IN ('notification:view', 'notification:read', 'notification:read-all', 'notification:delete'); + +DELETE FROM admin_permissions +WHERE code IN ('notification:view', 'notification:read', 'notification:read-all', 'notification:delete'); + +DROP TABLE IF EXISTS admin_notifications; diff --git a/services/notice-service/Dockerfile b/services/notice-service/Dockerfile new file mode 100644 index 00000000..2f6e1146 --- /dev/null +++ b/services/notice-service/Dockerfile @@ -0,0 +1,36 @@ +FROM golang:1.26.3-alpine AS builder + +WORKDIR /src + +# Module proxy is a build-time contract so Docker builds can recover from transient proxy EOFs. +ARG GOPROXY=https://goproxy.cn|https://proxy.golang.org|direct +ARG GOSUMDB=sum.golang.org +ENV GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB} + +COPY go.mod go.sum ./ +COPY api/go.mod api/go.sum ./api/ +RUN GOWORK=off go mod download + +COPY . . + +RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server ./services/notice-service/cmd/server +RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/grpc-health-probe ./cmd/grpc-health-probe + +FROM alpine:3.20 + +ENV TZ=UTC + +WORKDIR /app + +RUN apk add --no-cache ca-certificates && adduser -D -g '' appuser + +COPY --from=builder /out/server /app/server +COPY --from=builder /out/grpc-health-probe /app/grpc-health-probe +COPY services/notice-service/configs/config.docker.yaml /app/config.yaml + +USER appuser + +EXPOSE 13009 13109 + +ENTRYPOINT ["/app/server"] +CMD ["-config", "/app/config.yaml"] diff --git a/services/notice-service/cmd/server/main.go b/services/notice-service/cmd/server/main.go new file mode 100644 index 00000000..0ce323c6 --- /dev/null +++ b/services/notice-service/cmd/server/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "flag" + "log" + "os" + "os/signal" + "syscall" + + "hyapp/pkg/logx" + "hyapp/services/notice-service/internal/app" + "hyapp/services/notice-service/internal/config" +) + +// main 启动 notice-service;当前只暴露 health gRPC,业务投递由模块化 worker 执行。 +func main() { + configPath := flag.String("config", "services/notice-service/configs/config.yaml", "path to notice-service config") + flag.Parse() + + cfg, err := config.Load(*configPath) + if err != nil { + log.Fatal(err) + } + if err := logx.Init(cfg.Log.WithRuntimeFields(cfg.ServiceName, cfg.Environment, cfg.NodeID)); err != nil { + log.Fatal(err) + } + + application, err := app.New(cfg) + if err != nil { + fatalRuntime("app_new_failed", err) + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + errCh := make(chan error, 1) + go func() { + errCh <- application.Run() + }() + + select { + case <-ctx.Done(): + application.Close() + case err := <-errCh: + if err != nil { + fatalRuntime("service_run_failed", err) + } + } +} + +func fatalRuntime(msg string, err error) { + logx.Error(context.Background(), msg, err) + os.Exit(1) +} diff --git a/services/notice-service/configs/config.docker.yaml b/services/notice-service/configs/config.docker.yaml new file mode 100644 index 00000000..f0ef23a2 --- /dev/null +++ b/services/notice-service/configs/config.docker.yaml @@ -0,0 +1,32 @@ +service_name: notice-service +node_id: notice-docker +environment: docker +grpc_addr: ":13009" +health_http_addr: ":13109" +mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_notice?parseTime=true&charset=utf8mb4&loc=UTC" +wallet_database: "hyapp_wallet" +mysql_auto_migrate: false + +tencent_im: + enabled: false + sdk_app_id: 0 + secret_key: "" + admin_identifier: "administrator" + admin_user_sig_ttl: 24h + endpoint: "console.tim.qq.com" + request_timeout: 5s + +wallet_notice_worker: + enabled: false + poll_interval: 1s + batch_size: 100 + lock_ttl: 30s + publish_timeout: 3s + max_retry_count: 10 + initial_backoff: 5s + max_backoff: 5m + +log: + level: info + format: json + max_payload_bytes: 2048 diff --git a/services/notice-service/configs/config.tencent.example.yaml b/services/notice-service/configs/config.tencent.example.yaml new file mode 100644 index 00000000..e0ded1a1 --- /dev/null +++ b/services/notice-service/configs/config.tencent.example.yaml @@ -0,0 +1,32 @@ +service_name: notice-service +node_id: notice-prod-a +environment: prod +grpc_addr: ":13009" +health_http_addr: ":13109" +mysql_dsn: "hyapp:${MYSQL_PASSWORD}@tcp(${MYSQL_HOST}:3306)/hyapp_notice?parseTime=true&charset=utf8mb4&loc=UTC" +wallet_database: "hyapp_wallet" +mysql_auto_migrate: false + +tencent_im: + enabled: true + sdk_app_id: 1400000000 + secret_key: "${TENCENT_IM_SECRET_KEY}" + admin_identifier: "administrator" + admin_user_sig_ttl: 24h + endpoint: "console.tim.qq.com" + request_timeout: 5s + +wallet_notice_worker: + enabled: true + poll_interval: 500ms + batch_size: 100 + lock_ttl: 30s + publish_timeout: 3s + max_retry_count: 10 + initial_backoff: 5s + max_backoff: 5m + +log: + level: info + format: json + max_payload_bytes: 2048 diff --git a/services/notice-service/configs/config.yaml b/services/notice-service/configs/config.yaml new file mode 100644 index 00000000..e2966828 --- /dev/null +++ b/services/notice-service/configs/config.yaml @@ -0,0 +1,32 @@ +service_name: notice-service +node_id: notice-local +environment: local +grpc_addr: ":13009" +health_http_addr: ":13109" +mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_notice?parseTime=true&charset=utf8mb4&loc=UTC" +wallet_database: "hyapp_wallet" +mysql_auto_migrate: false + +tencent_im: + enabled: false + sdk_app_id: 0 + secret_key: "" + admin_identifier: "administrator" + admin_user_sig_ttl: 24h + endpoint: "console.tim.qq.com" + request_timeout: 5s + +wallet_notice_worker: + enabled: false + poll_interval: 1s + batch_size: 100 + lock_ttl: 30s + publish_timeout: 3s + max_retry_count: 10 + initial_backoff: 5s + max_backoff: 5m + +log: + level: info + format: json + max_payload_bytes: 2048 diff --git a/services/notice-service/deploy/mysql/initdb/001_notice_service.sql b/services/notice-service/deploy/mysql/initdb/001_notice_service.sql new file mode 100644 index 00000000..2488d555 --- /dev/null +++ b/services/notice-service/deploy/mysql/initdb/001_notice_service.sql @@ -0,0 +1,28 @@ +CREATE DATABASE IF NOT EXISTS hyapp_notice + DEFAULT CHARACTER SET utf8mb4 + DEFAULT COLLATE utf8mb4_unicode_ci; + +USE hyapp_notice; + +CREATE TABLE IF NOT EXISTS notice_delivery_events ( + source_name VARCHAR(64) NOT NULL COMMENT '事件源表或服务名,例如 wallet_outbox', + app_code VARCHAR(32) NOT NULL COMMENT '租户编码,必须和源 outbox 保持一致', + source_event_id VARCHAR(128) NOT NULL COMMENT '源事件幂等键', + channel VARCHAR(64) NOT NULL COMMENT '投递通道,例如 tencent_im_c2c', + notice_type VARCHAR(64) NOT NULL COMMENT '业务通知类型,例如 WalletBalanceChanged', + target_user_id BIGINT NOT NULL COMMENT '私有通知目标用户', + status VARCHAR(32) NOT NULL COMMENT 'delivering/delivered/retryable/failed', + retry_count INT NOT NULL DEFAULT 0 COMMENT '已尝试次数,达到上限后进入 failed', + locked_by VARCHAR(128) NOT NULL DEFAULT '' COMMENT '当前抢占 worker', + lock_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT '抢占锁过期时间,UTC epoch ms', + next_retry_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '下一次允许重试时间,UTC epoch ms', + payload_json JSON NOT NULL COMMENT '发送给客户端的最终负载快照', + last_error TEXT NULL COMMENT '最后一次投递失败原因,限制在应用层截断', + delivered_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '成功投递时间,UTC epoch ms', + created_at_ms BIGINT NOT NULL COMMENT 'notice 位点创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT 'notice 位点更新时间,UTC epoch ms', + PRIMARY KEY (source_name, app_code, source_event_id, channel), + KEY idx_notice_delivery_pending (status, next_retry_at_ms, lock_until_ms, updated_at_ms), + KEY idx_notice_delivery_target (app_code, target_user_id, updated_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='notice-service 外部通知投递位点和死信表'; diff --git a/services/notice-service/internal/app/app.go b/services/notice-service/internal/app/app.go new file mode 100644 index 00000000..69becec5 --- /dev/null +++ b/services/notice-service/internal/app/app.go @@ -0,0 +1,173 @@ +// Package app wires notice-service modules, health endpoints and background workers. +package app + +import ( + "context" + "errors" + "net" + "sync" + "time" + + "google.golang.org/grpc" + healthgrpc "google.golang.org/grpc/health/grpc_health_v1" + "hyapp/pkg/grpchealth" + "hyapp/pkg/grpcshutdown" + "hyapp/pkg/healthhttp" + "hyapp/pkg/logx" + "hyapp/pkg/tencentim" + "hyapp/services/notice-service/internal/config" + "hyapp/services/notice-service/internal/modules/walletnotice" + mysqlplatform "hyapp/services/notice-service/internal/platform/mysql" +) + +// App owns the notice-service process lifecycle. +type App struct { + server *grpc.Server + listener net.Listener + health *grpchealth.ServingChecker + healthHTTP *healthhttp.Server + store *mysqlplatform.Store + walletNoticeService *walletnotice.Service + walletWorkerOptions walletnotice.WalletNoticeWorkerOptions + walletWorkerEnabled bool + workerCtx context.Context + workerCancel context.CancelFunc + workerWG sync.WaitGroup + closeOnce sync.Once +} + +// New initializes MySQL, module repositories and optional Tencent IM publisher. +func New(cfg config.Config) (*App, error) { + startupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + store, err := mysqlplatform.Open(startupCtx, cfg.MySQLDSN) + if err != nil { + return nil, err + } + listener, err := net.Listen("tcp", cfg.GRPCAddr) + if err != nil { + _ = store.Close() + return nil, err + } + + walletRepo, err := walletnotice.NewMySQLRepository(store.DB, cfg.WalletDatabase) + if err != nil { + _ = listener.Close() + _ = store.Close() + return nil, err + } + var publisher walletnotice.UserMessagePublisher + if cfg.TencentIM.Enabled { + tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig()) + if err != nil { + _ = listener.Close() + _ = store.Close() + return nil, err + } + publisher = tencentClient + } + if cfg.WalletNoticeWorker.Enabled && publisher == nil { + _ = listener.Close() + _ = store.Close() + return nil, errors.New("wallet notice worker requires tencent_im.enabled") + } + + server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("notice-service"))) + health := grpchealth.NewServingChecker("notice-service", grpchealth.Dependency{ + Name: "mysql", + Check: store.Ping, + }) + healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health)) + healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health) + if err != nil { + _ = listener.Close() + _ = store.Close() + return nil, err + } + + workerCtx, workerCancel := context.WithCancel(context.Background()) + return &App{ + server: server, + listener: listener, + health: health, + healthHTTP: healthHTTP, + store: store, + walletNoticeService: walletnotice.New(walletnotice.Config{NodeID: cfg.NodeID}, walletRepo, publisher), + walletWorkerOptions: walletNoticeWorkerOptions(cfg.WalletNoticeWorker), + walletWorkerEnabled: cfg.WalletNoticeWorker.Enabled, + workerCtx: workerCtx, + workerCancel: workerCancel, + }, nil +} + +// Run starts health serving, optional workers and the gRPC health endpoint. +func (a *App) Run() error { + a.runHealthHTTP() + a.health.MarkServing() + defer func() { + if a.workerCancel != nil { + a.workerCancel() + } + a.workerWG.Wait() + a.health.MarkStopped() + }() + if a.walletWorkerEnabled && a.walletNoticeService != nil { + a.workerWG.Go(func() { + a.walletNoticeService.RunWalletBalanceWorker(a.workerCtx, a.walletWorkerOptions) + }) + } + err := a.server.Serve(a.listener) + if errors.Is(err, grpc.ErrServerStopped) { + return nil + } + return err +} + +// Close drains workers before closing gRPC and MySQL resources. +func (a *App) Close() { + a.closeOnce.Do(func() { + a.health.MarkDraining() + if a.workerCancel != nil { + a.workerCancel() + } + a.workerWG.Wait() + grpcshutdown.GracefulStop(a.server, 15*time.Second) + a.closeHealthHTTP() + if a.store != nil { + _ = a.store.Close() + } + }) +} + +func (a *App) runHealthHTTP() { + if a.healthHTTP == nil { + return + } + go func() { + if err := a.healthHTTP.Run(); err != nil { + logx.Error(context.Background(), "health_http_run_failed", err) + } + }() +} + +func (a *App) closeHealthHTTP() { + if a.healthHTTP == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = a.healthHTTP.Close(ctx) +} + +func walletNoticeWorkerOptions(cfg config.WalletNoticeWorkerConfig) walletnotice.WalletNoticeWorkerOptions { + return walletnotice.WalletNoticeWorkerOptions{ + PollInterval: cfg.PollInterval, + BatchSize: cfg.BatchSize, + LockTTL: cfg.LockTTL, + PublishTimeout: cfg.PublishTimeout, + MaxRetryCount: cfg.MaxRetryCount, + InitialBackoff: cfg.InitialBackoff, + MaxBackoff: cfg.MaxBackoff, + } +} diff --git a/services/notice-service/internal/config/config.go b/services/notice-service/internal/config/config.go new file mode 100644 index 00000000..e18d51e7 --- /dev/null +++ b/services/notice-service/internal/config/config.go @@ -0,0 +1,181 @@ +package config + +import ( + "net/http" + "strings" + "time" + + "hyapp/pkg/configx" + "hyapp/pkg/logx" + "hyapp/pkg/tencentim" +) + +// Config 描述 notice-service 启动配置。 +type Config struct { + ServiceName string `yaml:"service_name"` + NodeID string `yaml:"node_id"` + Environment string `yaml:"environment"` + GRPCAddr string `yaml:"grpc_addr"` + // HealthHTTPAddr 给 CLB/发布脚本探活使用,不承载业务 HTTP。 + HealthHTTPAddr string `yaml:"health_http_addr"` + // MySQLDSN 是 notice-service 自己的投递位点和死信状态库。 + MySQLDSN string `yaml:"mysql_dsn"` + // WalletDatabase 是 wallet_outbox 所在库名;notice 只把它当 append-only 事实源读取。 + WalletDatabase string `yaml:"wallet_database"` + // TencentIM 是私有 IM 投递通道配置;本地默认关闭,避免误发真实消息。 + TencentIM TencentIMConfig `yaml:"tencent_im"` + // WalletNoticeWorker 消费 wallet_outbox 中的 WalletBalanceChanged 事件。 + WalletNoticeWorker WalletNoticeWorkerConfig `yaml:"wallet_notice_worker"` + MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"` + Log logx.Config `yaml:"log"` +} + +// TencentIMConfig 描述服务端调用腾讯云 IM REST API 的配置。 +type TencentIMConfig struct { + Enabled bool `yaml:"enabled"` + SDKAppID int64 `yaml:"sdk_app_id"` + SecretKey string `yaml:"secret_key"` + AdminIdentifier string `yaml:"admin_identifier"` + AdminUserSigTTL time.Duration `yaml:"admin_user_sig_ttl"` + Endpoint string `yaml:"endpoint"` + RequestTimeout time.Duration `yaml:"request_timeout"` +} + +// RESTConfig converts YAML config into shared Tencent IM REST config. +func (c TencentIMConfig) RESTConfig() tencentim.RESTConfig { + timeout := c.RequestTimeout + if timeout <= 0 { + timeout = 5 * time.Second + } + return tencentim.RESTConfig{ + SDKAppID: c.SDKAppID, + SecretKey: c.SecretKey, + AdminIdentifier: c.AdminIdentifier, + AdminUserSigTTL: c.AdminUserSigTTL, + Endpoint: c.Endpoint, + HTTPClient: &http.Client{Timeout: timeout}, + } +} + +// WalletNoticeWorkerConfig 控制钱包私有通知投递 worker。 +type WalletNoticeWorkerConfig struct { + Enabled bool `yaml:"enabled"` + PollInterval time.Duration `yaml:"poll_interval"` + BatchSize int `yaml:"batch_size"` + LockTTL time.Duration `yaml:"lock_ttl"` + PublishTimeout time.Duration `yaml:"publish_timeout"` + MaxRetryCount int `yaml:"max_retry_count"` + InitialBackoff time.Duration `yaml:"initial_backoff"` + MaxBackoff time.Duration `yaml:"max_backoff"` +} + +// Default 返回本地开发默认配置。 +func Default() Config { + return Config{ + ServiceName: "notice-service", + NodeID: "notice-local", + Environment: "local", + GRPCAddr: ":13009", + HealthHTTPAddr: ":13109", + MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_notice?parseTime=true&charset=utf8mb4&loc=UTC", + WalletDatabase: "hyapp_wallet", + MySQLAutoMigrate: false, + TencentIM: TencentIMConfig{ + Enabled: false, + AdminIdentifier: "administrator", + AdminUserSigTTL: 24 * time.Hour, + Endpoint: tencentim.DefaultEndpoint, + RequestTimeout: 5 * time.Second, + }, + WalletNoticeWorker: WalletNoticeWorkerConfig{ + Enabled: false, + PollInterval: time.Second, + BatchSize: 100, + LockTTL: 30 * time.Second, + PublishTimeout: 3 * time.Second, + MaxRetryCount: 10, + InitialBackoff: 5 * time.Second, + MaxBackoff: 5 * time.Minute, + }, + Log: logx.Config{ + Level: "info", + Format: "json", + MaxPayloadBytes: 2048, + }, + } +} + +// Load 从独立 config.yaml 读取 notice-service 配置。 +func Load(path string) (Config, error) { + cfg := Default() + if path == "" { + return cfg, nil + } + if err := configx.LoadYAML(path, &cfg); err != nil { + return Config{}, err + } + cfg.ServiceName = strings.TrimSpace(cfg.ServiceName) + if cfg.ServiceName == "" { + cfg.ServiceName = "notice-service" + } + cfg.NodeID = strings.TrimSpace(cfg.NodeID) + if cfg.NodeID == "" { + cfg.NodeID = cfg.ServiceName + } + cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment)) + if cfg.Environment == "" { + cfg.Environment = "local" + } + cfg.GRPCAddr = strings.TrimSpace(cfg.GRPCAddr) + if cfg.GRPCAddr == "" { + cfg.GRPCAddr = ":13009" + } + cfg.HealthHTTPAddr = strings.TrimSpace(cfg.HealthHTTPAddr) + if cfg.HealthHTTPAddr == "" { + cfg.HealthHTTPAddr = ":13109" + } + cfg.WalletDatabase = strings.TrimSpace(cfg.WalletDatabase) + if cfg.WalletDatabase == "" { + cfg.WalletDatabase = "hyapp_wallet" + } + cfg.WalletNoticeWorker = normalizeWalletNoticeWorker(cfg.WalletNoticeWorker) + if strings.TrimSpace(cfg.TencentIM.AdminIdentifier) == "" { + cfg.TencentIM.AdminIdentifier = "administrator" + } + if cfg.TencentIM.AdminUserSigTTL <= 0 { + cfg.TencentIM.AdminUserSigTTL = 24 * time.Hour + } + if strings.TrimSpace(cfg.TencentIM.Endpoint) == "" { + cfg.TencentIM.Endpoint = tencentim.DefaultEndpoint + } + if cfg.TencentIM.RequestTimeout <= 0 { + cfg.TencentIM.RequestTimeout = 5 * time.Second + } + return cfg, nil +} + +func normalizeWalletNoticeWorker(cfg WalletNoticeWorkerConfig) WalletNoticeWorkerConfig { + def := Default().WalletNoticeWorker + if cfg.PollInterval <= 0 { + cfg.PollInterval = def.PollInterval + } + if cfg.BatchSize <= 0 { + cfg.BatchSize = def.BatchSize + } + if cfg.LockTTL <= 0 { + cfg.LockTTL = def.LockTTL + } + if cfg.PublishTimeout <= 0 { + cfg.PublishTimeout = def.PublishTimeout + } + if cfg.MaxRetryCount <= 0 { + cfg.MaxRetryCount = def.MaxRetryCount + } + if cfg.InitialBackoff <= 0 { + cfg.InitialBackoff = def.InitialBackoff + } + if cfg.MaxBackoff <= 0 { + cfg.MaxBackoff = def.MaxBackoff + } + return cfg +} diff --git a/services/notice-service/internal/modules/walletnotice/mysql_integration_test.go b/services/notice-service/internal/modules/walletnotice/mysql_integration_test.go new file mode 100644 index 00000000..8212dd1a --- /dev/null +++ b/services/notice-service/internal/modules/walletnotice/mysql_integration_test.go @@ -0,0 +1,129 @@ +package walletnotice + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +func TestMySQLRepositoryProcessesRealWalletOutbox(t *testing.T) { + dsn := strings.TrimSpace(os.Getenv("NOTICE_REAL_MYSQL_DSN")) + if dsn == "" { + t.Skip("set NOTICE_REAL_MYSQL_DSN to run real MySQL notice validation") + } + walletDatabase := strings.TrimSpace(os.Getenv("NOTICE_REAL_WALLET_DATABASE")) + if walletDatabase == "" { + walletDatabase = "hyapp_wallet" + } + + db, err := sql.Open("mysql", dsn) + if err != nil { + t.Fatalf("open mysql: %v", err) + } + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + t.Fatalf("ping mysql: %v", err) + } + + repository, err := NewMySQLRepository(db, walletDatabase) + if err != nil { + t.Fatalf("NewMySQLRepository: %v", err) + } + nowMs := time.Now().UnixMilli() + suffix := fmt.Sprintf("%d", time.Now().UnixNano()) + appCode := "notice_real" + userID := int64(990000000) + eventID := "notice_real_evt_" + suffix + transactionID := "notice_real_tx_" + suffix + commandID := "notice_real_cmd_" + suffix + payload := map[string]any{ + "transaction_id": transactionID, + "command_id": commandID, + "user_id": userID, + "asset_type": "COIN", + "available_delta": int64(188), + "frozen_delta": int64(0), + "available_after": int64(99887766), + "frozen_after": int64(0), + "balance_version": int64(66), + "metadata": map[string]any{ + "validation": "notice-service-real-mysql", + }, + "created_at_ms": nowMs, + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + if _, err := db.ExecContext(ctx, fmt.Sprintf(` + INSERT INTO %s.wallet_outbox ( + app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type, + available_delta, frozen_delta, payload, status, retry_count, last_error, created_at_ms, updated_at_ms + ) VALUES (?, ?, 'WalletBalanceChanged', ?, ?, ?, 'COIN', 188, 0, ?, 'pending', 0, '', ?, ?)`, + quoteDB(walletDatabase), + ), appCode, eventID, transactionID, commandID, userID, string(payloadJSON), nowMs, nowMs); err != nil { + t.Fatalf("insert wallet_outbox: %v", err) + } + if !keepRealRows() { + t.Cleanup(func() { + _, _ = db.ExecContext(context.Background(), `DELETE FROM notice_delivery_events WHERE app_code = ? AND source_event_id = ?`, appCode, eventID) + _, _ = db.ExecContext(context.Background(), fmt.Sprintf(`DELETE FROM %s.wallet_outbox WHERE app_code = ? AND event_id = ?`, quoteDB(walletDatabase)), appCode, eventID) + }) + } + + publisher := &fakePublisher{} + service := New(Config{NodeID: "notice-real-test"}, repository, publisher) + processed, err := service.ProcessWalletBalanceNotices(ctx, WalletNoticeWorkerOptions{ + WorkerID: "notice-real-test-worker", + BatchSize: 10, + LockTTL: 30 * time.Second, + PublishTimeout: time.Second, + MaxRetryCount: 3, + }) + if err != nil { + t.Fatalf("ProcessWalletBalanceNotices: %v", err) + } + if processed != 1 || len(publisher.messages) != 1 { + t.Fatalf("unexpected process result: processed=%d messages=%d", processed, len(publisher.messages)) + } + var noticeStatus string + var deliveredAtMS int64 + var storedPayload string + if err := db.QueryRowContext(ctx, ` + SELECT status, delivered_at_ms, COALESCE(CAST(payload_json AS CHAR), '{}') + FROM notice_delivery_events + WHERE source_name = 'wallet_outbox' AND app_code = ? AND source_event_id = ? AND channel = 'tencent_im_c2c'`, + appCode, eventID, + ).Scan(¬iceStatus, &deliveredAtMS, &storedPayload); err != nil { + t.Fatalf("query notice_delivery_events: %v", err) + } + if noticeStatus != "delivered" || deliveredAtMS <= 0 { + t.Fatalf("unexpected delivery marker: status=%s delivered_at_ms=%d payload=%s", noticeStatus, deliveredAtMS, storedPayload) + } + var noticePayload map[string]any + if err := json.Unmarshal([]byte(storedPayload), ¬icePayload); err != nil { + t.Fatalf("decode stored notice payload: %v", err) + } + if noticePayload["event_id"] != eventID || noticePayload["asset_type"] != "COIN" || noticePayload["balance_version"].(float64) != 66 { + t.Fatalf("unexpected stored notice payload: %+v", noticePayload) + } + t.Logf("real notice validation event_id=%s status=%s delivered_at_ms=%d", eventID, noticeStatus, deliveredAtMS) +} + +func keepRealRows() bool { + value := strings.ToLower(strings.TrimSpace(os.Getenv("NOTICE_REAL_MYSQL_KEEP_ROWS"))) + return value == "1" || value == "true" || value == "yes" +} + +func quoteDB(value string) string { + return "`" + strings.ReplaceAll(value, "`", "``") + "`" +} diff --git a/services/notice-service/internal/modules/walletnotice/mysql_repository.go b/services/notice-service/internal/modules/walletnotice/mysql_repository.go new file mode 100644 index 00000000..4645e6eb --- /dev/null +++ b/services/notice-service/internal/modules/walletnotice/mysql_repository.go @@ -0,0 +1,359 @@ +package walletnotice + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + "unicode" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" +) + +const ( + sourceWalletOutbox = "wallet_outbox" + channelTencentIMC2C = "tencent_im_c2c" + eventWalletBalance = "WalletBalanceChanged" + deliveryStatusDelivering = "delivering" + deliveryStatusDelivered = "delivered" + deliveryStatusRetryable = "retryable" + deliveryStatusFailed = "failed" +) + +// MySQLRepository owns wallet notice delivery state and reads wallet_outbox as an append-only source. +type MySQLRepository struct { + db *sql.DB + walletOutboxTable string +} + +// WalletBalanceEvent 是 notice worker 从 wallet_outbox 认领的一条私有余额通知事实。 +type WalletBalanceEvent struct { + AppCode string + EventID string + EventType string + TransactionID string + CommandID string + UserID int64 + AssetType string + AvailableDelta int64 + FrozenDelta int64 + PayloadJSON string + RetryCount int + CreatedAtMS int64 +} + +// NewMySQLRepository 创建钱包余额通知模块仓储。 +// notice-service 自己的连接池由 platform/mysql 持有;模块只使用同一 DB 事务管理投递位点。 +func NewMySQLRepository(db *sql.DB, walletDatabase string) (*MySQLRepository, error) { + if db == nil { + return nil, xerr.New(xerr.Unavailable, "mysql db is not configured") + } + walletDatabase = normalizeDatabaseName(walletDatabase) + if walletDatabase == "" { + return nil, xerr.New(xerr.InvalidArgument, "wallet_database is invalid") + } + return &MySQLRepository{ + db: db, + walletOutboxTable: fmt.Sprintf("`%s`.`wallet_outbox`", walletDatabase), + }, nil +} + +// ClaimWalletBalanceEvents 抢占 wallet_outbox 中待投递的余额变更事件。 +// wallet_outbox 自身不被 notice-service 修改;多实例互斥和重试状态保存在 notice_delivery_events。 +func (r *MySQLRepository) ClaimWalletBalanceEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) ([]WalletBalanceEvent, error) { + if r == nil || r.db == nil { + return nil, xerr.New(xerr.Unavailable, "notice repository is not configured") + } + workerID = strings.TrimSpace(workerID) + if workerID == "" { + return nil, xerr.New(xerr.InvalidArgument, "worker_id is required") + } + if limit <= 0 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + if lockTTL <= 0 { + lockTTL = 30 * time.Second + } + + candidates, err := r.listWalletBalanceCandidates(ctx, limit, time.Now().UnixMilli()) + if err != nil { + return nil, err + } + claimed := make([]WalletBalanceEvent, 0, len(candidates)) + for _, candidate := range candidates { + event, ok, err := r.claimWalletBalanceCandidate(ctx, candidate, workerID, lockTTL) + if err != nil { + return claimed, err + } + if ok { + claimed = append(claimed, event) + } + } + return claimed, nil +} + +type walletBalanceCandidate struct { + AppCode string + EventID string +} + +func (r *MySQLRepository) listWalletBalanceCandidates(ctx context.Context, limit int, nowMs int64) ([]walletBalanceCandidate, error) { + query := fmt.Sprintf(` + SELECT wo.app_code, wo.event_id + FROM %s wo + LEFT JOIN notice_delivery_events nde + ON nde.source_name = ? AND nde.app_code = wo.app_code AND nde.source_event_id = wo.event_id AND nde.channel = ? + WHERE wo.event_type = ? + AND ( + nde.source_event_id IS NULL + OR (nde.status = ? AND nde.next_retry_at_ms <= ?) + OR (nde.status = ? AND nde.lock_until_ms <= ?) + ) + ORDER BY wo.created_at_ms ASC, wo.event_id ASC + LIMIT ?`, r.walletOutboxTable) + rows, err := r.db.QueryContext(ctx, query, + sourceWalletOutbox, + channelTencentIMC2C, + eventWalletBalance, + deliveryStatusRetryable, + nowMs, + deliveryStatusDelivering, + nowMs, + limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + candidates := make([]walletBalanceCandidate, 0, limit) + for rows.Next() { + var candidate walletBalanceCandidate + if err := rows.Scan(&candidate.AppCode, &candidate.EventID); err != nil { + return nil, err + } + candidates = append(candidates, candidate) + } + return candidates, rows.Err() +} + +func (r *MySQLRepository) claimWalletBalanceCandidate(ctx context.Context, candidate walletBalanceCandidate, workerID string, lockTTL time.Duration) (WalletBalanceEvent, bool, error) { + nowMs := time.Now().UnixMilli() + lockUntilMS := time.Now().Add(lockTTL).UnixMilli() + ctx = appcode.WithContext(ctx, candidate.AppCode) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return WalletBalanceEvent{}, false, err + } + defer func() { _ = tx.Rollback() }() + + event, err := r.lockWalletBalanceEvent(ctx, tx, candidate) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // 候选扫描到事务锁定之间可能被清理;这种竞争不应让整批失败。 + return WalletBalanceEvent{}, false, nil + } + return WalletBalanceEvent{}, false, err + } + retryCount, claimed, err := r.claimDeliveryEvent(ctx, tx, event, workerID, lockUntilMS, nowMs) + if err != nil || !claimed { + return WalletBalanceEvent{}, false, err + } + event.RetryCount = retryCount + if err := tx.Commit(); err != nil { + return WalletBalanceEvent{}, false, err + } + return event, true, nil +} + +func (r *MySQLRepository) lockWalletBalanceEvent(ctx context.Context, tx *sql.Tx, candidate walletBalanceCandidate) (WalletBalanceEvent, error) { + var event WalletBalanceEvent + query := fmt.Sprintf(` + SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type, + available_delta, frozen_delta, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms + FROM %s + WHERE app_code = ? AND event_id = ? AND event_type = ? + FOR UPDATE`, r.walletOutboxTable) + err := tx.QueryRowContext(ctx, query, + appcode.Normalize(candidate.AppCode), + candidate.EventID, + eventWalletBalance, + ).Scan( + &event.AppCode, + &event.EventID, + &event.EventType, + &event.TransactionID, + &event.CommandID, + &event.UserID, + &event.AssetType, + &event.AvailableDelta, + &event.FrozenDelta, + &event.PayloadJSON, + &event.CreatedAtMS, + ) + return event, err +} + +func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, event WalletBalanceEvent, workerID string, lockUntilMS int64, nowMs int64) (int, bool, error) { + _, err := tx.ExecContext(ctx, ` + INSERT INTO notice_delivery_events ( + source_name, app_code, source_event_id, channel, notice_type, target_user_id, + status, retry_count, locked_by, lock_until_ms, next_retry_at_ms, + payload_json, last_error, delivered_at_ms, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 0, ?, '', 0, ?, ?)`, + sourceWalletOutbox, + event.AppCode, + event.EventID, + channelTencentIMC2C, + event.EventType, + event.UserID, + deliveryStatusDelivering, + workerID, + lockUntilMS, + event.PayloadJSON, + nowMs, + nowMs, + ) + if err == nil { + return 0, true, nil + } + if !isDuplicateKey(err) { + return 0, false, err + } + + result, err := tx.ExecContext(ctx, ` + UPDATE notice_delivery_events + SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ? + WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ? + AND ( + (status = ? AND next_retry_at_ms <= ?) + OR (status = ? AND lock_until_ms <= ?) + )`, + deliveryStatusDelivering, + workerID, + lockUntilMS, + event.PayloadJSON, + nowMs, + sourceWalletOutbox, + event.AppCode, + event.EventID, + channelTencentIMC2C, + deliveryStatusRetryable, + nowMs, + deliveryStatusDelivering, + nowMs, + ) + if err != nil { + return 0, false, err + } + affected, err := result.RowsAffected() + if err != nil || affected == 0 { + return 0, false, err + } + + var retryCount int + err = tx.QueryRowContext(ctx, ` + SELECT retry_count + FROM notice_delivery_events + WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ? + FOR UPDATE`, + sourceWalletOutbox, + event.AppCode, + event.EventID, + channelTencentIMC2C, + ).Scan(&retryCount) + return retryCount, err == nil, err +} + +// MarkWalletBalanceDelivered 标记一条私有余额通知已投递到腾讯云 IM。 +func (r *MySQLRepository) MarkWalletBalanceDelivered(ctx context.Context, event WalletBalanceEvent, nowMs int64) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE notice_delivery_events + SET status = ?, locked_by = '', lock_until_ms = 0, next_retry_at_ms = 0, + last_error = '', delivered_at_ms = ?, updated_at_ms = ? + WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`, + deliveryStatusDelivered, + nowMs, + nowMs, + sourceWalletOutbox, + event.AppCode, + event.EventID, + channelTencentIMC2C, + ) + return err +} + +// MarkWalletBalanceRetryable 释放当前投递锁,按 nextRetryAtMS 进入退避重试。 +func (r *MySQLRepository) MarkWalletBalanceRetryable(ctx context.Context, event WalletBalanceEvent, retryCount int, nextRetryAtMS int64, lastErr string, nowMs int64) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE notice_delivery_events + SET status = ?, retry_count = ?, locked_by = '', lock_until_ms = 0, + next_retry_at_ms = ?, last_error = ?, updated_at_ms = ? + WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`, + deliveryStatusRetryable, + retryCount, + nextRetryAtMS, + truncateError(lastErr), + nowMs, + sourceWalletOutbox, + event.AppCode, + event.EventID, + channelTencentIMC2C, + ) + return err +} + +// MarkWalletBalanceFailed 把 poison event 移入死信状态,不再自动抢占。 +func (r *MySQLRepository) MarkWalletBalanceFailed(ctx context.Context, event WalletBalanceEvent, retryCount int, lastErr string, nowMs int64) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE notice_delivery_events + SET status = ?, retry_count = ?, locked_by = '', lock_until_ms = 0, + next_retry_at_ms = 0, last_error = ?, updated_at_ms = ? + WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`, + deliveryStatusFailed, + retryCount, + truncateError(lastErr), + nowMs, + sourceWalletOutbox, + event.AppCode, + event.EventID, + channelTencentIMC2C, + ) + return err +} + +func normalizeDatabaseName(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + for _, r := range value { + if r == '_' || unicode.IsDigit(r) || unicode.IsLetter(r) { + continue + } + return "" + } + return value +} + +func isDuplicateKey(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "Error 1062") || strings.Contains(strings.ToLower(err.Error()), "duplicate") +} + +func truncateError(value string) string { + value = strings.TrimSpace(value) + if len(value) <= 1000 { + return value + } + return value[:1000] +} diff --git a/services/notice-service/internal/modules/walletnotice/service.go b/services/notice-service/internal/modules/walletnotice/service.go new file mode 100644 index 00000000..f35a4a18 --- /dev/null +++ b/services/notice-service/internal/modules/walletnotice/service.go @@ -0,0 +1,147 @@ +package walletnotice + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strconv" + "time" + + "hyapp/pkg/logx" + "hyapp/pkg/tencentim" +) + +// Repository 是 notice-service 需要的持久化能力。 +type Repository interface { + ClaimWalletBalanceEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) ([]WalletBalanceEvent, error) + MarkWalletBalanceDelivered(ctx context.Context, event WalletBalanceEvent, nowMs int64) error + MarkWalletBalanceRetryable(ctx context.Context, event WalletBalanceEvent, retryCount int, nextRetryAtMS int64, lastErr string, nowMs int64) error + MarkWalletBalanceFailed(ctx context.Context, event WalletBalanceEvent, retryCount int, lastErr string, nowMs int64) error +} + +// UserMessagePublisher 把私有 notice 投递到用户个人实时通道。 +type UserMessagePublisher interface { + PublishUserCustomMessage(ctx context.Context, message tencentim.CustomUserMessage) error +} + +// Config 保存 notice-service 领域配置。 +type Config struct { + NodeID string +} + +// Service 编排 notice 投递和消费位点,不拥有钱包账务状态。 +type Service struct { + cfg Config + repository Repository + publisher UserMessagePublisher +} + +// New 创建 notice 领域服务。 +func New(cfg Config, repository Repository, publisher UserMessagePublisher) *Service { + return &Service{cfg: cfg, repository: repository, publisher: publisher} +} + +// ProcessWalletBalanceNotices 处理一批钱包余额变更私有通知。 +func (s *Service) ProcessWalletBalanceNotices(ctx context.Context, options WalletNoticeWorkerOptions) (int, error) { + options = normalizeWalletNoticeWorkerOptions(options, s.cfg.NodeID) + if s.repository == nil { + return 0, fmt.Errorf("notice repository is not configured") + } + if s.publisher == nil { + return 0, fmt.Errorf("notice publisher is not configured") + } + events, err := s.repository.ClaimWalletBalanceEvents(ctx, options.WorkerID, options.BatchSize, options.LockTTL) + if err != nil { + return 0, err + } + processed := 0 + for _, event := range events { + if err := s.publishWalletBalanceEvent(ctx, event, options); err != nil { + return processed, err + } + processed++ + } + return processed, nil +} + +func (s *Service) publishWalletBalanceEvent(ctx context.Context, event WalletBalanceEvent, options WalletNoticeWorkerOptions) error { + payload, err := walletBalanceNoticePayload(event) + if err != nil { + return s.markFailed(ctx, event, options, err) + } + publishCtx, cancel := context.WithTimeout(ctx, options.PublishTimeout) + defer cancel() + err = s.publisher.PublishUserCustomMessage(publishCtx, tencentim.CustomUserMessage{ + ToAccount: tencentim.FormatUserID(event.UserID), + EventID: event.EventID, + Desc: event.EventType, + Ext: "wallet_notice", + PayloadJSON: payload, + }) + nowMs := time.Now().UnixMilli() + if err == nil { + if markErr := s.repository.MarkWalletBalanceDelivered(ctx, event, nowMs); markErr != nil { + return markErr + } + logx.Info(ctx, "notice_wallet_balance_delivered", + slog.String("event_id", event.EventID), + slog.String("app_code", event.AppCode), + slog.Int64("user_id", event.UserID), + slog.String("asset_type", event.AssetType), + ) + return nil + } + return s.markFailed(ctx, event, options, err) +} + +func (s *Service) markFailed(ctx context.Context, event WalletBalanceEvent, options WalletNoticeWorkerOptions, cause error) error { + nowMs := time.Now().UnixMilli() + nextRetryCount := event.RetryCount + 1 + if nextRetryCount >= options.MaxRetryCount { + if err := s.repository.MarkWalletBalanceFailed(ctx, event, nextRetryCount, cause.Error(), nowMs); err != nil { + return err + } + logx.Error(ctx, "notice_wallet_balance_dead_letter", cause, + slog.String("event_id", event.EventID), + slog.String("app_code", event.AppCode), + slog.Int64("user_id", event.UserID), + slog.Int("retry_count", nextRetryCount), + ) + return nil + } + nextRetryAtMS := nowMs + walletNoticeBackoff(nextRetryCount, options).Milliseconds() + if err := s.repository.MarkWalletBalanceRetryable(ctx, event, nextRetryCount, nextRetryAtMS, cause.Error(), nowMs); err != nil { + return err + } + logx.Error(ctx, "notice_wallet_balance_retryable", cause, + slog.String("event_id", event.EventID), + slog.String("app_code", event.AppCode), + slog.Int64("user_id", event.UserID), + slog.Int("retry_count", nextRetryCount), + slog.Int64("next_retry_at_ms", nextRetryAtMS), + ) + return nil +} + +func walletBalanceNoticePayload(event WalletBalanceEvent) (json.RawMessage, error) { + var payload map[string]any + if err := json.Unmarshal([]byte(event.PayloadJSON), &payload); err != nil { + return nil, fmt.Errorf("invalid wallet balance payload: %w", err) + } + if payload == nil { + payload = make(map[string]any) + } + // 钱包事件 payload 是客户端更新余额的私有事实;这里补齐 outbox 表中的稳定索引字段,避免客户端依赖 metadata 结构。 + payload["event_type"] = event.EventType + payload["event_id"] = event.EventID + payload["app_code"] = event.AppCode + payload["transaction_id"] = event.TransactionID + payload["command_id"] = event.CommandID + payload["user_id"] = strconv.FormatInt(event.UserID, 10) + payload["asset_type"] = event.AssetType + payload["available_delta"] = event.AvailableDelta + payload["frozen_delta"] = event.FrozenDelta + payload["source_created_at_ms"] = event.CreatedAtMS + return json.Marshal(payload) +} diff --git a/services/notice-service/internal/modules/walletnotice/service_test.go b/services/notice-service/internal/modules/walletnotice/service_test.go new file mode 100644 index 00000000..fc091d0e --- /dev/null +++ b/services/notice-service/internal/modules/walletnotice/service_test.go @@ -0,0 +1,126 @@ +package walletnotice + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "hyapp/pkg/tencentim" +) + +func TestProcessWalletBalanceNoticesPublishesPrivateIM(t *testing.T) { + repo := &fakeRepository{ + events: []WalletBalanceEvent{{ + AppCode: "lalu", + EventID: "evt-1", + EventType: "WalletBalanceChanged", + TransactionID: "tx-1", + CommandID: "cmd-1", + UserID: 123, + AssetType: "COIN", + AvailableDelta: -10, + FrozenDelta: 0, + PayloadJSON: `{"available_after":90,"frozen_after":0,"balance_version":7}`, + CreatedAtMS: 1710000000000, + }}, + } + publisher := &fakePublisher{} + service := New(Config{NodeID: "node-a"}, repo, publisher) + + processed, err := service.ProcessWalletBalanceNotices(context.Background(), WalletNoticeWorkerOptions{MaxRetryCount: 3}) + if err != nil { + t.Fatalf("ProcessWalletBalanceNotices failed: %v", err) + } + if processed != 1 || len(repo.delivered) != 1 || len(publisher.messages) != 1 { + t.Fatalf("unexpected process result: processed=%d delivered=%d messages=%d", processed, len(repo.delivered), len(publisher.messages)) + } + message := publisher.messages[0] + if message.ToAccount != "123" || message.EventID != "evt-1" || message.Ext != "wallet_notice" { + t.Fatalf("unexpected c2c message: %+v", message) + } + var payload map[string]any + if err := json.Unmarshal(message.PayloadJSON, &payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + if payload["event_id"] != "evt-1" || payload["asset_type"] != "COIN" || payload["balance_version"].(float64) != 7 { + t.Fatalf("unexpected wallet payload: %+v", payload) + } +} + +func TestProcessWalletBalanceNoticesMarksRetryable(t *testing.T) { + repo := &fakeRepository{ + events: []WalletBalanceEvent{{ + AppCode: "lalu", + EventID: "evt-2", + EventType: "WalletBalanceChanged", + UserID: 123, + AssetType: "COIN", + PayloadJSON: `{"available_after":90,"balance_version":8}`, + RetryCount: 0, + }}, + } + service := New(Config{NodeID: "node-a"}, repo, &fakePublisher{err: errors.New("im timeout")}) + + processed, err := service.ProcessWalletBalanceNotices(context.Background(), WalletNoticeWorkerOptions{ + MaxRetryCount: 3, + InitialBackoff: time.Second, + MaxBackoff: time.Minute, + }) + if err != nil { + t.Fatalf("ProcessWalletBalanceNotices failed: %v", err) + } + if processed != 1 || len(repo.retryable) != 1 { + t.Fatalf("unexpected retry result: processed=%d retryable=%d", processed, len(repo.retryable)) + } + if repo.retryable[0].retryCount != 1 || repo.retryable[0].nextRetryAtMS <= 0 { + t.Fatalf("unexpected retry marker: %+v", repo.retryable[0]) + } +} + +type fakeRepository struct { + events []WalletBalanceEvent + delivered []WalletBalanceEvent + retryable []retryMarker + failed []WalletBalanceEvent +} + +type retryMarker struct { + event WalletBalanceEvent + retryCount int + nextRetryAtMS int64 + lastErr string +} + +func (r *fakeRepository) ClaimWalletBalanceEvents(context.Context, string, int, time.Duration) ([]WalletBalanceEvent, error) { + return r.events, nil +} + +func (r *fakeRepository) MarkWalletBalanceDelivered(_ context.Context, event WalletBalanceEvent, _ int64) error { + r.delivered = append(r.delivered, event) + return nil +} + +func (r *fakeRepository) MarkWalletBalanceRetryable(_ context.Context, event WalletBalanceEvent, retryCount int, nextRetryAtMS int64, lastErr string, _ int64) error { + r.retryable = append(r.retryable, retryMarker{event: event, retryCount: retryCount, nextRetryAtMS: nextRetryAtMS, lastErr: lastErr}) + return nil +} + +func (r *fakeRepository) MarkWalletBalanceFailed(_ context.Context, event WalletBalanceEvent, _ int, _ string, _ int64) error { + r.failed = append(r.failed, event) + return nil +} + +type fakePublisher struct { + messages []tencentim.CustomUserMessage + err error +} + +func (p *fakePublisher) PublishUserCustomMessage(_ context.Context, message tencentim.CustomUserMessage) error { + if p.err != nil { + return p.err + } + p.messages = append(p.messages, message) + return nil +} diff --git a/services/notice-service/internal/modules/walletnotice/worker.go b/services/notice-service/internal/modules/walletnotice/worker.go new file mode 100644 index 00000000..8617e552 --- /dev/null +++ b/services/notice-service/internal/modules/walletnotice/worker.go @@ -0,0 +1,92 @@ +package walletnotice + +import ( + "context" + "log/slog" + "strings" + "time" + + "hyapp/pkg/logx" +) + +// WalletNoticeWorkerOptions 是钱包余额私有通知 worker 的运行参数。 +type WalletNoticeWorkerOptions struct { + WorkerID string + PollInterval time.Duration + BatchSize int + LockTTL time.Duration + PublishTimeout time.Duration + MaxRetryCount int + InitialBackoff time.Duration + MaxBackoff time.Duration +} + +// RunWalletBalanceWorker 持续消费 wallet_outbox 的 WalletBalanceChanged 事件。 +func (s *Service) RunWalletBalanceWorker(ctx context.Context, options WalletNoticeWorkerOptions) { + options = normalizeWalletNoticeWorkerOptions(options, s.cfg.NodeID) + ticker := time.NewTicker(options.PollInterval) + defer ticker.Stop() + for { + processed, err := s.ProcessWalletBalanceNotices(ctx, options) + if err != nil && ctx.Err() == nil { + logx.Error(ctx, "notice_wallet_balance_batch_failed", err, slog.String("worker_id", options.WorkerID)) + } + if processed >= options.BatchSize { + // 批次打满说明有积压,立即继续 drain,避免固定 tick 放大私有余额通知延迟。 + continue + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +func normalizeWalletNoticeWorkerOptions(options WalletNoticeWorkerOptions, nodeID string) WalletNoticeWorkerOptions { + if strings.TrimSpace(options.WorkerID) == "" { + if strings.TrimSpace(nodeID) == "" { + nodeID = "notice" + } + options.WorkerID = "wallet-notice-" + nodeID + } + if options.PollInterval <= 0 { + options.PollInterval = time.Second + } + if options.BatchSize <= 0 { + options.BatchSize = 100 + } + if options.LockTTL <= 0 { + options.LockTTL = 30 * time.Second + } + if options.PublishTimeout <= 0 { + options.PublishTimeout = 3 * time.Second + } + if options.MaxRetryCount <= 0 { + options.MaxRetryCount = 10 + } + if options.InitialBackoff <= 0 { + options.InitialBackoff = 5 * time.Second + } + if options.MaxBackoff <= 0 { + options.MaxBackoff = 5 * time.Minute + } + return options +} + +func walletNoticeBackoff(retryCount int, options WalletNoticeWorkerOptions) time.Duration { + if retryCount <= 0 { + return options.InitialBackoff + } + backoff := options.InitialBackoff + for i := 1; i < retryCount; i++ { + if backoff >= options.MaxBackoff/2 { + return options.MaxBackoff + } + backoff *= 2 + } + if backoff > options.MaxBackoff { + return options.MaxBackoff + } + return backoff +} diff --git a/services/notice-service/internal/platform/mysql/db.go b/services/notice-service/internal/platform/mysql/db.go new file mode 100644 index 00000000..67adf8fa --- /dev/null +++ b/services/notice-service/internal/platform/mysql/db.go @@ -0,0 +1,48 @@ +package mysql + +import ( + "context" + "database/sql" + "strings" + + _ "github.com/go-sql-driver/mysql" + "hyapp/pkg/xerr" +) + +// Store 持有 notice-service 自己的 MySQL 连接池。 +// 业务模块通过 DB 共享同一个连接池,但不能绕过各自模块仓储直接写状态。 +type Store struct { + DB *sql.DB +} + +// Open 创建 notice-service 基础连接池,并在启动期完成一次 ping。 +func Open(ctx context.Context, dsn string) (*Store, error) { + if strings.TrimSpace(dsn) == "" { + return nil, xerr.New(xerr.InvalidArgument, "mysql_dsn is required") + } + db, err := sql.Open("mysql", dsn) + if err != nil { + return nil, err + } + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, err + } + return &Store{DB: db}, nil +} + +// Ping 给 gRPC health 和 HTTP health 复用,不混入具体业务表检查。 +func (s *Store) Ping(ctx context.Context) error { + if s == nil || s.DB == nil { + return xerr.New(xerr.Unavailable, "mysql store is not configured") + } + return s.DB.PingContext(ctx) +} + +// Close 释放连接池;业务 worker 必须在调用前完成 drain。 +func (s *Store) Close() error { + if s == nil || s.DB == nil { + return nil + } + return s.DB.Close() +} diff --git a/services/user-service/deploy/mysql/initdb/001_user_service.sql b/services/user-service/deploy/mysql/initdb/001_user_service.sql index ae2a2114..3f4c8611 100644 --- a/services/user-service/deploy/mysql/initdb/001_user_service.sql +++ b/services/user-service/deploy/mysql/initdb/001_user_service.sql @@ -619,8 +619,8 @@ CREATE TABLE IF NOT EXISTS host_outbox ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; --- Country/region bootstrap is generated from services/user-service/deploy/data/countries.json. --- region_code intentionally uses source subregion directly; top-level region is not imported. +-- Country bootstrap is generated from services/user-service/deploy/data/countries.json. +-- Region bootstrap uses product-owned business buckets instead of source geography. INSERT INTO countries (country_name, country_code, iso_alpha3, iso_numeric, country_display_name, phone_country_code, flag, enabled, sort_order, created_at_ms, updated_at_ms) VALUES ('Andorra', 'AD', 'AND', '020', '安道尔', '+376', '🇦🇩', TRUE, 10, 0, 0), ('United Arab Emirates', 'AE', 'ARE', '784', '阿拉伯联合酋长国', '+971', '🇦🇪', TRUE, 20, 0, 0), @@ -834,7 +834,7 @@ INSERT INTO countries (country_name, country_code, iso_alpha3, iso_numeric, coun ('Vietnam', 'VN', 'VNM', '704', '越南', '+84', '🇻🇳', TRUE, 2410, 0, 0), ('Vanuatu', 'VU', 'VUT', '548', '瓦努阿图', '+678', '🇻🇺', TRUE, 2420, 0, 0), ('Samoa', 'WS', 'WSM', '882', '萨摩亚', '+685', '🇼🇸', TRUE, 2440, 0, 0), - ('Kosovo', 'XK', 'UNK', '', '科索沃', '+383', '🇽🇰', TRUE, 2450, 0, 0), + ('Kosovo', 'XK', 'UNK', NULL, '科索沃', '+383', '🇽🇰', TRUE, 2450, 0, 0), ('Yemen', 'YE', 'YEM', '887', '也门', '+967', '🇾🇪', TRUE, 2460, 0, 0), ('Mayotte', 'YT', 'MYT', '175', '马约特', '+262', '🇾🇹', TRUE, 2470, 0, 0), ('South Africa', 'ZA', 'ZAF', '710', '南非', '+27', '🇿🇦', TRUE, 2480, 0, 0), @@ -852,25 +852,9 @@ ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms); INSERT INTO regions (region_code, name, status, sort_order, created_at_ms, updated_at_ms) VALUES - ('GLOBAL', 'GLOBAL', 'active', 0, 0, 0), - ('Southern Europe', '南欧', 'active', 10, 0, 0), - ('Western Asia', '西亚', 'active', 20, 0, 0), - ('Southern Asia', '南亚', 'active', 30, 0, 0), - ('Southeast Europe', '东南欧', 'active', 50, 0, 0), - ('Middle Africa', '中非', 'active', 60, 0, 0), - ('South America', '南美洲', 'active', 80, 0, 0), - ('Central Europe', '中欧', 'active', 100, 0, 0), - ('Northern Europe', '北欧', 'active', 120, 0, 0), - ('Western Europe', '西欧', 'active', 130, 0, 0), - ('Western Africa', '西非', 'active', 140, 0, 0), - ('Eastern Africa', '东非', 'active', 150, 0, 0), - ('North America', '北美洲', 'active', 160, 0, 0), - ('South-Eastern Asia', '东南亚', 'active', 170, 0, 0), - ('Eastern Europe', '东欧', 'active', 190, 0, 0), - ('Central America', '中美洲', 'active', 200, 0, 0), - ('Eastern Asia', '东亚', 'active', 210, 0, 0), - ('Northern Africa', '北非', 'active', 220, 0, 0), - ('Central Asia', '中亚', 'active', 250, 0, 0) + ('MIDDLE_EAST', '中东区', 'active', 10, 0, 0), + ('INDIA', '印度区', 'active', 20, 0, 0), + ('EURASIA_AMERICA', '欧亚美区', 'active', 30, 0, 0) ON DUPLICATE KEY UPDATE name = VALUES(name), status = VALUES(status), @@ -880,450 +864,454 @@ ON DUPLICATE KEY UPDATE INSERT INTO region_countries (app_code, region_id, country_code, status, created_at_ms, updated_at_ms) SELECT 'lalu', r.region_id, seed.country_code, 'active', 0, 0 FROM ( - SELECT 'AD' AS country_code, 'Southern Europe' AS region_code + SELECT 'AD' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'AE' AS country_code, 'Western Asia' AS region_code + SELECT 'AE' AS country_code, 'MIDDLE_EAST' AS region_code UNION ALL - SELECT 'AF' AS country_code, 'Southern Asia' AS region_code + SELECT 'AF' AS country_code, 'INDIA' AS region_code UNION ALL - SELECT 'AI' AS country_code, 'Caribbean' AS region_code + SELECT 'AI' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'AL' AS country_code, 'Southeast Europe' AS region_code + SELECT 'AL' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'AM' AS country_code, 'Western Asia' AS region_code + SELECT 'AM' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'AO' AS country_code, 'Middle Africa' AS region_code + SELECT 'AO' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'AR' AS country_code, 'South America' AS region_code + SELECT 'AR' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'AT' AS country_code, 'Central Europe' AS region_code + SELECT 'AT' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'AU' AS country_code, 'Australia and New Zealand' AS region_code + SELECT 'AU' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'AW' AS country_code, 'Caribbean' AS region_code + SELECT 'AW' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'AX' AS country_code, 'Northern Europe' AS region_code + SELECT 'AX' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'AZ' AS country_code, 'Western Asia' AS region_code + SELECT 'AZ' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'BB' AS country_code, 'Caribbean' AS region_code + SELECT 'BB' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'BD' AS country_code, 'Southern Asia' AS region_code + SELECT 'BD' AS country_code, 'INDIA' AS region_code UNION ALL - SELECT 'BE' AS country_code, 'Western Europe' AS region_code + SELECT 'BE' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'BF' AS country_code, 'Western Africa' AS region_code + SELECT 'BF' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'BG' AS country_code, 'Southeast Europe' AS region_code + SELECT 'BG' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'BH' AS country_code, 'Western Asia' AS region_code + SELECT 'BH' AS country_code, 'MIDDLE_EAST' AS region_code UNION ALL - SELECT 'BI' AS country_code, 'Eastern Africa' AS region_code + SELECT 'BI' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'BJ' AS country_code, 'Western Africa' AS region_code + SELECT 'BJ' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'BL' AS country_code, 'Caribbean' AS region_code + SELECT 'BL' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'BM' AS country_code, 'North America' AS region_code + SELECT 'BM' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'BN' AS country_code, 'South-Eastern Asia' AS region_code + SELECT 'BN' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'BO' AS country_code, 'South America' AS region_code + SELECT 'BO' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'BR' AS country_code, 'South America' AS region_code + SELECT 'BR' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'BS' AS country_code, 'Caribbean' AS region_code + SELECT 'BS' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'BT' AS country_code, 'Southern Asia' AS region_code + SELECT 'BT' AS country_code, 'INDIA' AS region_code UNION ALL - SELECT 'BV' AS country_code, 'UNSPECIFIED' AS region_code + SELECT 'BV' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'BW' AS country_code, 'Southern Africa' AS region_code + SELECT 'BW' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'BY' AS country_code, 'Eastern Europe' AS region_code + SELECT 'BY' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'BZ' AS country_code, 'Central America' AS region_code + SELECT 'BZ' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'CA' AS country_code, 'North America' AS region_code + SELECT 'CA' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'CC' AS country_code, 'Australia and New Zealand' AS region_code + SELECT 'CC' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'CD' AS country_code, 'Middle Africa' AS region_code + SELECT 'CD' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'CF' AS country_code, 'Middle Africa' AS region_code + SELECT 'CF' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'CG' AS country_code, 'Middle Africa' AS region_code + SELECT 'CG' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'CH' AS country_code, 'Western Europe' AS region_code + SELECT 'CH' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'CI' AS country_code, 'Western Africa' AS region_code + SELECT 'CI' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'CL' AS country_code, 'South America' AS region_code + SELECT 'CL' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'CM' AS country_code, 'Middle Africa' AS region_code + SELECT 'CM' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'CN' AS country_code, 'Eastern Asia' AS region_code + SELECT 'CN' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'CO' AS country_code, 'South America' AS region_code + SELECT 'CO' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'CR' AS country_code, 'Central America' AS region_code + SELECT 'CR' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'CU' AS country_code, 'Caribbean' AS region_code + SELECT 'CU' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'CV' AS country_code, 'Western Africa' AS region_code + SELECT 'CV' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'CW' AS country_code, 'Caribbean' AS region_code + SELECT 'CW' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'CX' AS country_code, 'Australia and New Zealand' AS region_code + SELECT 'CX' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'CY' AS country_code, 'Southern Europe' AS region_code + SELECT 'CY' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'CZ' AS country_code, 'Central Europe' AS region_code + SELECT 'CZ' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'DE' AS country_code, 'Western Europe' AS region_code + SELECT 'DE' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'DJ' AS country_code, 'Eastern Africa' AS region_code + SELECT 'DJ' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'DK' AS country_code, 'Northern Europe' AS region_code + SELECT 'DK' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'DM' AS country_code, 'Caribbean' AS region_code + SELECT 'DM' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'DO' AS country_code, 'Caribbean' AS region_code + SELECT 'DO' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'DZ' AS country_code, 'Northern Africa' AS region_code + SELECT 'DZ' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'EC' AS country_code, 'South America' AS region_code + SELECT 'EC' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'EE' AS country_code, 'Northern Europe' AS region_code + SELECT 'EE' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'EG' AS country_code, 'Northern Africa' AS region_code + SELECT 'EG' AS country_code, 'MIDDLE_EAST' AS region_code UNION ALL - SELECT 'ER' AS country_code, 'Eastern Africa' AS region_code + SELECT 'ER' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'ES' AS country_code, 'Southern Europe' AS region_code + SELECT 'ES' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'ET' AS country_code, 'Eastern Africa' AS region_code + SELECT 'ET' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'FI' AS country_code, 'Northern Europe' AS region_code + SELECT 'FI' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'FJ' AS country_code, 'Melanesia' AS region_code + SELECT 'FJ' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'FM' AS country_code, 'Micronesia' AS region_code + SELECT 'FM' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'FR' AS country_code, 'Western Europe' AS region_code + SELECT 'FR' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'GA' AS country_code, 'Middle Africa' AS region_code + SELECT 'GA' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'GB' AS country_code, 'Northern Europe' AS region_code + SELECT 'GB' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'GD' AS country_code, 'Caribbean' AS region_code + SELECT 'GD' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'GE' AS country_code, 'Western Asia' AS region_code + SELECT 'GE' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'GF' AS country_code, 'South America' AS region_code + SELECT 'GF' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'GG' AS country_code, 'Northern Europe' AS region_code + SELECT 'GG' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'GH' AS country_code, 'Western Africa' AS region_code + SELECT 'GH' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'GI' AS country_code, 'Southern Europe' AS region_code + SELECT 'GI' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'GL' AS country_code, 'North America' AS region_code + SELECT 'GL' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'GM' AS country_code, 'Western Africa' AS region_code + SELECT 'GM' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'GN' AS country_code, 'Western Africa' AS region_code + SELECT 'GN' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'GQ' AS country_code, 'Middle Africa' AS region_code + SELECT 'GQ' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'GR' AS country_code, 'Southern Europe' AS region_code + SELECT 'GR' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'GS' AS country_code, 'UNSPECIFIED' AS region_code + SELECT 'GS' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'GT' AS country_code, 'Central America' AS region_code + SELECT 'GT' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'GU' AS country_code, 'Micronesia' AS region_code + SELECT 'GU' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'GY' AS country_code, 'South America' AS region_code + SELECT 'GY' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'HN' AS country_code, 'Central America' AS region_code + SELECT 'HN' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'HR' AS country_code, 'Southeast Europe' AS region_code + SELECT 'HR' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'HT' AS country_code, 'Caribbean' AS region_code + SELECT 'HT' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'HU' AS country_code, 'Central Europe' AS region_code + SELECT 'HU' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'ID' AS country_code, 'South-Eastern Asia' AS region_code + SELECT 'ID' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'IE' AS country_code, 'Northern Europe' AS region_code + SELECT 'IE' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'IL' AS country_code, 'Western Asia' AS region_code + SELECT 'IL' AS country_code, 'MIDDLE_EAST' AS region_code UNION ALL - SELECT 'IM' AS country_code, 'Northern Europe' AS region_code + SELECT 'IM' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'IN' AS country_code, 'Southern Asia' AS region_code + SELECT 'IN' AS country_code, 'INDIA' AS region_code UNION ALL - SELECT 'IQ' AS country_code, 'Western Asia' AS region_code + SELECT 'IQ' AS country_code, 'MIDDLE_EAST' AS region_code UNION ALL - SELECT 'IR' AS country_code, 'Southern Asia' AS region_code + SELECT 'IR' AS country_code, 'MIDDLE_EAST' AS region_code UNION ALL - SELECT 'IS' AS country_code, 'Northern Europe' AS region_code + SELECT 'IS' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'IT' AS country_code, 'Southern Europe' AS region_code + SELECT 'IT' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'JE' AS country_code, 'Northern Europe' AS region_code + SELECT 'JE' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'JM' AS country_code, 'Caribbean' AS region_code + SELECT 'JM' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'JO' AS country_code, 'Western Asia' AS region_code + SELECT 'JO' AS country_code, 'MIDDLE_EAST' AS region_code UNION ALL - SELECT 'JP' AS country_code, 'Eastern Asia' AS region_code + SELECT 'JP' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'KE' AS country_code, 'Eastern Africa' AS region_code + SELECT 'KE' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'KG' AS country_code, 'Central Asia' AS region_code + SELECT 'KG' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'KH' AS country_code, 'South-Eastern Asia' AS region_code + SELECT 'KH' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'KI' AS country_code, 'Micronesia' AS region_code + SELECT 'KI' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'KM' AS country_code, 'Eastern Africa' AS region_code + SELECT 'KM' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'KP' AS country_code, 'Eastern Asia' AS region_code + SELECT 'KP' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'KR' AS country_code, 'Eastern Asia' AS region_code + SELECT 'KR' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'KW' AS country_code, 'Western Asia' AS region_code + SELECT 'KW' AS country_code, 'MIDDLE_EAST' AS region_code UNION ALL - SELECT 'KY' AS country_code, 'Caribbean' AS region_code + SELECT 'KY' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'KZ' AS country_code, 'Central Asia' AS region_code + SELECT 'KZ' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'LA' AS country_code, 'South-Eastern Asia' AS region_code + SELECT 'LA' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'LB' AS country_code, 'Western Asia' AS region_code + SELECT 'LB' AS country_code, 'MIDDLE_EAST' AS region_code UNION ALL - SELECT 'LC' AS country_code, 'Caribbean' AS region_code + SELECT 'LC' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'LI' AS country_code, 'Western Europe' AS region_code + SELECT 'LI' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'LK' AS country_code, 'Southern Asia' AS region_code + SELECT 'LK' AS country_code, 'INDIA' AS region_code UNION ALL - SELECT 'LR' AS country_code, 'Western Africa' AS region_code + SELECT 'LR' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'LS' AS country_code, 'Southern Africa' AS region_code + SELECT 'LS' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'LT' AS country_code, 'Northern Europe' AS region_code + SELECT 'LT' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'LU' AS country_code, 'Western Europe' AS region_code + SELECT 'LU' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'LV' AS country_code, 'Northern Europe' AS region_code + SELECT 'LV' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'LY' AS country_code, 'Northern Africa' AS region_code + SELECT 'LY' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'MA' AS country_code, 'Northern Africa' AS region_code + SELECT 'MA' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'MC' AS country_code, 'Western Europe' AS region_code + SELECT 'MC' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'MD' AS country_code, 'Eastern Europe' AS region_code + SELECT 'MD' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'ME' AS country_code, 'Southeast Europe' AS region_code + SELECT 'ME' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'MF' AS country_code, 'Caribbean' AS region_code + SELECT 'MF' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'MG' AS country_code, 'Eastern Africa' AS region_code + SELECT 'MG' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'ML' AS country_code, 'Western Africa' AS region_code + SELECT 'ML' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'MM' AS country_code, 'South-Eastern Asia' AS region_code + SELECT 'MM' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'MN' AS country_code, 'Eastern Asia' AS region_code + SELECT 'MN' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'MO' AS country_code, 'Eastern Asia' AS region_code + SELECT 'MO' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'MQ' AS country_code, 'Caribbean' AS region_code + SELECT 'MQ' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'MR' AS country_code, 'Western Africa' AS region_code + SELECT 'MR' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'MS' AS country_code, 'Caribbean' AS region_code + SELECT 'MS' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'MT' AS country_code, 'Southern Europe' AS region_code + SELECT 'MT' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'MU' AS country_code, 'Eastern Africa' AS region_code + SELECT 'MU' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'MV' AS country_code, 'Southern Asia' AS region_code + SELECT 'MV' AS country_code, 'INDIA' AS region_code UNION ALL - SELECT 'MW' AS country_code, 'Eastern Africa' AS region_code + SELECT 'MW' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'MX' AS country_code, 'North America' AS region_code + SELECT 'MX' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'MY' AS country_code, 'South-Eastern Asia' AS region_code + SELECT 'MY' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'MZ' AS country_code, 'Eastern Africa' AS region_code + SELECT 'MZ' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'NA' AS country_code, 'Southern Africa' AS region_code + SELECT 'NA' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'NC' AS country_code, 'Melanesia' AS region_code + SELECT 'NC' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'NE' AS country_code, 'Western Africa' AS region_code + SELECT 'NE' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'NF' AS country_code, 'Australia and New Zealand' AS region_code + SELECT 'NF' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'NG' AS country_code, 'Western Africa' AS region_code + SELECT 'NG' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'NI' AS country_code, 'Central America' AS region_code + SELECT 'NI' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'NL' AS country_code, 'Western Europe' AS region_code + SELECT 'NL' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'NO' AS country_code, 'Northern Europe' AS region_code + SELECT 'NO' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'NP' AS country_code, 'Southern Asia' AS region_code + SELECT 'NP' AS country_code, 'INDIA' AS region_code UNION ALL - SELECT 'NR' AS country_code, 'Micronesia' AS region_code + SELECT 'NR' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'NU' AS country_code, 'Polynesia' AS region_code + SELECT 'NU' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'NZ' AS country_code, 'Australia and New Zealand' AS region_code + SELECT 'NZ' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'OM' AS country_code, 'Western Asia' AS region_code + SELECT 'OM' AS country_code, 'MIDDLE_EAST' AS region_code UNION ALL - SELECT 'PA' AS country_code, 'Central America' AS region_code + SELECT 'PA' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'PE' AS country_code, 'South America' AS region_code + SELECT 'PE' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'PH' AS country_code, 'South-Eastern Asia' AS region_code + SELECT 'PH' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'PK' AS country_code, 'Southern Asia' AS region_code + SELECT 'PK' AS country_code, 'INDIA' AS region_code UNION ALL - SELECT 'PL' AS country_code, 'Central Europe' AS region_code + SELECT 'PL' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'PR' AS country_code, 'Caribbean' AS region_code + SELECT 'PR' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'PS' AS country_code, 'Western Asia' AS region_code + SELECT 'PS' AS country_code, 'MIDDLE_EAST' AS region_code UNION ALL - SELECT 'PT' AS country_code, 'Southern Europe' AS region_code + SELECT 'PT' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'PW' AS country_code, 'Micronesia' AS region_code + SELECT 'PW' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'PY' AS country_code, 'South America' AS region_code + SELECT 'PY' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'QA' AS country_code, 'Western Asia' AS region_code + SELECT 'QA' AS country_code, 'MIDDLE_EAST' AS region_code UNION ALL - SELECT 'RE' AS country_code, 'Eastern Africa' AS region_code + SELECT 'RE' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'RO' AS country_code, 'Southeast Europe' AS region_code + SELECT 'RO' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'RS' AS country_code, 'Southeast Europe' AS region_code + SELECT 'RS' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'RU' AS country_code, 'Eastern Europe' AS region_code + SELECT 'RU' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'RW' AS country_code, 'Eastern Africa' AS region_code + SELECT 'RW' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'SA' AS country_code, 'Western Asia' AS region_code + SELECT 'SA' AS country_code, 'MIDDLE_EAST' AS region_code UNION ALL - SELECT 'SB' AS country_code, 'Melanesia' AS region_code + SELECT 'SB' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'SC' AS country_code, 'Eastern Africa' AS region_code + SELECT 'SC' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'SD' AS country_code, 'Northern Africa' AS region_code + SELECT 'SD' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'SE' AS country_code, 'Northern Europe' AS region_code + SELECT 'SE' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'SG' AS country_code, 'South-Eastern Asia' AS region_code + SELECT 'SG' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'SI' AS country_code, 'Central Europe' AS region_code + SELECT 'SI' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'SJ' AS country_code, 'Northern Europe' AS region_code + SELECT 'SJ' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'SK' AS country_code, 'Central Europe' AS region_code + SELECT 'SK' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'SL' AS country_code, 'Western Africa' AS region_code + SELECT 'SL' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'SM' AS country_code, 'Southern Europe' AS region_code + SELECT 'SM' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'SN' AS country_code, 'Western Africa' AS region_code + SELECT 'SN' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'SO' AS country_code, 'Eastern Africa' AS region_code + SELECT 'SO' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'SR' AS country_code, 'South America' AS region_code + SELECT 'SR' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'SS' AS country_code, 'Middle Africa' AS region_code + SELECT 'SS' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'SV' AS country_code, 'Central America' AS region_code + SELECT 'SV' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'SX' AS country_code, 'Caribbean' AS region_code + SELECT 'SX' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'SY' AS country_code, 'Western Asia' AS region_code + SELECT 'SY' AS country_code, 'MIDDLE_EAST' AS region_code UNION ALL - SELECT 'SZ' AS country_code, 'Southern Africa' AS region_code + SELECT 'SZ' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'TD' AS country_code, 'Middle Africa' AS region_code + SELECT 'TD' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'TG' AS country_code, 'Western Africa' AS region_code + SELECT 'TG' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'TH' AS country_code, 'South-Eastern Asia' AS region_code + SELECT 'TH' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'TJ' AS country_code, 'Central Asia' AS region_code + SELECT 'TJ' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'TK' AS country_code, 'Polynesia' AS region_code + SELECT 'TK' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'TL' AS country_code, 'South-Eastern Asia' AS region_code + SELECT 'TL' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'TM' AS country_code, 'Central Asia' AS region_code + SELECT 'TM' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'TN' AS country_code, 'Northern Africa' AS region_code + SELECT 'TN' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'TO' AS country_code, 'Polynesia' AS region_code + SELECT 'TO' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'TR' AS country_code, 'Western Asia' AS region_code + SELECT 'TR' AS country_code, 'MIDDLE_EAST' AS region_code UNION ALL - SELECT 'TV' AS country_code, 'Polynesia' AS region_code + SELECT 'TV' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'TW' AS country_code, 'Eastern Asia' AS region_code + SELECT 'TW' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'TZ' AS country_code, 'Eastern Africa' AS region_code + SELECT 'TZ' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'UA' AS country_code, 'Eastern Europe' AS region_code + SELECT 'UA' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'UG' AS country_code, 'Eastern Africa' AS region_code + SELECT 'UG' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'US' AS country_code, 'North America' AS region_code + SELECT 'US' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'UY' AS country_code, 'South America' AS region_code + SELECT 'UY' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'UZ' AS country_code, 'Central Asia' AS region_code + SELECT 'UZ' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'VA' AS country_code, 'Southern Europe' AS region_code + SELECT 'VA' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'VE' AS country_code, 'South America' AS region_code + SELECT 'VE' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'VN' AS country_code, 'South-Eastern Asia' AS region_code + SELECT 'VN' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'VU' AS country_code, 'Melanesia' AS region_code + SELECT 'VU' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'WS' AS country_code, 'Polynesia' AS region_code + SELECT 'WS' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'XK' AS country_code, 'Southeast Europe' AS region_code + SELECT 'XK' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'YE' AS country_code, 'Western Asia' AS region_code + SELECT 'YE' AS country_code, 'MIDDLE_EAST' AS region_code UNION ALL - SELECT 'YT' AS country_code, 'Eastern Africa' AS region_code + SELECT 'YT' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'ZA' AS country_code, 'Southern Africa' AS region_code + SELECT 'ZA' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'ZM' AS country_code, 'Eastern Africa' AS region_code + SELECT 'ZM' AS country_code, 'EURASIA_AMERICA' AS region_code UNION ALL - SELECT 'ZW' AS country_code, 'Eastern Africa' AS region_code + SELECT 'ZW' AS country_code, 'EURASIA_AMERICA' AS region_code ) AS seed INNER JOIN regions r ON r.app_code = 'lalu' AND r.region_code = seed.region_code - AND r.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED') INNER JOIN countries c ON c.app_code = 'lalu' AND c.country_code = seed.country_code ON DUPLICATE KEY UPDATE region_id = VALUES(region_id), status = VALUES(status), updated_at_ms = VALUES(updated_at_ms); +-- Region management now exposes only the product-owned business buckets; GLOBAL remains an internal fallback, not a managed region. +DELETE FROM regions +WHERE app_code = 'lalu' + AND region_code NOT IN ('MIDDLE_EAST', 'INDIA', 'EURASIA_AMERICA'); + -- Product-excluded countries are hard removed so reapplying initdb updates existing local databases as well as fresh volumes. DELETE FROM region_countries WHERE app_code = 'lalu' diff --git a/services/user-service/internal/domain/user/user.go b/services/user-service/internal/domain/user/user.go index b83a141b..939e2c2a 100644 --- a/services/user-service/internal/domain/user/user.go +++ b/services/user-service/internal/domain/user/user.go @@ -71,8 +71,8 @@ const ( PrettyLeaseStatusRefunded PrettyLeaseStatus = "refunded" ) -// displayUserIDPattern 限制短号为 6 到 11 位非 0 开头数字。 -var displayUserIDPattern = regexp.MustCompile(`^[1-9][0-9]{5,10}$`) +// displayUserIDPattern 限制短号为 5 到 11 位非 0 开头数字。 +var displayUserIDPattern = regexp.MustCompile(`^[1-9][0-9]{4,10}$`) // User 是 user-service 的主数据实体。 // 这里只保存注册时形成的用户资料和来源快照,不承载关系链、房间内临时态或连接态。 diff --git a/services/user-service/internal/domain/user/user_test.go b/services/user-service/internal/domain/user/user_test.go new file mode 100644 index 00000000..d3ee992f --- /dev/null +++ b/services/user-service/internal/domain/user/user_test.go @@ -0,0 +1,21 @@ +package user + +import "testing" + +func TestValidDisplayUserIDAllowsFiveDigitSeedAccounts(t *testing.T) { + t.Parallel() + + valid := []string{"10001", "99999", "123456", "12345678901"} + for _, value := range valid { + if !ValidDisplayUserID(value) { + t.Fatalf("display_user_id %s should be valid", value) + } + } + + invalid := []string{"", "09999", "9999", "123456789012", "12a45"} + for _, value := range invalid { + if ValidDisplayUserID(value) { + t.Fatalf("display_user_id %s should be invalid", value) + } + } +} diff --git a/services/user-service/internal/storage/mysql/region/regions.go b/services/user-service/internal/storage/mysql/region/regions.go index de0f2c1e..a34cd454 100644 --- a/services/user-service/internal/storage/mysql/region/regions.go +++ b/services/user-service/internal/storage/mysql/region/regions.go @@ -117,6 +117,8 @@ func (r *Repository) ListRegions(ctx context.Context, filter userdomain.RegionFi conditions = append(conditions, "status = ?") args = append(args, filter.Status) } + conditions = append(conditions, "region_code <> ?") + args = append(args, userdomain.GlobalRegionCode) conditions = append(conditions, "region_code NOT IN ("+retiredRegionCodeSQLList+")") query += " WHERE " + strings.Join(conditions, " AND ") query += " ORDER BY sort_order ASC, region_code ASC" @@ -127,33 +129,22 @@ func (r *Repository) ListRegions(ctx context.Context, filter userdomain.RegionFi defer rows.Close() regions := []userdomain.Region{} - hasGlobal := false for rows.Next() { region, err := scanRegion(rows) if err != nil { return nil, err } - if userdomain.IsGlobalRegionCode(region.RegionCode) { - hasGlobal = true - } countries, err := activeCountriesForRegion(ctx, r.db, region.RegionID) if err != nil { return nil, err } region.Countries = countries - if userdomain.IsGlobalRegionCode(region.RegionCode) { - region.RegionID = 0 - region.Countries = []string{} - } regions = append(regions, region) } if err := rows.Err(); err != nil { return nil, err } - if !hasGlobal && (filter.Status == "" || filter.Status == userdomain.RegionStatusActive) { - regions = append([]userdomain.Region{globalRegionFromContext(ctx)}, regions...) - } return regions, nil } diff --git a/services/wallet-service/internal/service/wallet/service.go b/services/wallet-service/internal/service/wallet/service.go index ea642563..24d6a144 100644 --- a/services/wallet-service/internal/service/wallet/service.go +++ b/services/wallet-service/internal/service/wallet/service.go @@ -72,9 +72,6 @@ func (s *Service) DebitGift(ctx context.Context, command ledger.DebitGiftCommand if command.CommandID == "" || command.RoomID == "" || command.SenderUserID <= 0 || command.TargetUserID <= 0 || command.GiftID == "" { return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "billing command is incomplete") } - if command.SenderUserID == command.TargetUserID { - return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "sender and target must be different") - } if command.GiftCount <= 0 { return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "gift_count must be positive") } diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index f4e0bb58..ba7f3423 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -61,6 +61,41 @@ func TestDebitGiftUsesServerPriceAndCreditsGiftPoint(t *testing.T) { } } +// TestDebitGiftAllowsSelfGift 验证用户可以给自己送礼,扣 COIN 和收 GIFT_POINT 仍然分别落账。 +func TestDebitGiftAllowsSelfGift(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetBalance(10001, 100) + repository.SetGiftPrice("rose", "v9", 7, 3, 11) + svc := walletservice.New(repository) + + receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ + CommandID: "cmd-self-gift", + RoomID: "room-1", + SenderUserID: 10001, + TargetUserID: 10001, + GiftID: "rose", + GiftCount: 2, + PriceVersion: "v9", + }) + if err != nil { + t.Fatalf("self DebitGift failed: %v", err) + } + + if receipt.CoinSpent != 14 || receipt.GiftPointAdded != 6 || receipt.BalanceAfter != 86 { + t.Fatalf("self gift settlement mismatch: %+v", receipt) + } + balances, err := svc.GetBalances(context.Background(), 10001, []string{ledger.AssetCoin, ledger.AssetGiftPoint}) + if err != nil { + t.Fatalf("GetBalances failed: %v", err) + } + if balanceAmount(balances, ledger.AssetCoin) != 86 || balanceAmount(balances, ledger.AssetGiftPoint) != 6 { + t.Fatalf("self gift balances mismatch: %+v", balances) + } + if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 2 { + t.Fatalf("self gift should still write debit and credit entries, got %d", got) + } +} + // TestGetUserGiftWallAggregatesSettledGifts 验证礼物墙只统计扣费成功后的收礼事实。 func TestGetUserGiftWallAggregatesSettledGifts(t *testing.T) { repository := mysqltest.NewRepository(t) diff --git a/services/wallet-service/internal/storage/mysql/app_wallet_repository.go b/services/wallet-service/internal/storage/mysql/app_wallet_repository.go index 78c3f39e..5e6a953a 100644 --- a/services/wallet-service/internal/storage/mysql/app_wallet_repository.go +++ b/services/wallet-service/internal/storage/mysql/app_wallet_repository.go @@ -300,7 +300,7 @@ func (r *Repository) ApplyWithdrawal(ctx context.Context, command ledger.Withdra return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetUSDBalance, -command.Amount, command.Amount, availableAfter, frozenAfter, metadata, nowMs), + balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetUSDBalance, -command.Amount, command.Amount, availableAfter, frozenAfter, account.Version+1, metadata, nowMs), }); err != nil { return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err } @@ -487,7 +487,7 @@ func (r *Repository) PurchaseVip(ctx context.Context, command ledger.PurchaseVip return ledger.PurchaseVipReceipt{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -level.PriceCoin, 0, coinBalanceAfter, account.FrozenAmount, metadata, nowMs), + balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -level.PriceCoin, 0, coinBalanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), { EventID: eventID(transactionID, vipEventType, command.UserID, "VIP"), EventType: vipEventType, diff --git a/services/wallet-service/internal/storage/mysql/repository.go b/services/wallet-service/internal/storage/mysql/repository.go index 39d474ae..e85a0735 100644 --- a/services/wallet-service/internal/storage/mysql/repository.go +++ b/services/wallet-service/internal/storage/mysql/repository.go @@ -208,8 +208,8 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm return ledger.Receipt{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, metadata, nowMs), - balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetGiftPoint, giftPointAdded, 0, targetAfter, target.FrozenAmount, metadata, nowMs), + balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMs), + balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetGiftPoint, giftPointAdded, 0, targetAfter, target.FrozenAmount, target.Version+1, metadata, nowMs), giftDebitedEvent(transactionID, command.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, metadata, nowMs), }); err != nil { return ledger.Receipt{}, err @@ -423,7 +423,7 @@ func (r *Repository) AdminCreditAsset(ctx context.Context, command ledger.AdminC return ledger.AssetBalance{}, "", err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, command.AssetType, command.Amount, 0, balance.AvailableAmount, balance.FrozenAmount, metadata, nowMs), + balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, command.AssetType, command.Amount, 0, balance.AvailableAmount, balance.FrozenAmount, balance.Version, metadata, nowMs), }); err != nil { return ledger.AssetBalance{}, "", err } @@ -494,7 +494,7 @@ func (r *Repository) CreditTaskReward(ctx context.Context, command ledger.TaskRe return ledger.TaskRewardReceipt{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, metadata, nowMs), + balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), taskRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs), }); err != nil { return ledger.TaskRewardReceipt{}, err @@ -581,7 +581,7 @@ func (r *Repository) ApplyGameCoinChange(ctx context.Context, command ledger.Gam return ledger.GameCoinChangeReceipt{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, delta, 0, balanceAfter, account.FrozenAmount, metadata, nowMs), + balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, delta, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), }); err != nil { return ledger.GameCoinChangeReceipt{}, err } @@ -680,7 +680,7 @@ func (r *Repository) AdminCreditCoinSellerStock(ctx context.Context, command led return ledger.CoinSellerStockCreditReceipt{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, command.CoinAmount, 0, balanceAfter, account.FrozenAmount, metadata, nowMs), + balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, command.CoinAmount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), coinSellerStockCreditedEvent(transactionID, command.CommandID, metadata, nowMs), }); err != nil { return ledger.CoinSellerStockCreditReceipt{}, err @@ -796,8 +796,8 @@ func (r *Repository) TransferCoinFromSeller(ctx context.Context, command ledger. return ledger.CoinSellerTransferReceipt{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, -command.Amount, 0, sellerAfter, seller.FrozenAmount, metadata, nowMs), - balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, targetAfter, target.FrozenAmount, metadata, nowMs), + balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, -command.Amount, 0, sellerAfter, seller.FrozenAmount, seller.Version+1, metadata, nowMs), + balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, targetAfter, target.FrozenAmount, target.Version+1, metadata, nowMs), coinSellerTransferredEvent(transactionID, command.CommandID, command.SellerUserID, -command.Amount, metadata, nowMs), rechargeRecordedEvent(transactionID, command.CommandID, command.TargetUserID, command.Amount, metadata, nowMs), }); err != nil { @@ -1504,7 +1504,7 @@ func receiptFromCoinSellerTransferMetadata(transactionID string, metadata coinSe } } -func balanceChangedEvent(transactionID string, commandID string, userID int64, assetType string, availableDelta int64, frozenDelta int64, availableAfter int64, frozenAfter int64, payload any, nowMs int64) walletOutboxEvent { +func balanceChangedEvent(transactionID string, commandID string, userID int64, assetType string, availableDelta int64, frozenDelta int64, availableAfter int64, frozenAfter int64, balanceVersion int64, payload any, nowMs int64) walletOutboxEvent { return walletOutboxEvent{ EventID: eventID(transactionID, "WalletBalanceChanged", userID, assetType), EventType: "WalletBalanceChanged", @@ -1523,6 +1523,7 @@ func balanceChangedEvent(transactionID string, commandID string, userID int64, a "frozen_delta": frozenDelta, "available_after": availableAfter, "frozen_after": frozenAfter, + "balance_version": balanceVersion, "metadata": payload, "created_at_ms": nowMs, }, diff --git a/services/wallet-service/internal/storage/mysql/resource_repository.go b/services/wallet-service/internal/storage/mysql/resource_repository.go index 663accc3..57916f42 100644 --- a/services/wallet-service/internal/storage/mysql/resource_repository.go +++ b/services/wallet-service/internal/storage/mysql/resource_repository.go @@ -1010,7 +1010,7 @@ func (r *Repository) applyGrantItem(ctx context.Context, tx *sql.Tx, grantID str return resourcedomain.ResourceGrantItem{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, walletCommandID, targetUserID, assetType, amount, 0, availableAfter, account.FrozenAmount, metadata, nowMs), + balanceChangedEvent(transactionID, walletCommandID, targetUserID, assetType, amount, 0, availableAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), }); err != nil { return resourcedomain.ResourceGrantItem{}, err } @@ -1085,7 +1085,7 @@ func (r *Repository) applyWalletAssetGrantItem(ctx context.Context, tx *sql.Tx, return resourcedomain.ResourceGrantItem{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, walletCommandID, targetUserID, assetType, groupItem.WalletAssetAmount, 0, availableAfter, account.FrozenAmount, metadata, nowMs), + balanceChangedEvent(transactionID, walletCommandID, targetUserID, assetType, groupItem.WalletAssetAmount, 0, availableAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), }); err != nil { return resourcedomain.ResourceGrantItem{}, err }