完善部署

This commit is contained in:
zhx 2026-05-13 16:07:25 +08:00
parent dbc5c5ec88
commit fb1789f97f
78 changed files with 3590 additions and 640 deletions

View File

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

View File

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

View File

@ -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/<service>/configs/config.yaml`; `room-service``user-service``wallet-service``activity-service``cron-service` 都要求 MySQL 可用。
For direct host execution, each service reads its own `services/<service>/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 <access_token>
- 客户端再用腾讯云 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 <access_token>
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 等本地配置只能用开发值,线上必须走安全配置源。

1
deploy/__init__.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,286 @@
# 腾讯云 TAT 滚动部署方案
本方案只部署本仓库 Go 微服务,不接管同机已有 Java 项目。远端连接使用腾讯云 TAT发布时只操作 `hyapp-*` systemd unit、`hyapp-*` Docker 容器和 `/etc/hyapp/<service>/docker.env`
MySQL、Redis、MQ 都按腾讯云托管资源处理。本方案不会在这些 CVM 上部署、重启或迁移本地 MySQL/Redis/MQ服务配置只需要指向腾讯云托管实例的内网地址。
## 机器拓扑
`deploy` 机器是发布控制面,不承载业务流量:
| 机器 | 实例 ID | 公网 IP | 私网 IP | 角色 |
| --- | --- | --- | --- | --- |
| `deploy` | `ns-ntmpcd3a` | `43.164.75.199` | `10.2.1.3` | 构建镜像、推送镜像、调用 TAT/CLB API 调度发布 |
当前机器按截图落到以下服务:
| 机器 | 实例 ID | 私网 IP | 本仓库服务 |
| --- | --- | --- | --- |
| `gateway-1` | `ins-aeyaoj40` | `10.2.1.8` | `gateway-service` |
| `gateway-2` | `ins-irvi5b7u` | `10.2.2.17` | `gateway-service` |
| `new-app-1` | `ins-fi5zufpk` | `10.2.1.4` | `room-service`, `user-service` |
| `new-app-2` | `ins-hwhaoe8c` | `10.2.1.13` | `room-service`, `user-service` |
| `new-game-1` | `ins-ir229jtw` | `10.2.1.10` | `game-service` |
| `new-game-2` | `ins-460rw7gu` | `10.2.1.6` | `game-service` |
| `new-app-activity-1` | `ins-n8wut6n8` | `10.2.1.11` | `activity-service` |
| `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-<service>`
4. 目标业务机只保存 `/etc/hyapp/<service>/config.yaml``docker.env`,不拉代码、不编译、不保存 Git 凭据。
不要让每台业务服务器自己 `git pull && build`。那会把代码权限、构建环境、版本一致性和回滚都扩散到业务机,且多服务发布时无法统一做 CLB 摘流。
## 前置条件
1. 每台目标机已安装腾讯云 TAT agent控制台能执行命令。
2. 每个 Go 服务已按 `deploy/standalone/systemd` 安装对应 systemd unit。
3. 每个 Go 服务已有 `/etc/hyapp/<service>/config.yaml``/etc/hyapp/<service>/docker.env`
4. 每个服务至少有两台实例CLB 后端都挂实例端口。
5. `room-service` 每台机器必须有唯一 `node_id``advertise_addr` 必须是当前机器私网地址,例如 `10.2.1.4:13001`,不能写内网 CLB。
6. 每个 `config.yaml``mysql_dsn``redis_addr`、MQ endpoint/topic/group 指向腾讯云托管资源的私网地址。
7. 发布机安装腾讯云 Python SDK
```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 <YOUR_GIT_REPO_URL> hyapp-server
cd hyapp-server
```
准备 inventory
```bash
cp deploy/tencent-tat/inventory.prod.example.json /opt/deploy/inventory/hyapp.prod.json
vi /opt/deploy/inventory/hyapp.prod.json
```
`inventory` 里的 `registry` 必须和实际镜像仓库一致。如果使用 `hy-app-monitor` 里的 Harbor 配置,通常是:
```text
registry = ${HARBOR_REGISTRY}/${HARBOR_PROJECT}
```
业务配置放到各目标机 `/etc/hyapp/<service>/config.yaml`。腾讯云 MySQL、Redis、MQ、JWT、IM/RTC/COS 等密钥不要提交到 Git。
## 构建并推送镜像
`deploy` 服务器上执行。2 核 2GiB 机器可以作为调度器;如果全量构建 OOM 或太慢,把构建放到 CIdeploy 服务器只负责调度发布。
```bash
cd /opt/deploy/sources/hyapp-server
TAG="$(date -u +%Y%m%d%H%M%S)-$(git rev-parse --short HEAD)"
REGISTRY="10.2.1.3:18082/hyapp"
docker build -t "$REGISTRY/gateway-service:$TAG" -f services/gateway-service/Dockerfile .
docker build -t "$REGISTRY/room-service:$TAG" -f services/room-service/Dockerfile .
docker build -t "$REGISTRY/user-service:$TAG" -f services/user-service/Dockerfile .
docker build -t "$REGISTRY/wallet-service:$TAG" -f services/wallet-service/Dockerfile .
docker build -t "$REGISTRY/activity-service:$TAG" -f services/activity-service/Dockerfile .
docker build -t "$REGISTRY/game-service:$TAG" -f services/game-service/Dockerfile .
docker 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-<service>`Docker 发送 `SIGTERM`,服务进入 graceful shutdown。
6. 等待 systemd active 和 Docker health 恢复。
7. 恢复该实例原 CLB 权重。
8. 等待 `stabilize_seconds` 后进入下一台实例。
如果重启或健康检查失败,脚本会停止后续步骤;已经摘流的故障实例保持权重 `0`,避免坏实例重新接流,需要人工确认后再恢复。
## 用户无感边界
- 客户端长连接、房间公屏、单聊仍由腾讯云 IM 承接gateway 重启不会断开 IM。
- gateway 是 HTTP 入口,摘流后只影响当前实例的新 HTTP 请求;另一台 gateway 继续接流。
- 内部服务通过各自内网 CLB 摘流滚动gateway 的 gRPC client 已配置短重试,只覆盖瞬时 `UNAVAILABLE`
- `room-service` 已有 owner route/node registry。旧 owner 停机时会释放 lease异常退出时最多等 `lease_ttl` 到期后其他节点接管。
- 正在执行的单个 HTTP/gRPC 请求可能返回失败,客户端必须按现有接口幂等键重试;“无感”指发布期间整体入口不断流、房间 IM 不断连。
## 禁止项
- 不要使用 `--no-clb` 做正常发布;它只适合孤立排障。
- 不要多个 `room-service` 共用同一个 `node_id`
- 不要把 `room-service.advertise_addr` 写成 CLB 地址。
- 不要先 `systemctl restart` 再摘 CLB。
- 不要在同机 Java compose 目录里执行本仓库部署命令。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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` 写余额 outboxnotice 不需要新增特殊账务逻辑,只要客户端按 `balance_version` 更新。
3. 活动/系统私有通知:新增 `activitynotice` 模块,读取 activity outbox复用 `notice_delivery_events` 或新增同语义位点表。
4. 站内 inbox新增 inbox 模块,写 notice-service 自己的 inbox 表,再按客户端分页接口读取。
5. Push新增 push 模块,和 IM 投递使用不同 `channel`,独立重试和死信。

View File

@ -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`
- SecretMySQL/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 验证:

View File

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

View File

@ -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 区域。
- 管理端修改国家归属后,后续注册和改国家立即按新映射生效。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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. 前端补角色授权抽屉和菜单权限页编辑能力。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,6 @@ package upload
type uploadPolicy struct {
allowedExtensions map[string]struct{}
kind string
maxBytes int64
requireImage bool
}

View File

@ -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(&notifications).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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 外部通知投递位点和死信表';

View File

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

View File

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

View File

@ -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(&noticeStatus, &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), &noticePayload); 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, "`", "``") + "`"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 的主数据实体。
// 这里只保存注册时形成的用户资料和来源快照,不承载关系链、房间内临时态或连接态。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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