完善部署
This commit is contained in:
parent
dbc5c5ec88
commit
fb1789f97f
@ -18,6 +18,7 @@
|
|||||||
- 腾讯云 IM 负责客户端长连接、房间群消息、公屏、单聊、离线/漫游和全球接入;
|
- 腾讯云 IM 负责客户端长连接、房间群消息、公屏、单聊、离线/漫游和全球接入;
|
||||||
- `wallet-service` 负责账务语义。`SendGift` 必须先扣费成功,再落房间表现。
|
- `wallet-service` 负责账务语义。`SendGift` 必须先扣费成功,再落房间表现。
|
||||||
- `activity-service` 通过 room outbox 事件消费,不侵入 Room Cell 主链路。
|
- `activity-service` 通过 room outbox 事件消费,不侵入 Room Cell 主链路。
|
||||||
|
- `notice-service` 负责从各 owner service 的 outbox/事件源读取事实并投递私有通知、腾讯云 IM 单聊、后续 push/inbox;钱包、活动和房间不能直接耦合私有通知投递实现。
|
||||||
- `user-service` 负责用户主数据,不应该成为房间高频流程的同步中心。
|
- `user-service` 负责用户主数据,不应该成为房间高频流程的同步中心。
|
||||||
- `cron-service` 只负责后台调度、任务运行记录和通过内部 gRPC 触发 owner service 批处理;不能直接拥有房间、账务、用户或活动业务状态。
|
- `cron-service` 只负责后台调度、任务运行记录和通过内部 gRPC 触发 owner service 批处理;不能直接拥有房间、账务、用户或活动业务状态。
|
||||||
- `server/admin` 是后台管理服务独立 module,负责管理员、RBAC、菜单、审计和后台 HTTP API;访问 app 后端只能通过 `hyapp.local/api` 生成的 protobuf client 或明确的集成接口。
|
- `server/admin` 是后台管理服务独立 module,负责管理员、RBAC、菜单、审计和后台 HTTP API;访问 app 后端只能通过 `hyapp.local/api` 生成的 protobuf client 或明确的集成接口。
|
||||||
@ -62,6 +63,8 @@
|
|||||||
- `13005`: user gRPC
|
- `13005`: user gRPC
|
||||||
- `13006`: activity gRPC
|
- `13006`: activity gRPC
|
||||||
- `13007`: cron gRPC
|
- `13007`: cron gRPC
|
||||||
|
- `13008`: game gRPC
|
||||||
|
- `13009`: notice gRPC
|
||||||
- `23306`: local Docker MySQL host port
|
- `23306`: local Docker MySQL host port
|
||||||
- `13379`: local Docker Redis host port
|
- `13379`: local Docker Redis host port
|
||||||
|
|
||||||
@ -73,7 +76,7 @@
|
|||||||
- 手写代码保持高密度有效注释,注释解释工程语义和失败分支,不复述语法。
|
- 手写代码保持高密度有效注释,注释解释工程语义和失败分支,不复述语法。
|
||||||
- 新增配置必须同时考虑本地 `config.yaml`、Docker `config.docker.yaml`、以及线上配置示例。
|
- 新增配置必须同时考虑本地 `config.yaml`、Docker `config.docker.yaml`、以及线上配置示例。
|
||||||
- 新增服务依赖必须在 `docker-compose.yml` 中体现本地可运行形态。
|
- 新增服务依赖必须在 `docker-compose.yml` 中体现本地可运行形态。
|
||||||
- 数据库表结构变更必须同步更新 `services/room-service/deploy/mysql/initdb`。
|
- 数据库表结构变更必须同步更新对应 owner service 的 `deploy/mysql/initdb`。
|
||||||
- 不要把线上密钥、云数据库密码、Redis 密码写进真实配置。只能写占位示例。
|
- 不要把线上密钥、云数据库密码、Redis 密码写进真实配置。只能写占位示例。
|
||||||
- 保持 `gofmt`,不要手调 Go import 顺序。
|
- 保持 `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
|
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 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 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_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
|
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)
|
.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 \
|
@if [ -z "$(SERVICE_ID)" ]; then \
|
||||||
docker compose up -d mysql redis; \
|
docker compose up -d mysql redis; \
|
||||||
./scripts/apply-local-mysql-initdb.sh; \
|
./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 \
|
elif [ "$(SERVICE_ID)" = "mysql" ] || [ "$(SERVICE_ID)" = "redis" ]; then \
|
||||||
docker compose up -d $(SERVICE_ID); \
|
docker compose up -d $(SERVICE_ID); \
|
||||||
else \
|
else \
|
||||||
@ -140,13 +140,13 @@ down:
|
|||||||
check-service:
|
check-service:
|
||||||
@if [ -z "$(SERVICE_ID)" ]; then \
|
@if [ -z "$(SERVICE_ID)" ]; then \
|
||||||
echo "Service is required. Example: make restart rs"; \
|
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)"; \
|
echo "Services: $(COMPOSE_SERVICES)"; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
@if ! printf '%s\n' $(COMPOSE_SERVICES) | grep -qx "$(SERVICE_ID)"; then \
|
@if ! printf '%s\n' $(COMPOSE_SERVICES) | grep -qx "$(SERVICE_ID)"; then \
|
||||||
echo "Unknown service: $(SERVICE_ID)"; \
|
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)"; \
|
echo "Services: $(COMPOSE_SERVICES)"; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
|
|||||||
24
README.md
24
README.md
@ -1,6 +1,6 @@
|
|||||||
# HY Voice Room
|
# HY Voice Room
|
||||||
|
|
||||||
HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连接服务:房间业务状态由 `room-service` 承担,客户端实时消息接入腾讯云 IM,服务端只负责签发 UserSig、创建房间群组、发送房间系统消息。
|
HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连接服务:房间业务状态由 `room-service` 承担,客户端实时消息接入腾讯云 IM;服务端负责签发 UserSig、房间群消息桥接,以及通过 `notice-service` 异步投递用户私有通知。
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@ -14,6 +14,7 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
|
|||||||
- `user-service` 当前承接登录、三方注册、密码、refresh session、用户主数据、默认短号和临时靓号基础能力。
|
- `user-service` 当前承接登录、三方注册、密码、refresh session、用户主数据、默认短号和临时靓号基础能力。
|
||||||
- `activity-service` 当前承接每日任务、活动/系统消息基础能力和房间事件 consumer 边界;`room_outbox` 到 activity/audit 的实际消费链路按独立消费位点接入。
|
- `activity-service` 当前承接每日任务、活动/系统消息基础能力和房间事件 consumer 边界;`room_outbox` 到 activity/audit 的实际消费链路按独立消费位点接入。
|
||||||
- `cron-service` 当前负责后台调度、任务运行记录,以及通过内部 gRPC 触发 owner service 批处理;它不直接拥有房间、账务、用户或活动业务状态。
|
- `cron-service` 当前负责后台调度、任务运行记录,以及通过内部 gRPC 触发 owner service 批处理;它不直接拥有房间、账务、用户或活动业务状态。
|
||||||
|
- `notice-service` 当前消费 `wallet_outbox` 的余额变更事件,异步投递腾讯云 IM 单聊私有通知;后续 push、站内 inbox 和其它 owner outbox 消费按模块继续扩展。
|
||||||
- 腾讯云 IM 承担客户端长连接、群消息、公屏、单聊、离线/漫游和全球接入能力;本仓库不再部署 `im-service`。
|
- 腾讯云 IM 承担客户端长连接、群消息、公屏、单聊、离线/漫游和全球接入能力;本仓库不再部署 `im-service`。
|
||||||
- 多 App 共用同一套后端服务,入口由 `gateway-service` 通过包名或 `app_code` 解析租户;所有业务库表按 `app_code` 隔离,设计细节见 `docs/多App租户架构.md`。
|
- 多 App 共用同一套后端服务,入口由 `gateway-service` 通过包名或 `app_code` 解析租户;所有业务库表按 `app_code` 隔离,设计细节见 `docs/多App租户架构.md`。
|
||||||
|
|
||||||
@ -46,6 +47,8 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
|
|||||||
| `user-service` | `13005` | gRPC |
|
| `user-service` | `13005` | gRPC |
|
||||||
| `activity-service` | `13006` | gRPC |
|
| `activity-service` | `13006` | gRPC |
|
||||||
| `cron-service` | `13007` | gRPC |
|
| `cron-service` | `13007` | gRPC |
|
||||||
|
| `game-service` | `13008` | gRPC |
|
||||||
|
| `notice-service` | `13009` | gRPC |
|
||||||
| MySQL | `23306 -> 3306` | local Docker only |
|
| MySQL | `23306 -> 3306` | local Docker only |
|
||||||
| Redis | `13379 -> 6379` | local Docker only |
|
| Redis | `13379 -> 6379` | local Docker only |
|
||||||
|
|
||||||
@ -57,7 +60,7 @@ Use Docker for local and test environments:
|
|||||||
make up
|
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:
|
Common commands:
|
||||||
|
|
||||||
@ -74,7 +77,7 @@ make down
|
|||||||
|
|
||||||
`make admin-up` 会先启动后台本地依赖 MySQL、Redis 和 `user-service`,再以前台进程启动 `server/admin`。依赖已经存在时,可以直接运行 `make admin`。
|
`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
|
## Tencent IM
|
||||||
|
|
||||||
@ -102,8 +105,9 @@ Authorization: Bearer <access_token>
|
|||||||
- 客户端再用腾讯云 IM SDK 登录并加入 `room_id` 对应的群组。
|
- 客户端再用腾讯云 IM SDK 登录并加入 `room_id` 对应的群组。
|
||||||
- `gateway-service` 创建房间时从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id`;`room-service` 由该 actor 收敛 owner,外部 CreateRoom 请求体不接收 `owner_user_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` 由前端按用户动作生成,用于服务端幂等。
|
- `X-Request-ID`/`request_id` 由后端生成,只做链路追踪;写操作的 `command_id` 由前端按用户动作生成,用于服务端幂等。
|
||||||
- `room-service` 在 `CreateRoom` 时通过腾讯云 IM REST API 创建同名群组。
|
- `room-service` 在 `CreateRoom` 成功后写 `room_outbox`,由 outbox worker 异步创建或补偿同名腾讯云 IM 群组。
|
||||||
- `room-service` 在上下麦、禁言、踢人、送礼等命令提交后,通过腾讯云 IM REST API 发送房间系统自定义消息。
|
- `room-service` 在上下麦、禁言、踢人、送礼等命令提交后只写房间状态、command log 和 outbox;腾讯云 IM 房间系统自定义消息由 outbox worker 异步投递。
|
||||||
|
- `notice-service` 消费钱包余额变更 outbox,向单个用户发送 `wallet_notice` 私有自定义消息;钱包不会直接调用腾讯云 IM。
|
||||||
- `gateway-service` 已提供 `/api/v1/tencent-im/callback`,支持腾讯云 IM 入群前和发言前回调,并分别回查 `VerifyRoomPresence` 和 `CheckSpeakPermission`。
|
- `gateway-service` 已提供 `/api/v1/tencent-im/callback`,支持腾讯云 IM 入群前和发言前回调,并分别回查 `VerifyRoomPresence` 和 `CheckSpeakPermission`。
|
||||||
|
|
||||||
腾讯云 IM 配置分别在:
|
腾讯云 IM 配置分别在:
|
||||||
@ -112,14 +116,17 @@ Authorization: Bearer <access_token>
|
|||||||
services/gateway-service/configs/config.yaml
|
services/gateway-service/configs/config.yaml
|
||||||
services/room-service/configs/config.yaml
|
services/room-service/configs/config.yaml
|
||||||
services/room-service/configs/config.tencent.example.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
|
```text
|
||||||
docs/语音房基础闭环实现.md
|
docs/语音房基础闭环实现.md
|
||||||
|
docs/notice-service架构.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Storage Model
|
## Storage Model
|
||||||
@ -131,6 +138,8 @@ MySQL 是本地和线上运行的持久化底座:
|
|||||||
- `wallet-service`: `hyapp_wallet`,保存余额、账务流水、礼物价格和扣费幂等记录,业务行按 `app_code` 隔离。
|
- `wallet-service`: `hyapp_wallet`,保存余额、账务流水、礼物价格和扣费幂等记录,业务行按 `app_code` 隔离。
|
||||||
- `activity-service`: `hyapp_activity`,保存任务定义、任务进度、奖励领取、活动状态、事件消费幂等和 activity outbox,业务行按 `app_code` 隔离。
|
- `activity-service`: `hyapp_activity`,保存任务定义、任务进度、奖励领取、活动状态、事件消费幂等和 activity outbox,业务行按 `app_code` 隔离。
|
||||||
- `cron-service`: `hyapp_cron`,保存后台任务定义、运行记录和调度锁;实际业务批处理仍由各 owner service 执行。
|
- `cron-service`: `hyapp_cron`,保存后台任务定义、运行记录和调度锁;实际业务批处理仍由各 owner service 执行。
|
||||||
|
- `game-service`: `hyapp_game`,保存游戏平台、房间游戏会话和游戏交易映射。
|
||||||
|
- `notice-service`: `hyapp_notice`,保存外部通知投递位点、重试锁和死信状态;业务事实仍留在 owner service outbox。
|
||||||
- `hyapp_admin`: 后台管理后端独立库,只保存后台账号、权限和审计;不要把后台审计耦合进 App 业务库。
|
- `hyapp_admin`: 后台管理后端独立库,只保存后台账号、权限和审计;不要把后台审计耦合进 App 业务库。
|
||||||
|
|
||||||
`room-service` uses MySQL as the room recovery durable source:
|
`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/user-service/ user service
|
||||||
services/activity-service/ activity foundation and room event consumer boundary
|
services/activity-service/ activity foundation and room event consumer boundary
|
||||||
services/cron-service/ background scheduling and owner-service batch trigger
|
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
|
## 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。
|
- 腾讯云 IM 回调入口已经落在 gateway;线上仍必须在腾讯云 IM 控制台启用 `Group.CallbackBeforeApplyJoinGroup` 和 `Group.CallbackBeforeSendMsg`,并配置公网 callback URL 与鉴权 token。
|
||||||
- 前端对同一次用户写动作的 HTTP 重试必须复用同一个 `command_id`;`X-Request-ID` 不需要也不应该由前端传入。
|
- 前端对同一次用户写动作的 HTTP 重试必须复用同一个 `command_id`;`X-Request-ID` 不需要也不应该由前端传入。
|
||||||
- `wallet-service` App 运行路径使用 MySQL 扣费事务;本地需要先通过 compose initdb 建好 `hyapp_wallet`。
|
- `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 的实际消费链路仍需按独立消费位点补齐。
|
- `activity-service` 已承接每日任务和消息/活动基础能力;`room_outbox` 到 activity/audit 的实际消费链路仍需按独立消费位点补齐。
|
||||||
- `room-service` App 已启动 `room_outbox` 补偿 worker;该状态只表示腾讯云 IM 系统消息补偿结果,activity/audit 后续必须使用独立消费位点。
|
- `room-service` App 已启动 `room_outbox` 补偿 worker;该状态只表示腾讯云 IM 系统消息补偿结果,activity/audit 后续必须使用独立消费位点。
|
||||||
- JWT、腾讯云 IM secret 等本地配置只能用开发值,线上必须走安全配置源。
|
- 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_admin.* TO 'hyapp'@'%';
|
||||||
GRANT ALL PRIVILEGES ON hyapp_cron.* TO 'hyapp'@'%';
|
GRANT ALL PRIVILEGES ON hyapp_cron.* TO 'hyapp'@'%';
|
||||||
GRANT ALL PRIVILEGES ON hyapp_game.* TO 'hyapp'@'%';
|
GRANT ALL PRIVILEGES ON hyapp_game.* TO 'hyapp'@'%';
|
||||||
|
GRANT ALL PRIVILEGES ON hyapp_notice.* TO 'hyapp'@'%';
|
||||||
|
|
||||||
FLUSH PRIVILEGES;
|
FLUSH PRIVILEGES;
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
本目录提供不依赖 Kubernetes 的生产部署模板。目标形态是 systemd 托管 Docker 容器,公网 CLB 只接 gateway,内部服务通过内网 CLB/DNS 或固定私网地址访问。
|
本目录提供不依赖 Kubernetes 的生产部署模板。目标形态是 systemd 托管 Docker 容器,公网 CLB 只接 gateway,内部服务通过内网 CLB/DNS 或固定私网地址访问。
|
||||||
|
|
||||||
|
生产环境 MySQL、Redis、MQ 使用腾讯云托管资源。本目录只管理本仓库 Go 服务容器,不在业务 CVM 上安装或重启 MySQL/Redis/MQ。
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `systemd/*.service`: 每个后端服务一份 systemd unit,负责启动、停止和故障重启 Docker 容器。
|
- `systemd/*.service`: 每个后端服务一份 systemd unit,负责启动、停止和故障重启 Docker 容器。
|
||||||
@ -53,7 +55,32 @@ sudo docker inspect --format '{{json .State.Health}}' hyapp-gateway-service
|
|||||||
5. 从 CLB 摘除旧实例,等待连接保护或业务 drain 窗口结束。
|
5. 从 CLB 摘除旧实例,等待连接保护或业务 drain 窗口结束。
|
||||||
6. `systemctl stop hyapp-<service>`,Docker 会先发送 SIGTERM,服务内的 graceful shutdown 会进入 draining 并等待已进入请求结束。
|
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
|
## Service Discovery
|
||||||
|
|
||||||
@ -69,3 +96,4 @@ game_service_addr: "game.service.internal:13008"
|
|||||||
|
|
||||||
如果后续引入 Consul/Nacos,仍建议让业务配置只保存稳定服务名,不把实例 IP 写进每个服务配置。
|
如果后续引入 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 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
|
CONTAINER_NAME=hyapp-activity-service
|
||||||
CONFIG_PATH=/etc/hyapp/activity-service/config.yaml
|
CONFIG_PATH=/etc/hyapp/activity-service/config.yaml
|
||||||
STOP_TIMEOUT_SEC=60
|
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.
|
# 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
|
CONTAINER_NAME=hyapp-cron-service
|
||||||
CONFIG_PATH=/etc/hyapp/cron-service/config.yaml
|
CONFIG_PATH=/etc/hyapp/cron-service/config.yaml
|
||||||
STOP_TIMEOUT_SEC=90
|
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
|
CONTAINER_NAME=hyapp-game-service
|
||||||
CONFIG_PATH=/etc/hyapp/game-service/config.yaml
|
CONFIG_PATH=/etc/hyapp/game-service/config.yaml
|
||||||
STOP_TIMEOUT_SEC=60
|
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.
|
# 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
|
CONTAINER_NAME=hyapp-gateway-service
|
||||||
CONFIG_PATH=/etc/hyapp/gateway-service/config.yaml
|
CONFIG_PATH=/etc/hyapp/gateway-service/config.yaml
|
||||||
STOP_TIMEOUT_SEC=60
|
STOP_TIMEOUT_SEC=60
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
# Keep room-service single-active until owner routing or non-owner forwarding is implemented.
|
# 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
|
CONTAINER_NAME=hyapp-room-service
|
||||||
CONFIG_PATH=/etc/hyapp/room-service/config.yaml
|
CONFIG_PATH=/etc/hyapp/room-service/config.yaml
|
||||||
STOP_TIMEOUT_SEC=90
|
STOP_TIMEOUT_SEC=90
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
# id_generator.node_id in config.yaml must be unique for every active user-service instance.
|
# 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
|
CONTAINER_NAME=hyapp-user-service
|
||||||
CONFIG_PATH=/etc/hyapp/user-service/config.yaml
|
CONFIG_PATH=/etc/hyapp/user-service/config.yaml
|
||||||
STOP_TIMEOUT_SEC=60
|
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
|
CONTAINER_NAME=hyapp-wallet-service
|
||||||
CONFIG_PATH=/etc/hyapp/wallet-service/config.yaml
|
CONFIG_PATH=/etc/hyapp/wallet-service/config.yaml
|
||||||
STOP_TIMEOUT_SEC=60
|
STOP_TIMEOUT_SEC=60
|
||||||
|
|||||||
@ -9,9 +9,9 @@ StartLimitBurst=3
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
EnvironmentFile=/etc/hyapp/activity-service/docker.env
|
EnvironmentFile=/etc/hyapp/activity-service/docker.env
|
||||||
ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME}
|
ExecStartPre=-/usr/bin/env 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}
|
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/docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
TimeoutStartSec=60
|
TimeoutStartSec=60
|
||||||
|
|||||||
@ -9,9 +9,9 @@ StartLimitBurst=3
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
EnvironmentFile=/etc/hyapp/cron-service/docker.env
|
EnvironmentFile=/etc/hyapp/cron-service/docker.env
|
||||||
ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME}
|
ExecStartPre=-/usr/bin/env 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}
|
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/docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
TimeoutStartSec=60
|
TimeoutStartSec=60
|
||||||
|
|||||||
@ -9,9 +9,9 @@ StartLimitBurst=3
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
EnvironmentFile=/etc/hyapp/game-service/docker.env
|
EnvironmentFile=/etc/hyapp/game-service/docker.env
|
||||||
ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME}
|
ExecStartPre=-/usr/bin/env 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}
|
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/docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
TimeoutStartSec=60
|
TimeoutStartSec=60
|
||||||
|
|||||||
@ -9,9 +9,9 @@ StartLimitBurst=3
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
EnvironmentFile=/etc/hyapp/gateway-service/docker.env
|
EnvironmentFile=/etc/hyapp/gateway-service/docker.env
|
||||||
ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME}
|
ExecStartPre=-/usr/bin/env 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}
|
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/docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
TimeoutStartSec=60
|
TimeoutStartSec=60
|
||||||
|
|||||||
@ -9,9 +9,9 @@ StartLimitBurst=3
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
EnvironmentFile=/etc/hyapp/room-service/docker.env
|
EnvironmentFile=/etc/hyapp/room-service/docker.env
|
||||||
ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME}
|
ExecStartPre=-/usr/bin/env 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}
|
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/docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
TimeoutStartSec=60
|
TimeoutStartSec=60
|
||||||
|
|||||||
@ -9,9 +9,9 @@ StartLimitBurst=3
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
EnvironmentFile=/etc/hyapp/user-service/docker.env
|
EnvironmentFile=/etc/hyapp/user-service/docker.env
|
||||||
ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME}
|
ExecStartPre=-/usr/bin/env 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}
|
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/docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
TimeoutStartSec=60
|
TimeoutStartSec=60
|
||||||
|
|||||||
@ -9,9 +9,9 @@ StartLimitBurst=3
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
EnvironmentFile=/etc/hyapp/wallet-service/docker.env
|
EnvironmentFile=/etc/hyapp/wallet-service/docker.env
|
||||||
ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME}
|
ExecStartPre=-/usr/bin/env 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}
|
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/docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
TimeoutStartSec=60
|
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
|
retries: 20
|
||||||
start_period: 10s
|
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:
|
mysql:
|
||||||
image: mysql:8.4
|
image: mysql:8.4
|
||||||
container_name: hyapp-mysql
|
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
|
- ./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/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/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
|
- ./deploy/mysql/initdb/999_local_grants.sql:/docker-entrypoint-initdb.d/999_local_grants.sql:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"]
|
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"]
|
||||||
|
|||||||
@ -1403,42 +1403,6 @@
|
|||||||
"summary": "navigationMenus",
|
"summary": "navigationMenus",
|
||||||
"tag": "admin"
|
"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",
|
"source": "admin",
|
||||||
"executable": true,
|
"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` 端口。
|
- 内部 gRPC 服务只监听私网,安全组只允许服务网段访问 `13xxx` 端口。
|
||||||
- `user-service`、`wallet-service`、`activity-service`、`game-service` 可以直接挂内网 CLB 或 DNS。
|
- `user-service`、`wallet-service`、`activity-service`、`game-service` 可以直接挂内网 CLB 或 DNS。
|
||||||
- `room-service` 可以挂内网 CLB/DNS 给 gateway 使用,但 owner 转发必须使用实例级 `advertise_addr`,不能使用 CLB 地址。
|
- `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 路由
|
## Room Owner 路由
|
||||||
|
|
||||||
@ -91,13 +102,19 @@ lease_ttl: "10s"
|
|||||||
|
|
||||||
不用统一配置中心也可以上线。推荐发布系统渲染最终 `config.yaml`:
|
不用统一配置中心也可以上线。推荐发布系统渲染最终 `config.yaml`:
|
||||||
|
|
||||||
- 公共环境配置:MySQL/Redis 私网地址、日志等级、腾讯云 endpoint。
|
- 公共环境配置:腾讯云 MySQL/Redis/MQ 私网地址、日志等级、腾讯云 endpoint。
|
||||||
- 服务配置:端口、worker 周期、批量大小、超时。
|
- 服务配置:端口、worker 周期、批量大小、超时。
|
||||||
- 实例配置:`node_id`、`advertise_addr`、`user-service.id_generator.node_id`。
|
- 实例配置:`node_id`、`advertise_addr`、`user-service.id_generator.node_id`。
|
||||||
- Secret:MySQL/Redis 密码、JWT secret、腾讯 IM/RTC/COS secret、callback token。
|
- Secret:MySQL/Redis 密码、JWT secret、腾讯 IM/RTC/COS secret、callback token。
|
||||||
|
|
||||||
只有当需要动态配置、灰度下发、审计回滚时,再引入 Consul/Nacos/Apollo。当前代码的启动配置仍以单服务 YAML 为边界。
|
只有当需要动态配置、灰度下发、审计回滚时,再引入 Consul/Nacos/Apollo。当前代码的启动配置仍以单服务 YAML 为边界。
|
||||||
|
|
||||||
|
托管依赖边界:
|
||||||
|
|
||||||
|
- 腾讯云 MySQL 是业务事实和恢复来源;DSN 必须保留 `loc=UTC`。
|
||||||
|
- 腾讯云 Redis 保存 owner route、node registry、登录风控和短 TTL 缓存;Redis 不能作为业务恢复事实来源。
|
||||||
|
- 腾讯云 MQ 只作为 outbox/event bus 的托管消息通道;创建 topic、consumer group 和权限变更走云产品流程,不混进服务滚动发布。
|
||||||
|
|
||||||
## 真实数据验证
|
## 真实数据验证
|
||||||
|
|
||||||
本地真实 MySQL/Redis 验证:
|
本地真实 MySQL/Redis 验证:
|
||||||
|
|||||||
@ -200,10 +200,6 @@
|
|||||||
| GET | `/api/v1/logs/operations` | admin | `listOperationLogs` | listOperationLogs |
|
| GET | `/api/v1/logs/operations` | admin | `listOperationLogs` | listOperationLogs |
|
||||||
| GET | `/api/v1/logs/operations/export` | admin | `exportOperationLogs` | exportOperationLogs |
|
| GET | `/api/v1/logs/operations/export` | admin | `exportOperationLogs` | exportOperationLogs |
|
||||||
| GET | `/api/v1/navigation/menus` | admin | `navigationMenus` | navigationMenus |
|
| 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 |
|
| GET | `/api/v1/permissions` | admin | `listPermissions` | listPermissions |
|
||||||
| POST | `/api/v1/permissions` | admin | `createPermission` | createPermission |
|
| POST | `/api/v1/permissions` | admin | `createPermission` | createPermission |
|
||||||
| DELETE | `/api/v1/permissions/{id}` | admin | `deletePermission` | deletePermission |
|
| DELETE | `/api/v1/permissions/{id}` | admin | `deletePermission` | deletePermission |
|
||||||
|
|||||||
@ -92,9 +92,9 @@
|
|||||||
| `flag` | yes | App 展示用 emoji flag |
|
| `flag` | yes | App 展示用 emoji flag |
|
||||||
| `status` | yes | 默认 `active` |
|
| `status` | yes | 默认 `active` |
|
||||||
| `sort_order` | yes | App 注册页排序 |
|
| `sort_order` | yes | App 注册页排序 |
|
||||||
| `region` | no | 地理大区,例如 `Asia`、`Africa`,只用于后续国家到业务区域的初始映射建议 |
|
| `region` | no | 地理大区,例如 `Asia`、`Africa`,只用于运营筛选和后续人工调整参考 |
|
||||||
| `region_display_name` | no | `region` 的中文展示名,例如 `亚洲`、`非洲`,只用于配置和运营展示 |
|
| `region_display_name` | no | `region` 的中文展示名,例如 `亚洲`、`非洲`,只用于配置和运营展示 |
|
||||||
| `subregion` | no | 地理子区域,例如 `South-Eastern Asia`,只用于后续映射规则或运营筛选 |
|
| `subregion` | no | 地理子区域,例如 `South-Eastern Asia`,只用于运营筛选和后续人工调整参考 |
|
||||||
| `subregion_display_name` | no | `subregion` 的中文展示名,例如 `东南亚`、`西亚`,只用于配置和运营展示 |
|
| `subregion_display_name` | no | `subregion` 的中文展示名,例如 `东南亚`、`西亚`,只用于配置和运营展示 |
|
||||||
|
|
||||||
不再保留这些源数据字段:
|
不再保留这些源数据字段:
|
||||||
@ -110,10 +110,9 @@ source_assignment_status
|
|||||||
当前开发态初始化会把种子数据导入三张表:
|
当前开发态初始化会把种子数据导入三张表:
|
||||||
|
|
||||||
- `countries` 写入国家主数据字段,不写 `region/subregion` 文本。
|
- `countries` 写入国家主数据字段,不写 `region/subregion` 文本。
|
||||||
- `regions.region_code` 使用 `subregion` 原值,`regions.name` 使用 `subregion_display_name`。
|
- `regions` 只初始化三个后台可管理业务区:`MIDDLE_EAST`/中东区、`INDIA`/印度区、`EURASIA_AMERICA`/欧亚美区。
|
||||||
- `region_countries` 按每个国家的 `subregion` 建立 active 映射。
|
- `region_countries` 按产品分桶写 active 映射:中东国家进入中东区,印度相关国家进入印度区,其余已开放国家进入欧亚美区。
|
||||||
- 一级 `region/region_display_name` 不入库,不参与业务区域判断。
|
- `GLOBAL` 只作为用户没有显式区域时的内部兜底,不出现在后台区域管理列表。
|
||||||
- 没有 `subregion` 的少数地区统一落到 `UNSPECIFIED`,避免注册和用户投影出现无区域主记录。
|
|
||||||
|
|
||||||
### Region
|
### Region
|
||||||
|
|
||||||
@ -124,7 +123,7 @@ source_assignment_status
|
|||||||
| Field | Rule |
|
| Field | Rule |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `region_id` | 服务端生成的内部主键,不给客户端提交 |
|
| `region_id` | 服务端生成的内部主键,不给客户端提交 |
|
||||||
| `region_code` | 稳定业务编码;当前初始化直接使用 `countries.json.subregion`,例如 `South-Eastern Asia` |
|
| `region_code` | 稳定业务编码;当前初始化使用 `MIDDLE_EAST`、`INDIA`、`EURASIA_AMERICA` |
|
||||||
| `name` | 管理端展示名 |
|
| `name` | 管理端展示名 |
|
||||||
| `status` | `active` 或 `disabled` |
|
| `status` | `active` 或 `disabled` |
|
||||||
| `countries` | 国家码列表,元素必须存在于 `countries.country_code` |
|
| `countries` | 国家码列表,元素必须存在于 `countries.country_code` |
|
||||||
@ -135,7 +134,7 @@ source_assignment_status
|
|||||||
|
|
||||||
- 只要国家记录存在即可配置到区域,`enabled=false` 只影响 App 用户选择。
|
- 只要国家记录存在即可配置到区域,`enabled=false` 只影响 App 用户选择。
|
||||||
- 一个国家最多只能属于一个 active 区域。
|
- 一个国家最多只能属于一个 active 区域。
|
||||||
- 国家不属于任何区域时,用户 `region_id` 为空。
|
- 种子内的开放国家都会归属到一个 active 业务区;没有显式映射时才使用 `GLOBAL` 内部兜底。
|
||||||
- 区域停用后,该区域下国家不再产生归属。
|
- 区域停用后,该区域下国家不再产生归属。
|
||||||
- 区域停用必须释放 active 国家归属,或把映射标记为 inactive,确保这些国家可以重新配置到其他 active 区域。
|
- 区域停用必须释放 active 国家归属,或把映射标记为 inactive,确保这些国家可以重新配置到其他 active 区域。
|
||||||
- 管理端修改国家归属后,后续注册和改国家立即按新映射生效。
|
- 管理端修改国家归属后,后续注册和改国家立即按新映射生效。
|
||||||
|
|||||||
@ -24,6 +24,8 @@ const (
|
|||||||
createGroupCommand = "v4/group_open_http_svc/create_group"
|
createGroupCommand = "v4/group_open_http_svc/create_group"
|
||||||
// sendGroupMsgCommand 是服务端发群消息 REST 命令;播报只走服务端管理员账号,不开放客户端直发。
|
// sendGroupMsgCommand 是服务端发群消息 REST 命令;播报只走服务端管理员账号,不开放客户端直发。
|
||||||
sendGroupMsgCommand = "v4/group_open_http_svc/send_group_msg"
|
sendGroupMsgCommand = "v4/group_open_http_svc/send_group_msg"
|
||||||
|
// sendC2CMsgCommand 是服务端给单个用户发自定义消息的 REST 命令;钱包余额变更这类私有事件只能走单聊面。
|
||||||
|
sendC2CMsgCommand = "v4/openim/sendmsg"
|
||||||
// destroyGroupCommand 用于真实 IM smoke test 或明确运维动作的临时群清理,业务代码不应自动解散播报群。
|
// destroyGroupCommand 用于真实 IM smoke test 或明确运维动作的临时群清理,业务代码不应自动解散播报群。
|
||||||
destroyGroupCommand = "v4/group_open_http_svc/destroy_group"
|
destroyGroupCommand = "v4/group_open_http_svc/destroy_group"
|
||||||
// deleteGroupMemberCommand 用于把已离房用户从腾讯群里移除,防止 IM 群成员残留扩大消息面。
|
// deleteGroupMemberCommand 用于把已离房用户从腾讯群里移除,防止 IM 群成员残留扩大消息面。
|
||||||
@ -70,6 +72,17 @@ type CustomGroupMessage struct {
|
|||||||
PayloadJSON json.RawMessage
|
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 的房间系统消息负载。
|
// RoomEvent 是 room-service 发给腾讯 IM 的房间系统消息负载。
|
||||||
type RoomEvent struct {
|
type RoomEvent struct {
|
||||||
EventID string `json:"event_id"`
|
EventID string `json:"event_id"`
|
||||||
@ -236,6 +249,50 @@ func (c *RESTClient) PublishGroupCustomMessage(ctx context.Context, message Cust
|
|||||||
return response.err()
|
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 群。
|
// DestroyGroup 解散一个腾讯云 IM 群。
|
||||||
// 生产播报群不应该在业务流里自动调用该方法;它主要用于真实 IM 测试后的临时群清理或显式运维操作。
|
// 生产播报群不应该在业务流里自动调用该方法;它主要用于真实 IM 测试后的临时群清理或显式运维操作。
|
||||||
func (c *RESTClient) DestroyGroup(ctx context.Context, groupID string) error {
|
func (c *RESTClient) DestroyGroup(ctx context.Context, groupID string) error {
|
||||||
@ -396,6 +453,16 @@ type sendGroupMsgRequest struct {
|
|||||||
FromAccount string `json:"From_Account,omitempty"`
|
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 {
|
type destroyGroupRequest struct {
|
||||||
GroupID string `json:"GroupId"`
|
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 请求。
|
// TestRESTClientDestroyGroupBuildsTencentRequest 锁定临时群清理的 REST 请求。
|
||||||
func TestRESTClientDestroyGroupBuildsTencentRequest(t *testing.T) {
|
func TestRESTClientDestroyGroupBuildsTencentRequest(t *testing.T) {
|
||||||
var capturedPath string
|
var capturedPath string
|
||||||
|
|||||||
@ -20,6 +20,7 @@ SQL_FILES=(
|
|||||||
"deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql"
|
"deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql"
|
||||||
"services/cron-service/deploy/mysql/initdb/001_cron_service.sql"
|
"services/cron-service/deploy/mysql/initdb/001_cron_service.sql"
|
||||||
"services/game-service/deploy/mysql/initdb/001_game_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"
|
"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 "user-service" "gRPC" "grpc" "user-service" "13005" "13005"
|
||||||
print_row "activity-service" "gRPC" "grpc" "activity-service" "13006" "13006"
|
print_row "activity-service" "gRPC" "grpc" "activity-service" "13006" "13006"
|
||||||
print_row "cron-service" "gRPC" "grpc" "cron-service" "13007" "13007"
|
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 "mysql" "MySQL" "tcp" "mysql" "3306" "23306"
|
||||||
print_row "redis" "Redis" "tcp" "redis" "6379" "13379"
|
print_row "redis" "Redis" "tcp" "redis" "6379" "13379"
|
||||||
|
|
||||||
|
|||||||
@ -37,7 +37,6 @@ import (
|
|||||||
hostorgmodule "hyapp-admin-server/internal/modules/hostorg"
|
hostorgmodule "hyapp-admin-server/internal/modules/hostorg"
|
||||||
jobmodule "hyapp-admin-server/internal/modules/job"
|
jobmodule "hyapp-admin-server/internal/modules/job"
|
||||||
menumodule "hyapp-admin-server/internal/modules/menu"
|
menumodule "hyapp-admin-server/internal/modules/menu"
|
||||||
notificationmodule "hyapp-admin-server/internal/modules/notification"
|
|
||||||
paymentmodule "hyapp-admin-server/internal/modules/payment"
|
paymentmodule "hyapp-admin-server/internal/modules/payment"
|
||||||
rbacmodule "hyapp-admin-server/internal/modules/rbac"
|
rbacmodule "hyapp-admin-server/internal/modules/rbac"
|
||||||
registrationrewardmodule "hyapp-admin-server/internal/modules/registrationreward"
|
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),
|
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
|
||||||
Job: jobmodule.New(store, cfg, auditHandler),
|
Job: jobmodule.New(store, cfg, auditHandler),
|
||||||
Menu: menumodule.New(store, auditHandler),
|
Menu: menumodule.New(store, auditHandler),
|
||||||
Notification: notificationmodule.New(store, auditHandler),
|
|
||||||
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
||||||
RBAC: rbacmodule.New(store, auditHandler),
|
RBAC: rbacmodule.New(store, auditHandler),
|
||||||
RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||||
|
|||||||
@ -186,23 +186,6 @@
|
|||||||
| 导出 | `log:export` | 未实现 | 新增 |
|
| 导出 | `log:export` | 未实现 | 新增 |
|
||||||
| 查看详情 | `log:view` | 未实现 | 新增详情抽屉 |
|
| 查看详情 | `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:view`
|
||||||
- `user:status`
|
- `user:status`
|
||||||
- `log:view`
|
- `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. 后端补权限定义和菜单管理接口。
|
2. 后端补权限定义和菜单管理接口。
|
||||||
3. 后端把现有接口从宽权限调整到按钮级权限。
|
3. 后端把现有接口从宽权限调整到按钮级权限。
|
||||||
4. 前端补角色授权抽屉和菜单权限页编辑能力。
|
4. 前端补角色授权抽屉和菜单权限页编辑能力。
|
||||||
|
|||||||
@ -183,20 +183,6 @@ func (OperationLog) TableName() string {
|
|||||||
return "admin_operation_logs"
|
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 {
|
type DataScope struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
RoleID uint `gorm:"index;not null" json:"roleId"`
|
RoleID uint `gorm:"index;not null" json:"roleId"`
|
||||||
|
|||||||
@ -10,6 +10,8 @@ type coinLedgerUserDTO struct {
|
|||||||
type coinLedgerEntryDTO struct {
|
type coinLedgerEntryDTO struct {
|
||||||
EntryID int64 `json:"entryId"`
|
EntryID int64 `json:"entryId"`
|
||||||
TransactionID string `json:"transactionId"`
|
TransactionID string `json:"transactionId"`
|
||||||
|
CommandID string `json:"commandId"`
|
||||||
|
ExternalRef string `json:"externalRef"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
User coinLedgerUserDTO `json:"user"`
|
User coinLedgerUserDTO `json:"user"`
|
||||||
BizType string `json:"bizType"`
|
BizType string `json:"bizType"`
|
||||||
@ -19,5 +21,6 @@ type coinLedgerEntryDTO struct {
|
|||||||
AvailableAfter int64 `json:"availableAfter"`
|
AvailableAfter int64 `json:"availableAfter"`
|
||||||
CounterpartyUserID string `json:"counterpartyUserId"`
|
CounterpartyUserID string `json:"counterpartyUserId"`
|
||||||
RoomID string `json:"roomId"`
|
RoomID string `json:"roomId"`
|
||||||
|
Metadata map[string]any `json:"metadata"`
|
||||||
CreatedAtMS int64 `json:"createdAtMs"`
|
CreatedAtMS int64 `json:"createdAtMs"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package coinledger
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@ -58,9 +59,9 @@ func (s *Service) ListCoinLedger(ctx context.Context, appCode string, query list
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.walletDB.QueryContext(ctx, `
|
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.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
|
FROM wallet_entries e
|
||||||
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
|
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
|
||||||
`+whereSQL+`
|
`+whereSQL+`
|
||||||
@ -79,22 +80,31 @@ func (s *Service) ListCoinLedger(ctx context.Context, appCode string, query list
|
|||||||
var item coinLedgerEntryDTO
|
var item coinLedgerEntryDTO
|
||||||
var userID int64
|
var userID int64
|
||||||
var counterpartyUserID int64
|
var counterpartyUserID int64
|
||||||
|
var metadataJSON string
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&item.EntryID,
|
&item.EntryID,
|
||||||
&item.TransactionID,
|
&item.TransactionID,
|
||||||
|
&item.CommandID,
|
||||||
|
&item.ExternalRef,
|
||||||
&userID,
|
&userID,
|
||||||
&item.BizType,
|
&item.BizType,
|
||||||
&item.AvailableDelta,
|
&item.AvailableDelta,
|
||||||
&item.AvailableAfter,
|
&item.AvailableAfter,
|
||||||
&counterpartyUserID,
|
&counterpartyUserID,
|
||||||
&item.RoomID,
|
&item.RoomID,
|
||||||
|
&metadataJSON,
|
||||||
&item.CreatedAtMS,
|
&item.CreatedAtMS,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
metadata, err := parseMetadataJSON(metadataJSON)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
item.UserID = strconv.FormatInt(userID, 10)
|
item.UserID = strconv.FormatInt(userID, 10)
|
||||||
item.User = coinLedgerUserDTO{UserID: item.UserID}
|
item.User = coinLedgerUserDTO{UserID: item.UserID}
|
||||||
item.CounterpartyUserID = formatOptionalID(counterpartyUserID)
|
item.CounterpartyUserID = formatOptionalID(counterpartyUserID)
|
||||||
|
item.Metadata = metadata
|
||||||
item.Direction = directionForDelta(item.AvailableDelta)
|
item.Direction = directionForDelta(item.AvailableDelta)
|
||||||
item.Amount = absInt64(item.AvailableDelta)
|
item.Amount = absInt64(item.AvailableDelta)
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
@ -259,6 +269,21 @@ func formatOptionalID(value int64) string {
|
|||||||
return strconv.FormatInt(value, 10)
|
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 {
|
func offset(page int, pageSize int) int {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
|
|||||||
@ -28,3 +28,21 @@ func TestDirectionAndAmountForDelta(t *testing.T) {
|
|||||||
t.Fatalf("income projection mismatch")
|
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 (
|
const (
|
||||||
uploadMultipartOverheadLimit = 1 << 20
|
uploadSniffBytes = 512
|
||||||
uploadSniffBytes = 512
|
|
||||||
imageUploadMaxBytes int64 = 5 << 20
|
|
||||||
fileUploadMaxBytes int64 = 20 << 20
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
imageUploadPolicy = uploadPolicy{
|
imageUploadPolicy = uploadPolicy{
|
||||||
kind: "images",
|
kind: "images",
|
||||||
maxBytes: imageUploadMaxBytes,
|
|
||||||
requireImage: true,
|
requireImage: true,
|
||||||
allowedExtensions: map[string]struct{}{
|
allowedExtensions: map[string]struct{}{
|
||||||
".jpg": {}, ".jpeg": {}, ".png": {}, ".webp": {}, ".svga": {}, ".pag": {},
|
".jpg": {}, ".jpeg": {}, ".png": {}, ".webp": {}, ".svga": {}, ".pag": {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
fileUploadPolicy = uploadPolicy{
|
fileUploadPolicy = uploadPolicy{
|
||||||
kind: "files",
|
kind: "files",
|
||||||
maxBytes: fileUploadMaxBytes,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -72,7 +67,7 @@ func (h *Handler) uploadObject(c *gin.Context, policy uploadPolicy) {
|
|||||||
response.ServerError(c, "文件上传未配置")
|
response.ServerError(c, "文件上传未配置")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
file, header, ok := parseUploadFile(c, policy.maxBytes)
|
file, header, ok := parseUploadFile(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
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, "文件大小不正确")
|
response.BadRequest(c, "文件大小不正确")
|
||||||
return
|
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) {
|
func parseUploadFile(c *gin.Context) (multipart.File, *multipart.FileHeader, bool) {
|
||||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes+uploadMultipartOverheadLimit)
|
|
||||||
if err := c.Request.ParseMultipartForm(1 << 20); err != nil {
|
if err := c.Request.ParseMultipartForm(1 << 20); err != nil {
|
||||||
response.BadRequest(c, "文件参数不正确")
|
response.BadRequest(c, "文件参数不正确")
|
||||||
return nil, nil, false
|
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 {
|
func ginRouter(handler *Handler) http.Handler {
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.Use(func(c *gin.Context) {
|
router.Use(func(c *gin.Context) {
|
||||||
|
|||||||
@ -3,7 +3,6 @@ package upload
|
|||||||
type uploadPolicy struct {
|
type uploadPolicy struct {
|
||||||
allowedExtensions map[string]struct{}
|
allowedExtensions map[string]struct{}
|
||||||
kind string
|
kind string
|
||||||
maxBytes int64
|
|
||||||
requireImage bool
|
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 {
|
if err := s.db.Model(&model.User{}).Where("status = ?", model.UserStatusDisabled).Count(&disabledUsers).Error; err != nil {
|
||||||
return nil, err
|
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 {
|
if err := s.db.Model(&model.Role{}).Count(&rolesTotal).Error; err != nil {
|
||||||
return nil, err
|
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 {
|
if err := s.db.Model(&model.OperationLog{}).Where("created_at_ms >= ?", todayMS).Count(&logsToday).Error; err != nil {
|
||||||
return nil, err
|
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{
|
return &DashboardOverview{
|
||||||
UsersTotal: int(usersTotal),
|
UsersTotal: int(usersTotal),
|
||||||
ActiveUsers: int(activeUsers),
|
ActiveUsers: int(activeUsers),
|
||||||
LockedUsers: int(lockedUsers),
|
LockedUsers: int(lockedUsers),
|
||||||
DisabledUsers: int(disabledUsers),
|
DisabledUsers: int(disabledUsers),
|
||||||
RolesTotal: int(rolesTotal),
|
RolesTotal: int(rolesTotal),
|
||||||
MenuTotal: int(menuTotal),
|
MenuTotal: int(menuTotal),
|
||||||
LogsToday: int(logsToday),
|
LogsToday: int(logsToday),
|
||||||
UnreadNotifications: int(unreadNotifications),
|
|
||||||
Series: map[string][]int{
|
Series: map[string][]int{
|
||||||
"users": {int(usersTotal), int(activeUsers), int(lockedUsers), int(disabledUsers)},
|
"users": {int(usersTotal), int(activeUsers), int(lockedUsers), int(disabledUsers)},
|
||||||
"operations": {int(logsToday)},
|
"operations": {int(logsToday)},
|
||||||
"notifications": {int(unreadNotifications)},
|
|
||||||
},
|
},
|
||||||
Alerts: alerts,
|
|
||||||
UpdatedAtMS: now.UnixMilli(),
|
UpdatedAtMS: now.UnixMilli(),
|
||||||
}, nil
|
}, 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 {
|
type DashboardOverview struct {
|
||||||
UsersTotal int `json:"usersTotal"`
|
UsersTotal int `json:"usersTotal"`
|
||||||
ActiveUsers int `json:"activeUsers"`
|
ActiveUsers int `json:"activeUsers"`
|
||||||
LockedUsers int `json:"lockedUsers"`
|
LockedUsers int `json:"lockedUsers"`
|
||||||
DisabledUsers int `json:"disabledUsers"`
|
DisabledUsers int `json:"disabledUsers"`
|
||||||
RolesTotal int `json:"rolesTotal"`
|
RolesTotal int `json:"rolesTotal"`
|
||||||
MenuTotal int `json:"menuTotal"`
|
MenuTotal int `json:"menuTotal"`
|
||||||
LogsToday int `json:"logsToday"`
|
LogsToday int `json:"logsToday"`
|
||||||
UnreadNotifications int `json:"unreadNotifications"`
|
Series map[string][]int `json:"series"`
|
||||||
Alerts []map[string]string `json:"alerts"`
|
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||||
Series map[string][]int `json:"series"`
|
|
||||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type MenuSortItem struct {
|
type MenuSortItem struct {
|
||||||
@ -70,7 +68,6 @@ func (s *Store) AutoMigrate() error {
|
|||||||
&model.RefreshToken{},
|
&model.RefreshToken{},
|
||||||
&model.LoginLog{},
|
&model.LoginLog{},
|
||||||
&model.OperationLog{},
|
&model.OperationLog{},
|
||||||
&model.Notification{},
|
|
||||||
&model.DataScope{},
|
&model.DataScope{},
|
||||||
&model.AdminJob{},
|
&model.AdminJob{},
|
||||||
)
|
)
|
||||||
@ -104,8 +101,5 @@ func (s *Store) Seed(cfg config.Config) error {
|
|||||||
if err := s.seedUsers(cfg); err != nil {
|
if err := s.seedUsers(cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !cfg.Bootstrap.SeedDemoData {
|
return nil
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return s.seedNotifications()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -98,18 +98,17 @@ var defaultPermissions = []model.Permission{
|
|||||||
{Name: "菜单显隐", Code: "menu:visible", Kind: "button"},
|
{Name: "菜单显隐", Code: "menu:visible", Kind: "button"},
|
||||||
{Name: "日志查看", Code: "log:view", Kind: "menu"},
|
{Name: "日志查看", Code: "log:view", Kind: "menu"},
|
||||||
{Name: "日志导出", Code: "log:export", Kind: "button"},
|
{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:view", Kind: "menu"},
|
||||||
{Name: "任务取消", Code: "job:cancel", Kind: "button"},
|
{Name: "任务取消", Code: "job:cancel", Kind: "button"},
|
||||||
{Name: "导出任务创建", Code: "export:create", Kind: "button"},
|
{Name: "导出任务创建", Code: "export:create", Kind: "button"},
|
||||||
{Name: "文件上传", Code: "upload:create", Kind: "button"},
|
{Name: "文件上传", Code: "upload:create", Kind: "button"},
|
||||||
}
|
}
|
||||||
|
|
||||||
var deprecatedPermissionCodes = []string{"service:view", "service:create", "service:update", "service:operate"}
|
var deprecatedPermissionCodes = []string{
|
||||||
var deprecatedMenuCodes = []string{"services"}
|
"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 {
|
func (s *Store) seedPermissions() error {
|
||||||
return s.syncDefaultPermissions(s.db)
|
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: "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: "system", Path: "", Icon: "settings", PermissionCode: "", Sort: 30, Visible: true},
|
||||||
{Title: "日志审计", Code: "logs", Path: "", Icon: "history", PermissionCode: "", Sort: 40, Visible: false},
|
{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: "app-users", Path: "", Icon: "users", PermissionCode: "", Sort: 60, Visible: true},
|
||||||
{Title: "房间管理", Code: "rooms", Path: "", Icon: "room", PermissionCode: "", Sort: 65, 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},
|
{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})
|
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 {
|
func (s *Store) pruneDeprecatedDefaults() error {
|
||||||
return s.db.Transaction(func(tx *gorm.DB) 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 {
|
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",
|
"game:view", "game:create", "game:update", "game:status",
|
||||||
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
||||||
"log:view",
|
"log:view",
|
||||||
"notification:view", "notification:read",
|
|
||||||
"job:view", "job:cancel", "export:create",
|
"job:view", "job:cancel", "export:create",
|
||||||
"upload:create",
|
"upload:create",
|
||||||
}
|
}
|
||||||
@ -492,7 +473,6 @@ func defaultRolePermissionCodes(code string) []string {
|
|||||||
"permission:view",
|
"permission:view",
|
||||||
"menu:view",
|
"menu:view",
|
||||||
"log:view",
|
"log:view",
|
||||||
"notification:view",
|
|
||||||
"job:view",
|
"job:view",
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -18,7 +18,6 @@ import (
|
|||||||
"hyapp-admin-server/internal/modules/hostorg"
|
"hyapp-admin-server/internal/modules/hostorg"
|
||||||
"hyapp-admin-server/internal/modules/job"
|
"hyapp-admin-server/internal/modules/job"
|
||||||
"hyapp-admin-server/internal/modules/menu"
|
"hyapp-admin-server/internal/modules/menu"
|
||||||
"hyapp-admin-server/internal/modules/notification"
|
|
||||||
"hyapp-admin-server/internal/modules/payment"
|
"hyapp-admin-server/internal/modules/payment"
|
||||||
"hyapp-admin-server/internal/modules/rbac"
|
"hyapp-admin-server/internal/modules/rbac"
|
||||||
"hyapp-admin-server/internal/modules/registrationreward"
|
"hyapp-admin-server/internal/modules/registrationreward"
|
||||||
@ -48,7 +47,6 @@ type Handlers struct {
|
|||||||
HostOrg *hostorg.Handler
|
HostOrg *hostorg.Handler
|
||||||
Job *job.Handler
|
Job *job.Handler
|
||||||
Menu *menu.Handler
|
Menu *menu.Handler
|
||||||
Notification *notification.Handler
|
|
||||||
Payment *payment.Handler
|
Payment *payment.Handler
|
||||||
RBAC *rbac.Handler
|
RBAC *rbac.Handler
|
||||||
RegistrationReward *registrationreward.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)
|
dashboard.RegisterRoutes(protected, h.Dashboard)
|
||||||
hostorg.RegisterRoutes(protected, h.HostOrg)
|
hostorg.RegisterRoutes(protected, h.HostOrg)
|
||||||
audit.RegisterRoutes(protected, h.Audit)
|
audit.RegisterRoutes(protected, h.Audit)
|
||||||
notification.RegisterRoutes(protected, h.Notification)
|
|
||||||
payment.RegisterRoutes(protected, h.Payment)
|
payment.RegisterRoutes(protected, h.Payment)
|
||||||
job.RegisterRoutes(protected, h.Job)
|
job.RegisterRoutes(protected, h.Job)
|
||||||
upload.RegisterRoutes(protected, h.Upload)
|
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;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
|
||||||
-- Country/region bootstrap is generated from services/user-service/deploy/data/countries.json.
|
-- Country bootstrap is generated from services/user-service/deploy/data/countries.json.
|
||||||
-- region_code intentionally uses source subregion directly; top-level region is not imported.
|
-- 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
|
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),
|
('Andorra', 'AD', 'AND', '020', '安道尔', '+376', '🇦🇩', TRUE, 10, 0, 0),
|
||||||
('United Arab Emirates', 'AE', 'ARE', '784', '阿拉伯联合酋长国', '+971', '🇦🇪', TRUE, 20, 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),
|
('Vietnam', 'VN', 'VNM', '704', '越南', '+84', '🇻🇳', TRUE, 2410, 0, 0),
|
||||||
('Vanuatu', 'VU', 'VUT', '548', '瓦努阿图', '+678', '🇻🇺', TRUE, 2420, 0, 0),
|
('Vanuatu', 'VU', 'VUT', '548', '瓦努阿图', '+678', '🇻🇺', TRUE, 2420, 0, 0),
|
||||||
('Samoa', 'WS', 'WSM', '882', '萨摩亚', '+685', '🇼🇸', TRUE, 2440, 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),
|
('Yemen', 'YE', 'YEM', '887', '也门', '+967', '🇾🇪', TRUE, 2460, 0, 0),
|
||||||
('Mayotte', 'YT', 'MYT', '175', '马约特', '+262', '🇾🇹', TRUE, 2470, 0, 0),
|
('Mayotte', 'YT', 'MYT', '175', '马约特', '+262', '🇾🇹', TRUE, 2470, 0, 0),
|
||||||
('South Africa', 'ZA', 'ZAF', '710', '南非', '+27', '🇿🇦', TRUE, 2480, 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);
|
updated_at_ms = VALUES(updated_at_ms);
|
||||||
|
|
||||||
INSERT INTO regions (region_code, name, status, sort_order, created_at_ms, updated_at_ms) VALUES
|
INSERT INTO regions (region_code, name, status, sort_order, created_at_ms, updated_at_ms) VALUES
|
||||||
('GLOBAL', 'GLOBAL', 'active', 0, 0, 0),
|
('MIDDLE_EAST', '中东区', 'active', 10, 0, 0),
|
||||||
('Southern Europe', '南欧', 'active', 10, 0, 0),
|
('INDIA', '印度区', 'active', 20, 0, 0),
|
||||||
('Western Asia', '西亚', 'active', 20, 0, 0),
|
('EURASIA_AMERICA', '欧亚美区', 'active', 30, 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)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
name = VALUES(name),
|
name = VALUES(name),
|
||||||
status = VALUES(status),
|
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)
|
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
|
SELECT 'lalu', r.region_id, seed.country_code, 'active', 0, 0
|
||||||
FROM (
|
FROM (
|
||||||
SELECT 'AD' AS country_code, 'Southern Europe' AS region_code
|
SELECT 'AD' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'AE' AS country_code, 'Western Asia' AS region_code
|
SELECT 'AE' AS country_code, 'MIDDLE_EAST' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'AF' AS country_code, 'Southern Asia' AS region_code
|
SELECT 'AF' AS country_code, 'INDIA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'AI' AS country_code, 'Caribbean' AS region_code
|
SELECT 'AI' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'AL' AS country_code, 'Southeast Europe' AS region_code
|
SELECT 'AL' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'AM' AS country_code, 'Western Asia' AS region_code
|
SELECT 'AM' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'AO' AS country_code, 'Middle Africa' AS region_code
|
SELECT 'AO' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'AR' AS country_code, 'South America' AS region_code
|
SELECT 'AR' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'AT' AS country_code, 'Central Europe' AS region_code
|
SELECT 'AT' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
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
|
UNION ALL
|
||||||
SELECT 'AW' AS country_code, 'Caribbean' AS region_code
|
SELECT 'AW' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'AX' AS country_code, 'Northern Europe' AS region_code
|
SELECT 'AX' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'AZ' AS country_code, 'Western Asia' AS region_code
|
SELECT 'AZ' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'BB' AS country_code, 'Caribbean' AS region_code
|
SELECT 'BB' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'BD' AS country_code, 'Southern Asia' AS region_code
|
SELECT 'BD' AS country_code, 'INDIA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'BE' AS country_code, 'Western Europe' AS region_code
|
SELECT 'BE' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'BF' AS country_code, 'Western Africa' AS region_code
|
SELECT 'BF' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'BG' AS country_code, 'Southeast Europe' AS region_code
|
SELECT 'BG' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'BH' AS country_code, 'Western Asia' AS region_code
|
SELECT 'BH' AS country_code, 'MIDDLE_EAST' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'BI' AS country_code, 'Eastern Africa' AS region_code
|
SELECT 'BI' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'BJ' AS country_code, 'Western Africa' AS region_code
|
SELECT 'BJ' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'BL' AS country_code, 'Caribbean' AS region_code
|
SELECT 'BL' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'BM' AS country_code, 'North America' AS region_code
|
SELECT 'BM' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
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
|
UNION ALL
|
||||||
SELECT 'BO' AS country_code, 'South America' AS region_code
|
SELECT 'BO' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'BR' AS country_code, 'South America' AS region_code
|
SELECT 'BR' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'BS' AS country_code, 'Caribbean' AS region_code
|
SELECT 'BS' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'BT' AS country_code, 'Southern Asia' AS region_code
|
SELECT 'BT' AS country_code, 'INDIA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'BV' AS country_code, 'UNSPECIFIED' AS region_code
|
SELECT 'BV' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'BW' AS country_code, 'Southern Africa' AS region_code
|
SELECT 'BW' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'BY' AS country_code, 'Eastern Europe' AS region_code
|
SELECT 'BY' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'BZ' AS country_code, 'Central America' AS region_code
|
SELECT 'BZ' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'CA' AS country_code, 'North America' AS region_code
|
SELECT 'CA' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
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
|
UNION ALL
|
||||||
SELECT 'CD' AS country_code, 'Middle Africa' AS region_code
|
SELECT 'CD' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'CF' AS country_code, 'Middle Africa' AS region_code
|
SELECT 'CF' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'CG' AS country_code, 'Middle Africa' AS region_code
|
SELECT 'CG' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'CH' AS country_code, 'Western Europe' AS region_code
|
SELECT 'CH' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'CI' AS country_code, 'Western Africa' AS region_code
|
SELECT 'CI' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'CL' AS country_code, 'South America' AS region_code
|
SELECT 'CL' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'CM' AS country_code, 'Middle Africa' AS region_code
|
SELECT 'CM' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'CN' AS country_code, 'Eastern Asia' AS region_code
|
SELECT 'CN' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'CO' AS country_code, 'South America' AS region_code
|
SELECT 'CO' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'CR' AS country_code, 'Central America' AS region_code
|
SELECT 'CR' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'CU' AS country_code, 'Caribbean' AS region_code
|
SELECT 'CU' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'CV' AS country_code, 'Western Africa' AS region_code
|
SELECT 'CV' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'CW' AS country_code, 'Caribbean' AS region_code
|
SELECT 'CW' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
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
|
UNION ALL
|
||||||
SELECT 'CY' AS country_code, 'Southern Europe' AS region_code
|
SELECT 'CY' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'CZ' AS country_code, 'Central Europe' AS region_code
|
SELECT 'CZ' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'DE' AS country_code, 'Western Europe' AS region_code
|
SELECT 'DE' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'DJ' AS country_code, 'Eastern Africa' AS region_code
|
SELECT 'DJ' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'DK' AS country_code, 'Northern Europe' AS region_code
|
SELECT 'DK' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'DM' AS country_code, 'Caribbean' AS region_code
|
SELECT 'DM' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'DO' AS country_code, 'Caribbean' AS region_code
|
SELECT 'DO' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'DZ' AS country_code, 'Northern Africa' AS region_code
|
SELECT 'DZ' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'EC' AS country_code, 'South America' AS region_code
|
SELECT 'EC' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'EE' AS country_code, 'Northern Europe' AS region_code
|
SELECT 'EE' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'EG' AS country_code, 'Northern Africa' AS region_code
|
SELECT 'EG' AS country_code, 'MIDDLE_EAST' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'ER' AS country_code, 'Eastern Africa' AS region_code
|
SELECT 'ER' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'ES' AS country_code, 'Southern Europe' AS region_code
|
SELECT 'ES' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'ET' AS country_code, 'Eastern Africa' AS region_code
|
SELECT 'ET' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'FI' AS country_code, 'Northern Europe' AS region_code
|
SELECT 'FI' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'FJ' AS country_code, 'Melanesia' AS region_code
|
SELECT 'FJ' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'FM' AS country_code, 'Micronesia' AS region_code
|
SELECT 'FM' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'FR' AS country_code, 'Western Europe' AS region_code
|
SELECT 'FR' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'GA' AS country_code, 'Middle Africa' AS region_code
|
SELECT 'GA' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'GB' AS country_code, 'Northern Europe' AS region_code
|
SELECT 'GB' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'GD' AS country_code, 'Caribbean' AS region_code
|
SELECT 'GD' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'GE' AS country_code, 'Western Asia' AS region_code
|
SELECT 'GE' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'GF' AS country_code, 'South America' AS region_code
|
SELECT 'GF' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'GG' AS country_code, 'Northern Europe' AS region_code
|
SELECT 'GG' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'GH' AS country_code, 'Western Africa' AS region_code
|
SELECT 'GH' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'GI' AS country_code, 'Southern Europe' AS region_code
|
SELECT 'GI' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'GL' AS country_code, 'North America' AS region_code
|
SELECT 'GL' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'GM' AS country_code, 'Western Africa' AS region_code
|
SELECT 'GM' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'GN' AS country_code, 'Western Africa' AS region_code
|
SELECT 'GN' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'GQ' AS country_code, 'Middle Africa' AS region_code
|
SELECT 'GQ' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'GR' AS country_code, 'Southern Europe' AS region_code
|
SELECT 'GR' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'GS' AS country_code, 'UNSPECIFIED' AS region_code
|
SELECT 'GS' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'GT' AS country_code, 'Central America' AS region_code
|
SELECT 'GT' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'GU' AS country_code, 'Micronesia' AS region_code
|
SELECT 'GU' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'GY' AS country_code, 'South America' AS region_code
|
SELECT 'GY' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'HN' AS country_code, 'Central America' AS region_code
|
SELECT 'HN' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'HR' AS country_code, 'Southeast Europe' AS region_code
|
SELECT 'HR' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'HT' AS country_code, 'Caribbean' AS region_code
|
SELECT 'HT' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'HU' AS country_code, 'Central Europe' AS region_code
|
SELECT 'HU' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
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
|
UNION ALL
|
||||||
SELECT 'IE' AS country_code, 'Northern Europe' AS region_code
|
SELECT 'IE' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'IL' AS country_code, 'Western Asia' AS region_code
|
SELECT 'IL' AS country_code, 'MIDDLE_EAST' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'IM' AS country_code, 'Northern Europe' AS region_code
|
SELECT 'IM' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'IN' AS country_code, 'Southern Asia' AS region_code
|
SELECT 'IN' AS country_code, 'INDIA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'IQ' AS country_code, 'Western Asia' AS region_code
|
SELECT 'IQ' AS country_code, 'MIDDLE_EAST' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'IR' AS country_code, 'Southern Asia' AS region_code
|
SELECT 'IR' AS country_code, 'MIDDLE_EAST' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'IS' AS country_code, 'Northern Europe' AS region_code
|
SELECT 'IS' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'IT' AS country_code, 'Southern Europe' AS region_code
|
SELECT 'IT' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'JE' AS country_code, 'Northern Europe' AS region_code
|
SELECT 'JE' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'JM' AS country_code, 'Caribbean' AS region_code
|
SELECT 'JM' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'JO' AS country_code, 'Western Asia' AS region_code
|
SELECT 'JO' AS country_code, 'MIDDLE_EAST' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'JP' AS country_code, 'Eastern Asia' AS region_code
|
SELECT 'JP' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'KE' AS country_code, 'Eastern Africa' AS region_code
|
SELECT 'KE' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'KG' AS country_code, 'Central Asia' AS region_code
|
SELECT 'KG' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
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
|
UNION ALL
|
||||||
SELECT 'KI' AS country_code, 'Micronesia' AS region_code
|
SELECT 'KI' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'KM' AS country_code, 'Eastern Africa' AS region_code
|
SELECT 'KM' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'KP' AS country_code, 'Eastern Asia' AS region_code
|
SELECT 'KP' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'KR' AS country_code, 'Eastern Asia' AS region_code
|
SELECT 'KR' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'KW' AS country_code, 'Western Asia' AS region_code
|
SELECT 'KW' AS country_code, 'MIDDLE_EAST' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'KY' AS country_code, 'Caribbean' AS region_code
|
SELECT 'KY' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'KZ' AS country_code, 'Central Asia' AS region_code
|
SELECT 'KZ' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
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
|
UNION ALL
|
||||||
SELECT 'LB' AS country_code, 'Western Asia' AS region_code
|
SELECT 'LB' AS country_code, 'MIDDLE_EAST' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'LC' AS country_code, 'Caribbean' AS region_code
|
SELECT 'LC' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'LI' AS country_code, 'Western Europe' AS region_code
|
SELECT 'LI' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'LK' AS country_code, 'Southern Asia' AS region_code
|
SELECT 'LK' AS country_code, 'INDIA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'LR' AS country_code, 'Western Africa' AS region_code
|
SELECT 'LR' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'LS' AS country_code, 'Southern Africa' AS region_code
|
SELECT 'LS' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'LT' AS country_code, 'Northern Europe' AS region_code
|
SELECT 'LT' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'LU' AS country_code, 'Western Europe' AS region_code
|
SELECT 'LU' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'LV' AS country_code, 'Northern Europe' AS region_code
|
SELECT 'LV' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'LY' AS country_code, 'Northern Africa' AS region_code
|
SELECT 'LY' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'MA' AS country_code, 'Northern Africa' AS region_code
|
SELECT 'MA' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'MC' AS country_code, 'Western Europe' AS region_code
|
SELECT 'MC' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'MD' AS country_code, 'Eastern Europe' AS region_code
|
SELECT 'MD' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'ME' AS country_code, 'Southeast Europe' AS region_code
|
SELECT 'ME' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'MF' AS country_code, 'Caribbean' AS region_code
|
SELECT 'MF' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'MG' AS country_code, 'Eastern Africa' AS region_code
|
SELECT 'MG' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'ML' AS country_code, 'Western Africa' AS region_code
|
SELECT 'ML' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
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
|
UNION ALL
|
||||||
SELECT 'MN' AS country_code, 'Eastern Asia' AS region_code
|
SELECT 'MN' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'MO' AS country_code, 'Eastern Asia' AS region_code
|
SELECT 'MO' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'MQ' AS country_code, 'Caribbean' AS region_code
|
SELECT 'MQ' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'MR' AS country_code, 'Western Africa' AS region_code
|
SELECT 'MR' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'MS' AS country_code, 'Caribbean' AS region_code
|
SELECT 'MS' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'MT' AS country_code, 'Southern Europe' AS region_code
|
SELECT 'MT' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'MU' AS country_code, 'Eastern Africa' AS region_code
|
SELECT 'MU' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'MV' AS country_code, 'Southern Asia' AS region_code
|
SELECT 'MV' AS country_code, 'INDIA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'MW' AS country_code, 'Eastern Africa' AS region_code
|
SELECT 'MW' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'MX' AS country_code, 'North America' AS region_code
|
SELECT 'MX' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
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
|
UNION ALL
|
||||||
SELECT 'MZ' AS country_code, 'Eastern Africa' AS region_code
|
SELECT 'MZ' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'NA' AS country_code, 'Southern Africa' AS region_code
|
SELECT 'NA' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'NC' AS country_code, 'Melanesia' AS region_code
|
SELECT 'NC' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'NE' AS country_code, 'Western Africa' AS region_code
|
SELECT 'NE' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
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
|
UNION ALL
|
||||||
SELECT 'NG' AS country_code, 'Western Africa' AS region_code
|
SELECT 'NG' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'NI' AS country_code, 'Central America' AS region_code
|
SELECT 'NI' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'NL' AS country_code, 'Western Europe' AS region_code
|
SELECT 'NL' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'NO' AS country_code, 'Northern Europe' AS region_code
|
SELECT 'NO' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'NP' AS country_code, 'Southern Asia' AS region_code
|
SELECT 'NP' AS country_code, 'INDIA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'NR' AS country_code, 'Micronesia' AS region_code
|
SELECT 'NR' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'NU' AS country_code, 'Polynesia' AS region_code
|
SELECT 'NU' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
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
|
UNION ALL
|
||||||
SELECT 'OM' AS country_code, 'Western Asia' AS region_code
|
SELECT 'OM' AS country_code, 'MIDDLE_EAST' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'PA' AS country_code, 'Central America' AS region_code
|
SELECT 'PA' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'PE' AS country_code, 'South America' AS region_code
|
SELECT 'PE' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
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
|
UNION ALL
|
||||||
SELECT 'PK' AS country_code, 'Southern Asia' AS region_code
|
SELECT 'PK' AS country_code, 'INDIA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'PL' AS country_code, 'Central Europe' AS region_code
|
SELECT 'PL' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'PR' AS country_code, 'Caribbean' AS region_code
|
SELECT 'PR' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'PS' AS country_code, 'Western Asia' AS region_code
|
SELECT 'PS' AS country_code, 'MIDDLE_EAST' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'PT' AS country_code, 'Southern Europe' AS region_code
|
SELECT 'PT' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'PW' AS country_code, 'Micronesia' AS region_code
|
SELECT 'PW' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'PY' AS country_code, 'South America' AS region_code
|
SELECT 'PY' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'QA' AS country_code, 'Western Asia' AS region_code
|
SELECT 'QA' AS country_code, 'MIDDLE_EAST' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'RE' AS country_code, 'Eastern Africa' AS region_code
|
SELECT 'RE' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'RO' AS country_code, 'Southeast Europe' AS region_code
|
SELECT 'RO' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'RS' AS country_code, 'Southeast Europe' AS region_code
|
SELECT 'RS' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'RU' AS country_code, 'Eastern Europe' AS region_code
|
SELECT 'RU' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'RW' AS country_code, 'Eastern Africa' AS region_code
|
SELECT 'RW' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'SA' AS country_code, 'Western Asia' AS region_code
|
SELECT 'SA' AS country_code, 'MIDDLE_EAST' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'SB' AS country_code, 'Melanesia' AS region_code
|
SELECT 'SB' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'SC' AS country_code, 'Eastern Africa' AS region_code
|
SELECT 'SC' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'SD' AS country_code, 'Northern Africa' AS region_code
|
SELECT 'SD' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'SE' AS country_code, 'Northern Europe' AS region_code
|
SELECT 'SE' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
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
|
UNION ALL
|
||||||
SELECT 'SI' AS country_code, 'Central Europe' AS region_code
|
SELECT 'SI' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'SJ' AS country_code, 'Northern Europe' AS region_code
|
SELECT 'SJ' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'SK' AS country_code, 'Central Europe' AS region_code
|
SELECT 'SK' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'SL' AS country_code, 'Western Africa' AS region_code
|
SELECT 'SL' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'SM' AS country_code, 'Southern Europe' AS region_code
|
SELECT 'SM' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'SN' AS country_code, 'Western Africa' AS region_code
|
SELECT 'SN' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'SO' AS country_code, 'Eastern Africa' AS region_code
|
SELECT 'SO' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'SR' AS country_code, 'South America' AS region_code
|
SELECT 'SR' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'SS' AS country_code, 'Middle Africa' AS region_code
|
SELECT 'SS' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'SV' AS country_code, 'Central America' AS region_code
|
SELECT 'SV' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'SX' AS country_code, 'Caribbean' AS region_code
|
SELECT 'SX' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'SY' AS country_code, 'Western Asia' AS region_code
|
SELECT 'SY' AS country_code, 'MIDDLE_EAST' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'SZ' AS country_code, 'Southern Africa' AS region_code
|
SELECT 'SZ' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'TD' AS country_code, 'Middle Africa' AS region_code
|
SELECT 'TD' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'TG' AS country_code, 'Western Africa' AS region_code
|
SELECT 'TG' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
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
|
UNION ALL
|
||||||
SELECT 'TJ' AS country_code, 'Central Asia' AS region_code
|
SELECT 'TJ' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'TK' AS country_code, 'Polynesia' AS region_code
|
SELECT 'TK' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
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
|
UNION ALL
|
||||||
SELECT 'TM' AS country_code, 'Central Asia' AS region_code
|
SELECT 'TM' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'TN' AS country_code, 'Northern Africa' AS region_code
|
SELECT 'TN' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'TO' AS country_code, 'Polynesia' AS region_code
|
SELECT 'TO' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'TR' AS country_code, 'Western Asia' AS region_code
|
SELECT 'TR' AS country_code, 'MIDDLE_EAST' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'TV' AS country_code, 'Polynesia' AS region_code
|
SELECT 'TV' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'TW' AS country_code, 'Eastern Asia' AS region_code
|
SELECT 'TW' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'TZ' AS country_code, 'Eastern Africa' AS region_code
|
SELECT 'TZ' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'UA' AS country_code, 'Eastern Europe' AS region_code
|
SELECT 'UA' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'UG' AS country_code, 'Eastern Africa' AS region_code
|
SELECT 'UG' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'US' AS country_code, 'North America' AS region_code
|
SELECT 'US' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'UY' AS country_code, 'South America' AS region_code
|
SELECT 'UY' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'UZ' AS country_code, 'Central Asia' AS region_code
|
SELECT 'UZ' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'VA' AS country_code, 'Southern Europe' AS region_code
|
SELECT 'VA' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'VE' AS country_code, 'South America' AS region_code
|
SELECT 'VE' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
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
|
UNION ALL
|
||||||
SELECT 'VU' AS country_code, 'Melanesia' AS region_code
|
SELECT 'VU' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'WS' AS country_code, 'Polynesia' AS region_code
|
SELECT 'WS' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'XK' AS country_code, 'Southeast Europe' AS region_code
|
SELECT 'XK' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'YE' AS country_code, 'Western Asia' AS region_code
|
SELECT 'YE' AS country_code, 'MIDDLE_EAST' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'YT' AS country_code, 'Eastern Africa' AS region_code
|
SELECT 'YT' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'ZA' AS country_code, 'Southern Africa' AS region_code
|
SELECT 'ZA' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'ZM' AS country_code, 'Eastern Africa' AS region_code
|
SELECT 'ZM' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'ZW' AS country_code, 'Eastern Africa' AS region_code
|
SELECT 'ZW' AS country_code, 'EURASIA_AMERICA' AS region_code
|
||||||
) AS seed
|
) AS seed
|
||||||
INNER JOIN regions r ON r.app_code = 'lalu' AND r.region_code = seed.region_code
|
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
|
INNER JOIN countries c ON c.app_code = 'lalu' AND c.country_code = seed.country_code
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
region_id = VALUES(region_id),
|
region_id = VALUES(region_id),
|
||||||
status = VALUES(status),
|
status = VALUES(status),
|
||||||
updated_at_ms = VALUES(updated_at_ms);
|
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.
|
-- Product-excluded countries are hard removed so reapplying initdb updates existing local databases as well as fresh volumes.
|
||||||
DELETE FROM region_countries
|
DELETE FROM region_countries
|
||||||
WHERE app_code = 'lalu'
|
WHERE app_code = 'lalu'
|
||||||
|
|||||||
@ -71,8 +71,8 @@ const (
|
|||||||
PrettyLeaseStatusRefunded PrettyLeaseStatus = "refunded"
|
PrettyLeaseStatusRefunded PrettyLeaseStatus = "refunded"
|
||||||
)
|
)
|
||||||
|
|
||||||
// displayUserIDPattern 限制短号为 6 到 11 位非 0 开头数字。
|
// displayUserIDPattern 限制短号为 5 到 11 位非 0 开头数字。
|
||||||
var displayUserIDPattern = regexp.MustCompile(`^[1-9][0-9]{5,10}$`)
|
var displayUserIDPattern = regexp.MustCompile(`^[1-9][0-9]{4,10}$`)
|
||||||
|
|
||||||
// User 是 user-service 的主数据实体。
|
// 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 = ?")
|
conditions = append(conditions, "status = ?")
|
||||||
args = append(args, filter.Status)
|
args = append(args, filter.Status)
|
||||||
}
|
}
|
||||||
|
conditions = append(conditions, "region_code <> ?")
|
||||||
|
args = append(args, userdomain.GlobalRegionCode)
|
||||||
conditions = append(conditions, "region_code NOT IN ("+retiredRegionCodeSQLList+")")
|
conditions = append(conditions, "region_code NOT IN ("+retiredRegionCodeSQLList+")")
|
||||||
query += " WHERE " + strings.Join(conditions, " AND ")
|
query += " WHERE " + strings.Join(conditions, " AND ")
|
||||||
query += " ORDER BY sort_order ASC, region_code ASC"
|
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()
|
defer rows.Close()
|
||||||
|
|
||||||
regions := []userdomain.Region{}
|
regions := []userdomain.Region{}
|
||||||
hasGlobal := false
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
region, err := scanRegion(rows)
|
region, err := scanRegion(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if userdomain.IsGlobalRegionCode(region.RegionCode) {
|
|
||||||
hasGlobal = true
|
|
||||||
}
|
|
||||||
countries, err := activeCountriesForRegion(ctx, r.db, region.RegionID)
|
countries, err := activeCountriesForRegion(ctx, r.db, region.RegionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
region.Countries = countries
|
region.Countries = countries
|
||||||
if userdomain.IsGlobalRegionCode(region.RegionCode) {
|
|
||||||
region.RegionID = 0
|
|
||||||
region.Countries = []string{}
|
|
||||||
}
|
|
||||||
regions = append(regions, region)
|
regions = append(regions, region)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !hasGlobal && (filter.Status == "" || filter.Status == userdomain.RegionStatusActive) {
|
|
||||||
regions = append([]userdomain.Region{globalRegionFromContext(ctx)}, regions...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return regions, nil
|
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 == "" {
|
if command.CommandID == "" || command.RoomID == "" || command.SenderUserID <= 0 || command.TargetUserID <= 0 || command.GiftID == "" {
|
||||||
return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "billing command is incomplete")
|
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 {
|
if command.GiftCount <= 0 {
|
||||||
return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "gift_count must be positive")
|
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 验证礼物墙只统计扣费成功后的收礼事实。
|
// TestGetUserGiftWallAggregatesSettledGifts 验证礼物墙只统计扣费成功后的收礼事实。
|
||||||
func TestGetUserGiftWallAggregatesSettledGifts(t *testing.T) {
|
func TestGetUserGiftWallAggregatesSettledGifts(t *testing.T) {
|
||||||
repository := mysqltest.NewRepository(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
|
return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
|
||||||
}
|
}
|
||||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
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 {
|
}); err != nil {
|
||||||
return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
|
return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
|
||||||
}
|
}
|
||||||
@ -487,7 +487,7 @@ func (r *Repository) PurchaseVip(ctx context.Context, command ledger.PurchaseVip
|
|||||||
return ledger.PurchaseVipReceipt{}, err
|
return ledger.PurchaseVipReceipt{}, err
|
||||||
}
|
}
|
||||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
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"),
|
EventID: eventID(transactionID, vipEventType, command.UserID, "VIP"),
|
||||||
EventType: vipEventType,
|
EventType: vipEventType,
|
||||||
|
|||||||
@ -208,8 +208,8 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
|||||||
return ledger.Receipt{}, err
|
return ledger.Receipt{}, err
|
||||||
}
|
}
|
||||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
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.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, 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),
|
giftDebitedEvent(transactionID, command.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, metadata, nowMs),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return ledger.Receipt{}, err
|
return ledger.Receipt{}, err
|
||||||
@ -423,7 +423,7 @@ func (r *Repository) AdminCreditAsset(ctx context.Context, command ledger.AdminC
|
|||||||
return ledger.AssetBalance{}, "", err
|
return ledger.AssetBalance{}, "", err
|
||||||
}
|
}
|
||||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
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 {
|
}); err != nil {
|
||||||
return ledger.AssetBalance{}, "", err
|
return ledger.AssetBalance{}, "", err
|
||||||
}
|
}
|
||||||
@ -494,7 +494,7 @@ func (r *Repository) CreditTaskReward(ctx context.Context, command ledger.TaskRe
|
|||||||
return ledger.TaskRewardReceipt{}, err
|
return ledger.TaskRewardReceipt{}, err
|
||||||
}
|
}
|
||||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
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),
|
taskRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return ledger.TaskRewardReceipt{}, err
|
return ledger.TaskRewardReceipt{}, err
|
||||||
@ -581,7 +581,7 @@ func (r *Repository) ApplyGameCoinChange(ctx context.Context, command ledger.Gam
|
|||||||
return ledger.GameCoinChangeReceipt{}, err
|
return ledger.GameCoinChangeReceipt{}, err
|
||||||
}
|
}
|
||||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
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 {
|
}); err != nil {
|
||||||
return ledger.GameCoinChangeReceipt{}, err
|
return ledger.GameCoinChangeReceipt{}, err
|
||||||
}
|
}
|
||||||
@ -680,7 +680,7 @@ func (r *Repository) AdminCreditCoinSellerStock(ctx context.Context, command led
|
|||||||
return ledger.CoinSellerStockCreditReceipt{}, err
|
return ledger.CoinSellerStockCreditReceipt{}, err
|
||||||
}
|
}
|
||||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
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),
|
coinSellerStockCreditedEvent(transactionID, command.CommandID, metadata, nowMs),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return ledger.CoinSellerStockCreditReceipt{}, err
|
return ledger.CoinSellerStockCreditReceipt{}, err
|
||||||
@ -796,8 +796,8 @@ func (r *Repository) TransferCoinFromSeller(ctx context.Context, command ledger.
|
|||||||
return ledger.CoinSellerTransferReceipt{}, err
|
return ledger.CoinSellerTransferReceipt{}, err
|
||||||
}
|
}
|
||||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
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.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, 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),
|
coinSellerTransferredEvent(transactionID, command.CommandID, command.SellerUserID, -command.Amount, metadata, nowMs),
|
||||||
rechargeRecordedEvent(transactionID, command.CommandID, command.TargetUserID, command.Amount, metadata, nowMs),
|
rechargeRecordedEvent(transactionID, command.CommandID, command.TargetUserID, command.Amount, metadata, nowMs),
|
||||||
}); err != nil {
|
}); 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{
|
return walletOutboxEvent{
|
||||||
EventID: eventID(transactionID, "WalletBalanceChanged", userID, assetType),
|
EventID: eventID(transactionID, "WalletBalanceChanged", userID, assetType),
|
||||||
EventType: "WalletBalanceChanged",
|
EventType: "WalletBalanceChanged",
|
||||||
@ -1523,6 +1523,7 @@ func balanceChangedEvent(transactionID string, commandID string, userID int64, a
|
|||||||
"frozen_delta": frozenDelta,
|
"frozen_delta": frozenDelta,
|
||||||
"available_after": availableAfter,
|
"available_after": availableAfter,
|
||||||
"frozen_after": frozenAfter,
|
"frozen_after": frozenAfter,
|
||||||
|
"balance_version": balanceVersion,
|
||||||
"metadata": payload,
|
"metadata": payload,
|
||||||
"created_at_ms": nowMs,
|
"created_at_ms": nowMs,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1010,7 +1010,7 @@ func (r *Repository) applyGrantItem(ctx context.Context, tx *sql.Tx, grantID str
|
|||||||
return resourcedomain.ResourceGrantItem{}, err
|
return resourcedomain.ResourceGrantItem{}, err
|
||||||
}
|
}
|
||||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
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 {
|
}); err != nil {
|
||||||
return resourcedomain.ResourceGrantItem{}, err
|
return resourcedomain.ResourceGrantItem{}, err
|
||||||
}
|
}
|
||||||
@ -1085,7 +1085,7 @@ func (r *Repository) applyWalletAssetGrantItem(ctx context.Context, tx *sql.Tx,
|
|||||||
return resourcedomain.ResourceGrantItem{}, err
|
return resourcedomain.ResourceGrantItem{}, err
|
||||||
}
|
}
|
||||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
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 {
|
}); err != nil {
|
||||||
return resourcedomain.ResourceGrantItem{}, err
|
return resourcedomain.ResourceGrantItem{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user