完善部署
This commit is contained in:
parent
dbc5c5ec88
commit
fb1789f97f
@ -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 顺序。
|
||||
|
||||
|
||||
12
Makefile
12
Makefile
@ -1,7 +1,7 @@
|
||||
COMPOSE_SERVICES := mysql redis gateway-service room-service wallet-service user-service activity-service cron-service game-service
|
||||
SERVICE_ALIASES := gs gateway rs room ws wallet us user as activity cs cron game games mysql redis gateway-service room-service wallet-service user-service activity-service cron-service game-service
|
||||
COMPOSE_SERVICES := mysql redis gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service
|
||||
SERVICE_ALIASES := gs gateway rs room ws wallet us user as activity cs cron game games ns notice mysql redis gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service
|
||||
SERVICE_TOKEN := $(firstword $(filter $(SERVICE_ALIASES),$(MAKECMDGOALS)))
|
||||
SERVICE_ID := $(strip $(or $(SERVICE),$(if $(filter gs gateway,$(SERVICE_TOKEN)),gateway-service,$(if $(filter rs room,$(SERVICE_TOKEN)),room-service,$(if $(filter ws wallet,$(SERVICE_TOKEN)),wallet-service,$(if $(filter us user,$(SERVICE_TOKEN)),user-service,$(if $(filter as activity,$(SERVICE_TOKEN)),activity-service,$(if $(filter cs cron,$(SERVICE_TOKEN)),cron-service,$(if $(filter game games,$(SERVICE_TOKEN)),game-service,$(SERVICE_TOKEN))))))))))
|
||||
SERVICE_ID := $(strip $(or $(SERVICE),$(if $(filter gs gateway,$(SERVICE_TOKEN)),gateway-service,$(if $(filter rs room,$(SERVICE_TOKEN)),room-service,$(if $(filter ws wallet,$(SERVICE_TOKEN)),wallet-service,$(if $(filter us user,$(SERVICE_TOKEN)),user-service,$(if $(filter as activity,$(SERVICE_TOKEN)),activity-service,$(if $(filter cs cron,$(SERVICE_TOKEN)),cron-service,$(if $(filter game games,$(SERVICE_TOKEN)),game-service,$(if $(filter ns notice,$(SERVICE_TOKEN)),notice-service,$(SERVICE_TOKEN)))))))))))
|
||||
ADMIN_CONFIG ?= configs/config.yaml
|
||||
|
||||
.PHONY: proto vet test build admin admin-up admin-deps admin-bootstrap admin-test admin-build up db-init rebuild restart stop logs ps addr down up-service check-service resolve-compose-conflicts $(SERVICE_ALIASES)
|
||||
@ -78,7 +78,7 @@ up:
|
||||
@if [ -z "$(SERVICE_ID)" ]; then \
|
||||
docker compose up -d mysql redis; \
|
||||
./scripts/apply-local-mysql-initdb.sh; \
|
||||
docker compose up --build -d gateway-service room-service wallet-service user-service activity-service cron-service game-service; \
|
||||
docker compose up --build -d gateway-service room-service wallet-service user-service activity-service cron-service game-service notice-service; \
|
||||
elif [ "$(SERVICE_ID)" = "mysql" ] || [ "$(SERVICE_ID)" = "redis" ]; then \
|
||||
docker compose up -d $(SERVICE_ID); \
|
||||
else \
|
||||
@ -140,13 +140,13 @@ down:
|
||||
check-service:
|
||||
@if [ -z "$(SERVICE_ID)" ]; then \
|
||||
echo "Service is required. Example: make restart rs"; \
|
||||
echo "Aliases: gs/gateway rs/room ws/wallet us/user as/activity cs/cron game/games mysql redis"; \
|
||||
echo "Aliases: gs/gateway rs/room ws/wallet us/user as/activity cs/cron game/games ns/notice mysql redis"; \
|
||||
echo "Services: $(COMPOSE_SERVICES)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if ! printf '%s\n' $(COMPOSE_SERVICES) | grep -qx "$(SERVICE_ID)"; then \
|
||||
echo "Unknown service: $(SERVICE_ID)"; \
|
||||
echo "Aliases: gs/gateway rs/room ws/wallet us/user as/activity cs/cron game/games mysql redis"; \
|
||||
echo "Aliases: gs/gateway rs/room ws/wallet us/user as/activity cs/cron game/games ns/notice mysql redis"; \
|
||||
echo "Services: $(COMPOSE_SERVICES)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
24
README.md
24
README.md
@ -1,6 +1,6 @@
|
||||
# HY Voice Room
|
||||
|
||||
HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连接服务:房间业务状态由 `room-service` 承担,客户端实时消息接入腾讯云 IM,服务端只负责签发 UserSig、创建房间群组、发送房间系统消息。
|
||||
HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连接服务:房间业务状态由 `room-service` 承担,客户端实时消息接入腾讯云 IM;服务端负责签发 UserSig、房间群消息桥接,以及通过 `notice-service` 异步投递用户私有通知。
|
||||
|
||||
## Architecture
|
||||
|
||||
@ -14,6 +14,7 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
|
||||
- `user-service` 当前承接登录、三方注册、密码、refresh session、用户主数据、默认短号和临时靓号基础能力。
|
||||
- `activity-service` 当前承接每日任务、活动/系统消息基础能力和房间事件 consumer 边界;`room_outbox` 到 activity/audit 的实际消费链路按独立消费位点接入。
|
||||
- `cron-service` 当前负责后台调度、任务运行记录,以及通过内部 gRPC 触发 owner service 批处理;它不直接拥有房间、账务、用户或活动业务状态。
|
||||
- `notice-service` 当前消费 `wallet_outbox` 的余额变更事件,异步投递腾讯云 IM 单聊私有通知;后续 push、站内 inbox 和其它 owner outbox 消费按模块继续扩展。
|
||||
- 腾讯云 IM 承担客户端长连接、群消息、公屏、单聊、离线/漫游和全球接入能力;本仓库不再部署 `im-service`。
|
||||
- 多 App 共用同一套后端服务,入口由 `gateway-service` 通过包名或 `app_code` 解析租户;所有业务库表按 `app_code` 隔离,设计细节见 `docs/多App租户架构.md`。
|
||||
|
||||
@ -46,6 +47,8 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
|
||||
| `user-service` | `13005` | gRPC |
|
||||
| `activity-service` | `13006` | gRPC |
|
||||
| `cron-service` | `13007` | gRPC |
|
||||
| `game-service` | `13008` | gRPC |
|
||||
| `notice-service` | `13009` | gRPC |
|
||||
| MySQL | `23306 -> 3306` | local Docker only |
|
||||
| Redis | `13379 -> 6379` | local Docker only |
|
||||
|
||||
@ -57,7 +60,7 @@ Use Docker for local and test environments:
|
||||
make up
|
||||
```
|
||||
|
||||
Service aliases: `gs/gateway`, `rs/room`, `ws/wallet`, `us/user`, `as/activity`, `cs/cron`, `mysql`, `redis`, plus full service names.
|
||||
Service aliases: `gs/gateway`, `rs/room`, `ws/wallet`, `us/user`, `as/activity`, `cs/cron`, `game/games`, `ns/notice`, `mysql`, `redis`, plus full service names.
|
||||
|
||||
Common commands:
|
||||
|
||||
@ -74,7 +77,7 @@ make down
|
||||
|
||||
`make admin-up` 会先启动后台本地依赖 MySQL、Redis 和 `user-service`,再以前台进程启动 `server/admin`。依赖已经存在时,可以直接运行 `make admin`。
|
||||
|
||||
For direct host execution, each service reads its own `services/<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
1
deploy/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Package marker for deployment helper tests.
|
||||
@ -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;
|
||||
|
||||
@ -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,变更走对应云产品流程。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
286
deploy/tencent-tat/README.md
Normal file
286
deploy/tencent-tat/README.md
Normal 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 或太慢,把构建放到 CI,deploy 服务器只负责调度发布。
|
||||
|
||||
```bash
|
||||
cd /opt/deploy/sources/hyapp-server
|
||||
TAG="$(date -u +%Y%m%d%H%M%S)-$(git rev-parse --short HEAD)"
|
||||
REGISTRY="10.2.1.3:18082/hyapp"
|
||||
|
||||
docker build -t "$REGISTRY/gateway-service:$TAG" -f services/gateway-service/Dockerfile .
|
||||
docker build -t "$REGISTRY/room-service:$TAG" -f services/room-service/Dockerfile .
|
||||
docker build -t "$REGISTRY/user-service:$TAG" -f services/user-service/Dockerfile .
|
||||
docker build -t "$REGISTRY/wallet-service:$TAG" -f services/wallet-service/Dockerfile .
|
||||
docker build -t "$REGISTRY/activity-service:$TAG" -f services/activity-service/Dockerfile .
|
||||
docker build -t "$REGISTRY/game-service:$TAG" -f services/game-service/Dockerfile .
|
||||
|
||||
docker push "$REGISTRY/gateway-service:$TAG"
|
||||
docker push "$REGISTRY/room-service:$TAG"
|
||||
docker push "$REGISTRY/user-service:$TAG"
|
||||
docker push "$REGISTRY/wallet-service:$TAG"
|
||||
docker push "$REGISTRY/activity-service:$TAG"
|
||||
docker push "$REGISTRY/game-service:$TAG"
|
||||
```
|
||||
|
||||
如果实际用 Harbor,把 `REGISTRY` 换成 `${HARBOR_REGISTRY}/${HARBOR_PROJECT}`,并先 `docker login`。
|
||||
|
||||
## 配置 inventory
|
||||
|
||||
复制模板后填真实 CLB:
|
||||
|
||||
```bash
|
||||
cp deploy/tencent-tat/inventory.prod.example.json deploy/tencent-tat/inventory.prod.json
|
||||
vi deploy/tencent-tat/inventory.prod.json
|
||||
```
|
||||
|
||||
必须替换:
|
||||
|
||||
- `gateway-service.clb.load_balancer_id`:公网 gateway CLB。
|
||||
- `gateway-service.clb.listener_ids`:公网 HTTP/HTTPS 监听器。
|
||||
- 内部服务各自的 `load_balancer_id` 和 `listener_ids`:room/user/wallet/activity/game 内网 CLB。
|
||||
|
||||
脚本通过实例 ID、私网 IP、端口三者匹配 CLB target。发布前会把当前实例在相关 listener 下的权重改成 `0`,服务健康后恢复原权重。
|
||||
|
||||
`managed_dependencies` 只记录部署边界:腾讯云 MySQL/Redis/MQ 是外部托管依赖,不纳入 TAT 发布动作。生产变更这些资源时单独走云数据库、Redis 或 MQ 的变更流程。
|
||||
|
||||
当前生产 inventory 已写入真实绑定:
|
||||
|
||||
| 服务 | CLB | 监听器 | 对外/内网入口 |
|
||||
| --- | --- | --- | --- |
|
||||
| `gateway-service` | `lb-cbiabr1q` | `lbl-ktvz8m6k` / rule `loc-pwedmsmm` | `https://api.global-interaction.com` -> `13000` |
|
||||
| `room-service` | `lb-epvnr4o0` | `lbl-d2urlzba` | `10.2.1.16:13001` |
|
||||
| `user-service` | `lb-epvnr4o0` | `lbl-honi8z3a` | `10.2.1.16:13005` |
|
||||
| `activity-service` | `lb-epvnr4o0` | `lbl-j7oqtfhq` | `10.2.1.16:13006` |
|
||||
| `game-service` | `lb-epvnr4o0` | `lbl-ku138b4y` | `10.2.1.16:13008` |
|
||||
| `wallet-service` | `lb-4f5xi6p0` | `lbl-9wi5mvu4` | `10.2.1.15:13004` |
|
||||
|
||||
Harbor 项目使用 `hyapp`,当前生产镜像前缀是 `10.2.1.3:18082/hyapp`。不要使用其他业务项目名前缀。
|
||||
|
||||
## 线上服务配置边界
|
||||
|
||||
这些机器上只部署 Go 服务进程:
|
||||
|
||||
- `gateway-service`:`room_service_addr/user_service_addr/wallet_service_addr/activity_service_addr/game_service_addr` 指向各服务内网 CLB/DNS。
|
||||
- `room-service`:`mysql_dsn` 指向腾讯云 MySQL,`redis_addr` 指向腾讯云 Redis;`advertise_addr` 指向本机私网 IP。
|
||||
- `user-service`:`mysql_dsn`、worker 里引用的 wallet/room MySQL DSN、登录风控 Redis 都指向腾讯云托管实例。
|
||||
- `wallet-service`、`activity-service`、`game-service`:MySQL 和下游 gRPC 地址都使用腾讯云托管资源或内网 CLB/DNS。
|
||||
- MQ 当前只作为 outbox/event bus 方向的外部托管依赖;本脚本不会创建 topic、consumer group 或本地 broker。
|
||||
|
||||
腾讯云 MySQL DSN 必须保留 `loc=UTC`。Redis 使用同一托管实例时,用 `redis_db` 或 key prefix 区分业务域,不要把 Redis 当恢复事实来源。
|
||||
|
||||
## 常用命令
|
||||
|
||||
先看计划,不执行远端动作:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py plan \
|
||||
--inventory deploy/tencent-tat/inventory.prod.json \
|
||||
--services gateway-service,room-service
|
||||
```
|
||||
|
||||
用 TAT 连接目标机并自动发现真实部署状态和 CLB 绑定:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py discover \
|
||||
--env-file /opt/hy-app-monitor/.env \
|
||||
--inventory /opt/deploy/inventory/hyapp.prod.json \
|
||||
--services gateway-service,room-service,user-service,wallet-service,activity-service,game-service
|
||||
```
|
||||
|
||||
`discover` 会读取每台目标机的 `hyapp-*` systemd unit、`/etc/hyapp/*/docker.env`、`hyapp-*` 容器和监听端口,并通过 CLB API 按后端私网 IP/端口反查负载均衡绑定。
|
||||
|
||||
单服务重启:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py restart \
|
||||
--env-file /opt/hy-app-monitor/.env \
|
||||
--inventory /opt/deploy/inventory/hyapp.prod.json \
|
||||
--services wallet-service \
|
||||
--yes
|
||||
```
|
||||
|
||||
多服务重启:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py restart \
|
||||
--env-file /opt/hy-app-monitor/.env \
|
||||
--inventory /opt/deploy/inventory/hyapp.prod.json \
|
||||
--services room-service,user-service,wallet-service,activity-service,game-service \
|
||||
--yes
|
||||
```
|
||||
|
||||
单服务更新部署:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy \
|
||||
--env-file /opt/hy-app-monitor/.env \
|
||||
--inventory /opt/deploy/inventory/hyapp.prod.json \
|
||||
--services gateway-service \
|
||||
--tag 20260513-1349 \
|
||||
--yes
|
||||
```
|
||||
|
||||
多服务更新部署:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy \
|
||||
--env-file /opt/hy-app-monitor/.env \
|
||||
--inventory /opt/deploy/inventory/hyapp.prod.json \
|
||||
--services gateway-service,room-service,user-service,wallet-service,activity-service,game-service \
|
||||
--tag 20260513-1349 \
|
||||
--yes
|
||||
```
|
||||
|
||||
如果某个服务镜像 tag 不同,可以显式传镜像:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy \
|
||||
--env-file /opt/hy-app-monitor/.env \
|
||||
--inventory /opt/deploy/inventory/hyapp.prod.json \
|
||||
--services gateway-service,wallet-service \
|
||||
--image gateway-service=10.2.1.3:18082/hyapp/gateway-service:gw-20260513 \
|
||||
--image wallet-service=10.2.1.3:18082/hyapp/wallet-service:pay-20260513 \
|
||||
--yes
|
||||
```
|
||||
|
||||
只维护某一台机器时使用 `--hosts`,例如只重启 `gateway-1`:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/hyapp_tat_deploy.py restart \
|
||||
--env-file /opt/hy-app-monitor/.env \
|
||||
--inventory /opt/deploy/inventory/hyapp.prod.json \
|
||||
--services gateway-service \
|
||||
--hosts gateway-1 \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 无感更新顺序
|
||||
|
||||
对每个服务实例,脚本固定执行:
|
||||
|
||||
1. 读取该实例当前 CLB 绑定和原权重。
|
||||
2. 将该实例在所有匹配 listener/rule 下的权重改为 `0`。
|
||||
3. 等待 `drain_seconds`,让 CLB 停止分配新请求。
|
||||
4. 通过 TAT 在目标机更新 `docker.env` 的 `IMAGE`,执行 `docker pull`。
|
||||
5. `systemctl restart hyapp-<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 目录里执行本仓库部署命令。
|
||||
703
deploy/tencent-tat/hyapp_tat_deploy.py
Normal file
703
deploy/tencent-tat/hyapp_tat_deploy.py
Normal 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:]))
|
||||
180
deploy/tencent-tat/inventory.prod.example.json
Normal file
180
deploy/tencent-tat/inventory.prod.example.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
110
deploy/tencent-tat/tests/test_hyapp_tat_deploy.py
Normal file
110
deploy/tencent-tat/tests/test_hyapp_tat_deploy.py
Normal 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()
|
||||
1
deploy/tencent_tat/__init__.py
Normal file
1
deploy/tencent_tat/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Package marker for Tencent TAT deployment helper tests.
|
||||
16
deploy/tencent_tat/hyapp_tat_deploy.py
Normal file
16
deploy/tencent_tat/hyapp_tat_deploy.py
Normal 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
|
||||
@ -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"]
|
||||
|
||||
@ -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,
|
||||
|
||||
161
docs/notice-service架构.md
Normal file
161
docs/notice-service架构.md
Normal 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` 写余额 outbox;notice 不需要新增特殊账务逻辑,只要客户端按 `balance_version` 更新。
|
||||
3. 活动/系统私有通知:新增 `activitynotice` 模块,读取 activity outbox,复用 `notice_delivery_events` 或新增同语义位点表。
|
||||
4. 站内 inbox:新增 inbox 模块,写 notice-service 自己的 inbox 表,再按客户端分页接口读取。
|
||||
5. Push:新增 push 模块,和 IM 投递使用不同 `channel`,独立重试和死信。
|
||||
@ -24,6 +24,17 @@ room-service 实例之间通过 Redis owner route 和 node registry 直连转发
|
||||
- 内部 gRPC 服务只监听私网,安全组只允许服务网段访问 `13xxx` 端口。
|
||||
- `user-service`、`wallet-service`、`activity-service`、`game-service` 可以直接挂内网 CLB 或 DNS。
|
||||
- `room-service` 可以挂内网 CLB/DNS 给 gateway 使用,但 owner 转发必须使用实例级 `advertise_addr`,不能使用 CLB 地址。
|
||||
- MySQL、Redis、MQ 使用腾讯云托管资源,不在业务 CVM 上部署;服务只通过内网 endpoint 访问这些托管依赖。
|
||||
|
||||
当前生产入口:
|
||||
|
||||
| 层级 | 地址 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 公网 API | `https://api.global-interaction.com` | HTTPS 443 由公网 CLB 转发到两台 `gateway-service:13000` |
|
||||
| room/user/activity/game 内网入口 | `10.2.1.16:13001/13005/13006/13008` | `lb-epvnr4o0` 的 Go 服务监听器 |
|
||||
| wallet 内网入口 | `10.2.1.15:13004` | `lb-4f5xi6p0` 的 Go 服务监听器 |
|
||||
|
||||
当前生产镜像仓库前缀是 `10.2.1.3:18082/hyapp`。部署脚本和 systemd env 只允许使用 `hyapp` 项目路径。
|
||||
|
||||
## Room Owner 路由
|
||||
|
||||
@ -91,13 +102,19 @@ lease_ttl: "10s"
|
||||
|
||||
不用统一配置中心也可以上线。推荐发布系统渲染最终 `config.yaml`:
|
||||
|
||||
- 公共环境配置:MySQL/Redis 私网地址、日志等级、腾讯云 endpoint。
|
||||
- 公共环境配置:腾讯云 MySQL/Redis/MQ 私网地址、日志等级、腾讯云 endpoint。
|
||||
- 服务配置:端口、worker 周期、批量大小、超时。
|
||||
- 实例配置:`node_id`、`advertise_addr`、`user-service.id_generator.node_id`。
|
||||
- Secret:MySQL/Redis 密码、JWT secret、腾讯 IM/RTC/COS secret、callback token。
|
||||
|
||||
只有当需要动态配置、灰度下发、审计回滚时,再引入 Consul/Nacos/Apollo。当前代码的启动配置仍以单服务 YAML 为边界。
|
||||
|
||||
托管依赖边界:
|
||||
|
||||
- 腾讯云 MySQL 是业务事实和恢复来源;DSN 必须保留 `loc=UTC`。
|
||||
- 腾讯云 Redis 保存 owner route、node registry、登录风控和短 TTL 缓存;Redis 不能作为业务恢复事实来源。
|
||||
- 腾讯云 MQ 只作为 outbox/event bus 的托管消息通道;创建 topic、consumer group 和权限变更走云产品流程,不混进服务滚动发布。
|
||||
|
||||
## 真实数据验证
|
||||
|
||||
本地真实 MySQL/Redis 验证:
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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 区域。
|
||||
- 管理端修改国家归属后,后续注册和改国家立即按新映射生效。
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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. 前端补角色授权抽屉和菜单权限页编辑能力。
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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})
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -3,7 +3,6 @@ package upload
|
||||
type uploadPolicy struct {
|
||||
allowedExtensions map[string]struct{}
|
||||
kind string
|
||||
maxBytes int64
|
||||
requireImage bool
|
||||
}
|
||||
|
||||
|
||||
@ -20,7 +20,7 @@ func (s *Store) DashboardOverview() (*DashboardOverview, error) {
|
||||
if err := s.db.Model(&model.User{}).Where("status = ?", model.UserStatusDisabled).Count(&disabledUsers).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rolesTotal, menuTotal, logsToday, unreadNotifications int64
|
||||
var rolesTotal, menuTotal, logsToday int64
|
||||
if err := s.db.Model(&model.Role{}).Count(&rolesTotal).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -32,39 +32,19 @@ func (s *Store) DashboardOverview() (*DashboardOverview, error) {
|
||||
if err := s.db.Model(&model.OperationLog{}).Where("created_at_ms >= ?", todayMS).Count(&logsToday).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(&model.Notification{}).Where("read_at_ms IS NULL").Count(&unreadNotifications).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var notifications []model.Notification
|
||||
if err := s.db.Where("read_at_ms IS NULL").Order("created_at_ms DESC").Limit(3).Find(¬ifications).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
alerts := make([]map[string]string, 0, len(notifications))
|
||||
for _, item := range notifications {
|
||||
alerts = append(alerts, map[string]string{
|
||||
"type": item.Level,
|
||||
"name": item.Title,
|
||||
"desc": item.Content,
|
||||
"time": time.UnixMilli(item.CreatedAtMS).UTC().Format("15:04"),
|
||||
})
|
||||
}
|
||||
|
||||
return &DashboardOverview{
|
||||
UsersTotal: int(usersTotal),
|
||||
ActiveUsers: int(activeUsers),
|
||||
LockedUsers: int(lockedUsers),
|
||||
DisabledUsers: int(disabledUsers),
|
||||
RolesTotal: int(rolesTotal),
|
||||
MenuTotal: int(menuTotal),
|
||||
LogsToday: int(logsToday),
|
||||
UnreadNotifications: int(unreadNotifications),
|
||||
UsersTotal: int(usersTotal),
|
||||
ActiveUsers: int(activeUsers),
|
||||
LockedUsers: int(lockedUsers),
|
||||
DisabledUsers: int(disabledUsers),
|
||||
RolesTotal: int(rolesTotal),
|
||||
MenuTotal: int(menuTotal),
|
||||
LogsToday: int(logsToday),
|
||||
Series: map[string][]int{
|
||||
"users": {int(usersTotal), int(activeUsers), int(lockedUsers), int(disabledUsers)},
|
||||
"operations": {int(logsToday)},
|
||||
"notifications": {int(unreadNotifications)},
|
||||
"users": {int(usersTotal), int(activeUsers), int(lockedUsers), int(disabledUsers)},
|
||||
"operations": {int(logsToday)},
|
||||
},
|
||||
Alerts: alerts,
|
||||
UpdatedAtMS: now.UnixMilli(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
13
server/admin/migrations/008_remove_notification_center.sql
Normal file
13
server/admin/migrations/008_remove_notification_center.sql
Normal 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;
|
||||
36
services/notice-service/Dockerfile
Normal file
36
services/notice-service/Dockerfile
Normal 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"]
|
||||
55
services/notice-service/cmd/server/main.go
Normal file
55
services/notice-service/cmd/server/main.go
Normal 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)
|
||||
}
|
||||
32
services/notice-service/configs/config.docker.yaml
Normal file
32
services/notice-service/configs/config.docker.yaml
Normal 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
|
||||
32
services/notice-service/configs/config.tencent.example.yaml
Normal file
32
services/notice-service/configs/config.tencent.example.yaml
Normal 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
|
||||
32
services/notice-service/configs/config.yaml
Normal file
32
services/notice-service/configs/config.yaml
Normal 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
|
||||
@ -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 外部通知投递位点和死信表';
|
||||
173
services/notice-service/internal/app/app.go
Normal file
173
services/notice-service/internal/app/app.go
Normal 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,
|
||||
}
|
||||
}
|
||||
181
services/notice-service/internal/config/config.go
Normal file
181
services/notice-service/internal/config/config.go
Normal 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
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
package walletnotice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func TestMySQLRepositoryProcessesRealWalletOutbox(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("NOTICE_REAL_MYSQL_DSN"))
|
||||
if dsn == "" {
|
||||
t.Skip("set NOTICE_REAL_MYSQL_DSN to run real MySQL notice validation")
|
||||
}
|
||||
walletDatabase := strings.TrimSpace(os.Getenv("NOTICE_REAL_WALLET_DATABASE"))
|
||||
if walletDatabase == "" {
|
||||
walletDatabase = "hyapp_wallet"
|
||||
}
|
||||
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open mysql: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
t.Fatalf("ping mysql: %v", err)
|
||||
}
|
||||
|
||||
repository, err := NewMySQLRepository(db, walletDatabase)
|
||||
if err != nil {
|
||||
t.Fatalf("NewMySQLRepository: %v", err)
|
||||
}
|
||||
nowMs := time.Now().UnixMilli()
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
appCode := "notice_real"
|
||||
userID := int64(990000000)
|
||||
eventID := "notice_real_evt_" + suffix
|
||||
transactionID := "notice_real_tx_" + suffix
|
||||
commandID := "notice_real_cmd_" + suffix
|
||||
payload := map[string]any{
|
||||
"transaction_id": transactionID,
|
||||
"command_id": commandID,
|
||||
"user_id": userID,
|
||||
"asset_type": "COIN",
|
||||
"available_delta": int64(188),
|
||||
"frozen_delta": int64(0),
|
||||
"available_after": int64(99887766),
|
||||
"frozen_after": int64(0),
|
||||
"balance_version": int64(66),
|
||||
"metadata": map[string]any{
|
||||
"validation": "notice-service-real-mysql",
|
||||
},
|
||||
"created_at_ms": nowMs,
|
||||
}
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, fmt.Sprintf(`
|
||||
INSERT INTO %s.wallet_outbox (
|
||||
app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
|
||||
available_delta, frozen_delta, payload, status, retry_count, last_error, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, 'WalletBalanceChanged', ?, ?, ?, 'COIN', 188, 0, ?, 'pending', 0, '', ?, ?)`,
|
||||
quoteDB(walletDatabase),
|
||||
), appCode, eventID, transactionID, commandID, userID, string(payloadJSON), nowMs, nowMs); err != nil {
|
||||
t.Fatalf("insert wallet_outbox: %v", err)
|
||||
}
|
||||
if !keepRealRows() {
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.ExecContext(context.Background(), `DELETE FROM notice_delivery_events WHERE app_code = ? AND source_event_id = ?`, appCode, eventID)
|
||||
_, _ = db.ExecContext(context.Background(), fmt.Sprintf(`DELETE FROM %s.wallet_outbox WHERE app_code = ? AND event_id = ?`, quoteDB(walletDatabase)), appCode, eventID)
|
||||
})
|
||||
}
|
||||
|
||||
publisher := &fakePublisher{}
|
||||
service := New(Config{NodeID: "notice-real-test"}, repository, publisher)
|
||||
processed, err := service.ProcessWalletBalanceNotices(ctx, WalletNoticeWorkerOptions{
|
||||
WorkerID: "notice-real-test-worker",
|
||||
BatchSize: 10,
|
||||
LockTTL: 30 * time.Second,
|
||||
PublishTimeout: time.Second,
|
||||
MaxRetryCount: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessWalletBalanceNotices: %v", err)
|
||||
}
|
||||
if processed != 1 || len(publisher.messages) != 1 {
|
||||
t.Fatalf("unexpected process result: processed=%d messages=%d", processed, len(publisher.messages))
|
||||
}
|
||||
var noticeStatus string
|
||||
var deliveredAtMS int64
|
||||
var storedPayload string
|
||||
if err := db.QueryRowContext(ctx, `
|
||||
SELECT status, delivered_at_ms, COALESCE(CAST(payload_json AS CHAR), '{}')
|
||||
FROM notice_delivery_events
|
||||
WHERE source_name = 'wallet_outbox' AND app_code = ? AND source_event_id = ? AND channel = 'tencent_im_c2c'`,
|
||||
appCode, eventID,
|
||||
).Scan(¬iceStatus, &deliveredAtMS, &storedPayload); err != nil {
|
||||
t.Fatalf("query notice_delivery_events: %v", err)
|
||||
}
|
||||
if noticeStatus != "delivered" || deliveredAtMS <= 0 {
|
||||
t.Fatalf("unexpected delivery marker: status=%s delivered_at_ms=%d payload=%s", noticeStatus, deliveredAtMS, storedPayload)
|
||||
}
|
||||
var noticePayload map[string]any
|
||||
if err := json.Unmarshal([]byte(storedPayload), ¬icePayload); err != nil {
|
||||
t.Fatalf("decode stored notice payload: %v", err)
|
||||
}
|
||||
if noticePayload["event_id"] != eventID || noticePayload["asset_type"] != "COIN" || noticePayload["balance_version"].(float64) != 66 {
|
||||
t.Fatalf("unexpected stored notice payload: %+v", noticePayload)
|
||||
}
|
||||
t.Logf("real notice validation event_id=%s status=%s delivered_at_ms=%d", eventID, noticeStatus, deliveredAtMS)
|
||||
}
|
||||
|
||||
func keepRealRows() bool {
|
||||
value := strings.ToLower(strings.TrimSpace(os.Getenv("NOTICE_REAL_MYSQL_KEEP_ROWS")))
|
||||
return value == "1" || value == "true" || value == "yes"
|
||||
}
|
||||
|
||||
func quoteDB(value string) string {
|
||||
return "`" + strings.ReplaceAll(value, "`", "``") + "`"
|
||||
}
|
||||
@ -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]
|
||||
}
|
||||
147
services/notice-service/internal/modules/walletnotice/service.go
Normal file
147
services/notice-service/internal/modules/walletnotice/service.go
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
48
services/notice-service/internal/platform/mysql/db.go
Normal file
48
services/notice-service/internal/platform/mysql/db.go
Normal 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()
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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 的主数据实体。
|
||||
// 这里只保存注册时形成的用户资料和来源快照,不承载关系链、房间内临时态或连接态。
|
||||
|
||||
21
services/user-service/internal/domain/user/user_test.go
Normal file
21
services/user-service/internal/domain/user/user_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user