完善
This commit is contained in:
parent
20bef24a12
commit
f6a0fa5f29
@ -14,6 +14,7 @@
|
||||
-> wallet-service 内网入口
|
||||
-> activity-service 内网入口
|
||||
-> game-service 内网入口
|
||||
-> notice-service 内网入口
|
||||
|
||||
room-service 实例之间通过 Redis owner route 和 node registry 直连转发。
|
||||
```
|
||||
@ -22,7 +23,7 @@ room-service 实例之间通过 Redis owner route 和 node registry 直连转发
|
||||
|
||||
- 公网只暴露 `gateway-service`。
|
||||
- 内部 gRPC 服务只监听私网,安全组只允许服务网段访问 `13xxx` 端口。
|
||||
- `user-service`、`wallet-service`、`activity-service`、`game-service` 可以直接挂内网 CLB 或 DNS。
|
||||
- `user-service`、`wallet-service`、`activity-service`、`game-service`、`notice-service` 可以直接挂内网 CLB 或 DNS。
|
||||
- `room-service` 可以挂内网 CLB/DNS 给 gateway 使用,但 owner 转发必须使用实例级 `advertise_addr`,不能使用 CLB 地址。
|
||||
- MySQL、Redis、MQ 使用腾讯云托管资源,不在业务 CVM 上部署;服务只通过内网 endpoint 访问这些托管依赖。
|
||||
|
||||
@ -31,7 +32,8 @@ room-service 实例之间通过 Redis owner route 和 node registry 直连转发
|
||||
| 层级 | 地址 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 公网 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 服务监听器 |
|
||||
| 后台平台 | `https://hyapp-platform.global-interaction.com` | 香港 Lighthouse 上 nginx 终止 HTTPS,反代本机 `admin-server:13100` |
|
||||
| room/user/activity/game/notice 内网入口 | `10.2.1.16:13001/13005/13006/13008/13009` | `lb-epvnr4o0` 的 Go 服务监听器 |
|
||||
| wallet 内网入口 | `10.2.1.15:13004` | `lb-4f5xi6p0` 的 Go 服务监听器 |
|
||||
|
||||
当前生产镜像仓库前缀是 `10.2.1.3:18082/hyapp`。部署脚本和 systemd env 只允许使用 `hyapp` 项目路径。
|
||||
@ -109,6 +111,22 @@ lease_ttl: "10s"
|
||||
|
||||
只有当需要动态配置、灰度下发、审计回滚时,再引入 Consul/Nacos/Apollo。当前代码的启动配置仍以单服务 YAML 为边界。
|
||||
|
||||
线上字段契约以 `services/<service>/configs/config.tencent.example.yaml` 为准。开发时新增配置字段的动作必须同时更新三处:
|
||||
|
||||
1. `services/<service>/configs/config.yaml`:本地直跑默认配置。
|
||||
2. `services/<service>/configs/config.docker.yaml`:本地 Docker/Compose 配置。
|
||||
3. `services/<service>/configs/config.tencent.example.yaml`:线上渲染契约。
|
||||
|
||||
部署前运行:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/validate_rendered_configs.py \
|
||||
--inventory deploy/tencent-tat/inventory.prod.example.json \
|
||||
--rendered-dir /tmp/hyapp-rendered-prod
|
||||
```
|
||||
|
||||
这个检查只比较 YAML 字段路径,不比较也不输出配置值。只要本地新增字段没有进入线上契约,或者最终渲染 YAML 漏字段,部署前会直接失败。
|
||||
|
||||
托管依赖边界:
|
||||
|
||||
- 腾讯云 MySQL 是业务事实和恢复来源;DSN 必须保留 `loc=UTC`。
|
||||
@ -41,6 +41,7 @@ sudo docker inspect --format '{{json .State.Health}}' hyapp-gateway-service
|
||||
- `13006`: activity gRPC
|
||||
- `13007`: cron gRPC
|
||||
- `13008`: game gRPC
|
||||
- `13009`: notice gRPC
|
||||
|
||||
同一台 CVM 上不要启动两个相同端口的同服务实例。做蓝绿发布时优先使用新 CVM 或新容器节点,健康通过后再挂载到 CLB。
|
||||
|
||||
@ -75,7 +76,7 @@ python3 deploy/tencent-tat/hyapp_tat_deploy.py restart \
|
||||
|
||||
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 \
|
||||
--services gateway-service,room-service,user-service,wallet-service,activity-service,game-service,notice-service \
|
||||
--tag 20260513-1349 \
|
||||
--yes
|
||||
```
|
||||
@ -92,6 +93,7 @@ wallet_service_addr: "wallet.service.internal:13004"
|
||||
activity_service_addr: "activity.service.internal:13006"
|
||||
room_service_addr: "room.service.internal:13001"
|
||||
game_service_addr: "game.service.internal:13008"
|
||||
notice_service_addr: "notice.service.internal:13009"
|
||||
```
|
||||
|
||||
如果后续引入 Consul/Nacos,仍建议让业务配置只保存稳定服务名,不把实例 IP 写进每个服务配置。
|
||||
|
||||
3
deploy/standalone/docker/admin-platform.env.example
Normal file
3
deploy/standalone/docker/admin-platform.env.example
Normal file
@ -0,0 +1,3 @@
|
||||
IMAGE=10.2.1.3:18082/hyapp/admin-platform:REPLACE_TAG
|
||||
CONTAINER_NAME=hyapp-admin-platform
|
||||
STOP_TIMEOUT_SEC=15
|
||||
4
deploy/standalone/docker/admin-server.env.example
Normal file
4
deploy/standalone/docker/admin-server.env.example
Normal file
@ -0,0 +1,4 @@
|
||||
IMAGE=10.2.1.3:18082/hyapp/admin-server:REPLACE_TAG
|
||||
CONTAINER_NAME=hyapp-admin-server
|
||||
CONFIG_PATH=/etc/hyapp/admin-server/config.yaml
|
||||
STOP_TIMEOUT_SEC=30
|
||||
5
deploy/standalone/docker/notice-service.env.example
Normal file
5
deploy/standalone/docker/notice-service.env.example
Normal file
@ -0,0 +1,5 @@
|
||||
# Image is immutable per release; publish scripts pull this tag before restarting systemd.
|
||||
IMAGE=10.2.1.3:18082/hyapp/notice-service:REPLACE_WITH_RELEASE_TAG
|
||||
CONTAINER_NAME=hyapp-notice-service
|
||||
CONFIG_PATH=/etc/hyapp/notice-service/config.yaml
|
||||
STOP_TIMEOUT_SEC=60
|
||||
17
deploy/standalone/systemd/hyapp-admin-platform.service
Normal file
17
deploy/standalone/systemd/hyapp-admin-platform.service
Normal file
@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=HYApp admin platform frontend
|
||||
After=docker.service network-online.target hyapp-admin-server.service
|
||||
Wants=network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/admin-platform/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop -t ${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
17
deploy/standalone/systemd/hyapp-admin-server.service
Normal file
17
deploy/standalone/systemd/hyapp-admin-server.service
Normal file
@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=HYApp admin-server
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/admin-server/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --mount=type=bind,src=/var/lib/hyapp/admin-server/storage,dst=/app/storage ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop -t ${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
23
deploy/standalone/systemd/hyapp-notice-service.service
Normal file
23
deploy/standalone/systemd/hyapp-notice-service.service
Normal file
@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=Hyapp notice-service container
|
||||
Requires=docker.service
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/notice-service/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13009 -service=notice-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStartSec=60
|
||||
TimeoutStopSec=90
|
||||
KillMode=none
|
||||
LimitNOFILE=1048576
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -22,8 +22,8 @@ MySQL、Redis、MQ 都按腾讯云托管资源处理。本方案不会在这些
|
||||
| `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` |
|
||||
| `new-app-activity-1` | `ins-n8wut6n8` | `10.2.1.11` | `activity-service`, `notice-service` |
|
||||
| `new-app-activity-2` | `ins-d1n303sa` | `10.2.1.7` | `activity-service`, `notice-service` |
|
||||
| `pay-1` | `ins-ceqfcxd2` | `10.2.11.14` | `wallet-service` |
|
||||
| `pay-2` | `ins-awhb74q6` | `10.2.12.5` | `wallet-service` |
|
||||
|
||||
@ -48,10 +48,10 @@ MySQL、Redis、MQ 都按腾讯云托管资源处理。本方案不会在这些
|
||||
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:
|
||||
7. 发布机安装腾讯云 Python SDK 和 YAML 解析依赖:
|
||||
|
||||
```bash
|
||||
python3 -m pip install tencentcloud-sdk-python
|
||||
python3 -m pip install tencentcloud-sdk-python PyYAML
|
||||
```
|
||||
|
||||
凭据通过环境变量提供:
|
||||
@ -72,7 +72,7 @@ export TENCENTCLOUD_REGION="me-saudi-arabia"
|
||||
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
|
||||
python3 -m pip install --user tencentcloud-sdk-python PyYAML
|
||||
```
|
||||
|
||||
拉取本仓库:
|
||||
@ -98,6 +98,23 @@ registry = ${HARBOR_REGISTRY}/${HARBOR_PROJECT}
|
||||
|
||||
业务配置放到各目标机 `/etc/hyapp/<service>/config.yaml`。腾讯云 MySQL、Redis、MQ、JWT、IM/RTC/COS 等密钥不要提交到 Git。
|
||||
|
||||
配置字段契约以 `services/<service>/configs/config.tencent.example.yaml` 为准。开发时如果在本地 `config.yaml` 或 `config.docker.yaml` 新增字段,必须同时补到 `config.tencent.example.yaml`;生产渲染前运行校验,避免线上漏字段:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/validate_rendered_configs.py \
|
||||
--inventory deploy/tencent-tat/inventory.prod.example.json
|
||||
```
|
||||
|
||||
如果已经渲染了生产最终配置,还要校验每台机器的最终 YAML:
|
||||
|
||||
```bash
|
||||
python3 deploy/tencent-tat/validate_rendered_configs.py \
|
||||
--inventory deploy/tencent-tat/inventory.prod.example.json \
|
||||
--rendered-dir /tmp/hyapp-rendered-prod
|
||||
```
|
||||
|
||||
该脚本只比较 YAML key,不打印配置值,避免把生产密钥带到日志里。
|
||||
|
||||
## 构建并推送镜像
|
||||
|
||||
在 `deploy` 服务器上执行。2 核 2GiB 机器可以作为调度器;如果全量构建 OOM 或太慢,把构建放到 CI,deploy 服务器只负责调度发布。
|
||||
@ -113,6 +130,7 @@ docker build -t "$REGISTRY/user-service:$TAG" -f services/user-service/Dockerfil
|
||||
docker build -t "$REGISTRY/wallet-service:$TAG" -f services/wallet-service/Dockerfile .
|
||||
docker build -t "$REGISTRY/activity-service:$TAG" -f services/activity-service/Dockerfile .
|
||||
docker build -t "$REGISTRY/game-service:$TAG" -f services/game-service/Dockerfile .
|
||||
docker build -t "$REGISTRY/notice-service:$TAG" -f services/notice-service/Dockerfile .
|
||||
|
||||
docker push "$REGISTRY/gateway-service:$TAG"
|
||||
docker push "$REGISTRY/room-service:$TAG"
|
||||
@ -120,6 +138,7 @@ docker push "$REGISTRY/user-service:$TAG"
|
||||
docker push "$REGISTRY/wallet-service:$TAG"
|
||||
docker push "$REGISTRY/activity-service:$TAG"
|
||||
docker push "$REGISTRY/game-service:$TAG"
|
||||
docker push "$REGISTRY/notice-service:$TAG"
|
||||
```
|
||||
|
||||
如果实际用 Harbor,把 `REGISTRY` 换成 `${HARBOR_REGISTRY}/${HARBOR_PROJECT}`,并先 `docker login`。
|
||||
@ -152,6 +171,7 @@ vi deploy/tencent-tat/inventory.prod.json
|
||||
| `user-service` | `lb-epvnr4o0` | `lbl-honi8z3a` | `10.2.1.16:13005` |
|
||||
| `activity-service` | `lb-epvnr4o0` | `lbl-j7oqtfhq` | `10.2.1.16:13006` |
|
||||
| `game-service` | `lb-epvnr4o0` | `lbl-ku138b4y` | `10.2.1.16:13008` |
|
||||
| `notice-service` | `lb-epvnr4o0` | `lbl-aux9xsjq` | `10.2.1.16:13009` |
|
||||
| `wallet-service` | `lb-4f5xi6p0` | `lbl-9wi5mvu4` | `10.2.1.15:13004` |
|
||||
|
||||
Harbor 项目使用 `hyapp`,当前生产镜像前缀是 `10.2.1.3:18082/hyapp`。不要使用其他业务项目名前缀。
|
||||
@ -164,6 +184,7 @@ Harbor 项目使用 `hyapp`,当前生产镜像前缀是 `10.2.1.3:18082/hyapp`
|
||||
- `room-service`:`mysql_dsn` 指向腾讯云 MySQL,`redis_addr` 指向腾讯云 Redis;`advertise_addr` 指向本机私网 IP。
|
||||
- `user-service`:`mysql_dsn`、worker 里引用的 wallet/room MySQL DSN、登录风控 Redis 都指向腾讯云托管实例。
|
||||
- `wallet-service`、`activity-service`、`game-service`:MySQL 和下游 gRPC 地址都使用腾讯云托管资源或内网 CLB/DNS。
|
||||
- `notice-service`:`mysql_dsn` 指向 `hyapp_notice`,`wallet_database` 指向 `hyapp_wallet`;它只读取 wallet outbox 并写自己的投递位点,不修改钱包事实表。
|
||||
- MQ 当前只作为 outbox/event bus 方向的外部托管依赖;本脚本不会创建 topic、consumer group 或本地 broker。
|
||||
|
||||
腾讯云 MySQL DSN 必须保留 `loc=UTC`。Redis 使用同一托管实例时,用 `redis_db` 或 key prefix 区分业务域,不要把 Redis 当恢复事实来源。
|
||||
@ -184,7 +205,7 @@ python3 deploy/tencent-tat/hyapp_tat_deploy.py plan \
|
||||
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
|
||||
--services gateway-service,room-service,user-service,wallet-service,activity-service,game-service,notice-service
|
||||
```
|
||||
|
||||
`discover` 会读取每台目标机的 `hyapp-*` systemd unit、`/etc/hyapp/*/docker.env`、`hyapp-*` 容器和监听端口,并通过 CLB API 按后端私网 IP/端口反查负载均衡绑定。
|
||||
@ -205,7 +226,7 @@ python3 deploy/tencent-tat/hyapp_tat_deploy.py restart \
|
||||
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 \
|
||||
--services room-service,user-service,wallet-service,activity-service,game-service,notice-service \
|
||||
--yes
|
||||
```
|
||||
|
||||
@ -226,7 +247,7 @@ python3 deploy/tencent-tat/hyapp_tat_deploy.py deploy \
|
||||
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 \
|
||||
--services gateway-service,room-service,user-service,wallet-service,activity-service,game-service,notice-service \
|
||||
--tag 20260513-1349 \
|
||||
--yes
|
||||
```
|
||||
|
||||
@ -523,7 +523,7 @@ 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
|
||||
ss -lntp 2>/dev/null | awk 'NR==1 || /:13000|:13001|:13004|:13005|:13006|:13008|:13009|:13100|:13101|:13104|:13105|:13106|:13108|:13109/' || true
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
},
|
||||
"deploy_server": {
|
||||
"name": "deploy",
|
||||
"instance_id": "ns-ntmpcd3a",
|
||||
"instance_id": "ins-ntmpcd3a",
|
||||
"public_ip": "43.164.75.199",
|
||||
"private_ip": "10.2.1.3",
|
||||
"role": "control plane only: build/push images and call Tencent TAT/CLB APIs; do not attach to business CLB."
|
||||
@ -157,6 +157,25 @@
|
||||
"drain_seconds": 5
|
||||
}
|
||||
},
|
||||
"notice-service": {
|
||||
"unit": "hyapp-notice-service",
|
||||
"container": "hyapp-notice-service",
|
||||
"env_file": "/etc/hyapp/notice-service/docker.env",
|
||||
"image_template": "${REGISTRY}/notice-service:${TAG}",
|
||||
"target_port": 13009,
|
||||
"hosts": [
|
||||
"new-app-activity-1",
|
||||
"new-app-activity-2"
|
||||
],
|
||||
"clb": {
|
||||
"enabled": true,
|
||||
"load_balancer_id": "lb-epvnr4o0",
|
||||
"listener_ids": [
|
||||
"lbl-aux9xsjq"
|
||||
],
|
||||
"drain_seconds": 5
|
||||
}
|
||||
},
|
||||
"game-service": {
|
||||
"unit": "hyapp-game-service",
|
||||
"container": "hyapp-game-service",
|
||||
|
||||
@ -18,6 +18,8 @@ class TatDeployPlanTests(unittest.TestCase):
|
||||
self.assertEqual(inventory["hosts"]["gateway-2"]["private_ip"], "10.2.2.17")
|
||||
self.assertEqual(inventory["services"]["wallet-service"]["hosts"], ["pay-1", "pay-2"])
|
||||
self.assertEqual(inventory["services"]["game-service"]["target_port"], 13008)
|
||||
self.assertEqual(inventory["services"]["notice-service"]["hosts"], ["new-app-activity-1", "new-app-activity-2"])
|
||||
self.assertEqual(inventory["services"]["notice-service"]["target_port"], 13009)
|
||||
self.assertIn("TencentDB for MySQL", inventory["managed_dependencies"]["mysql"])
|
||||
|
||||
def test_plan_is_service_first_and_keeps_clb_enabled(self) -> None:
|
||||
|
||||
124
deploy/tencent-tat/validate_rendered_configs.py
Normal file
124
deploy/tencent-tat/validate_rendered_configs.py
Normal file
@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
CONFIG_FILENAMES = ("config.yaml", "config.docker.yaml", "config.tencent.example.yaml")
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
raise RuntimeError(f"config file missing: {path}")
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError(f"config file must be a mapping: {path}")
|
||||
return data
|
||||
|
||||
|
||||
def load_inventory(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
raise RuntimeError(f"inventory missing: {path}")
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data.get("services"), dict):
|
||||
raise RuntimeError("inventory.services is required")
|
||||
return data
|
||||
|
||||
|
||||
def mapping_paths(value: Any, prefix: str = "") -> set[str]:
|
||||
# 只比较 YAML object key,不比较值;密钥和环境差异不会被打印或误判。
|
||||
if not isinstance(value, dict):
|
||||
return set()
|
||||
paths: set[str] = set()
|
||||
for key, child in value.items():
|
||||
current = f"{prefix}.{key}" if prefix else str(key)
|
||||
paths.add(current)
|
||||
paths.update(mapping_paths(child, current))
|
||||
return paths
|
||||
|
||||
|
||||
def selected_service_names(inventory: dict[str, Any], raw_services: str) -> list[str]:
|
||||
names = [item.strip() for item in raw_services.split(",") if item.strip()]
|
||||
if not names:
|
||||
return sorted(str(name) for name in inventory["services"].keys())
|
||||
unknown = [name for name in names if name not in inventory["services"]]
|
||||
if unknown:
|
||||
raise RuntimeError("unknown services: " + ", ".join(unknown))
|
||||
return list(dict.fromkeys(names))
|
||||
|
||||
|
||||
def selected_hosts(service: dict[str, Any], raw_hosts: str) -> list[str]:
|
||||
hosts = [str(item) for item in service.get("hosts") or []]
|
||||
requested = [item.strip() for item in raw_hosts.split(",") if item.strip()]
|
||||
if not requested:
|
||||
return hosts
|
||||
invalid = [host for host in requested if host not in hosts]
|
||||
if invalid:
|
||||
raise RuntimeError("selected hosts are not assigned to this service: " + ", ".join(invalid))
|
||||
return requested
|
||||
|
||||
|
||||
def validate_service_templates(service_name: str) -> list[str]:
|
||||
errors: list[str] = []
|
||||
config_dir = Path("services") / service_name / "configs"
|
||||
configs = {name: load_yaml(config_dir / name) for name in CONFIG_FILENAMES}
|
||||
contract_paths = mapping_paths(configs["config.tencent.example.yaml"])
|
||||
for filename in ("config.yaml", "config.docker.yaml"):
|
||||
missing = sorted(mapping_paths(configs[filename]) - contract_paths)
|
||||
if missing:
|
||||
errors.append(
|
||||
f"{service_name}: {filename} contains keys missing from config.tencent.example.yaml: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def validate_rendered_config(service_name: str, host_name: str, rendered_dir: Path) -> list[str]:
|
||||
contract = load_yaml(Path("services") / service_name / "configs" / "config.tencent.example.yaml")
|
||||
rendered_path = rendered_dir / host_name / service_name / "config.yaml"
|
||||
rendered = load_yaml(rendered_path)
|
||||
missing = sorted(mapping_paths(contract) - mapping_paths(rendered))
|
||||
if not missing:
|
||||
return []
|
||||
return [
|
||||
f"{service_name}@{host_name}: rendered config missing keys required by config.tencent.example.yaml: "
|
||||
+ ", ".join(missing)
|
||||
]
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate service config contracts before TAT deployment.")
|
||||
parser.add_argument("--inventory", default="deploy/tencent-tat/inventory.prod.example.json")
|
||||
parser.add_argument("--rendered-dir", default="", help="Directory with <host>/<service>/config.yaml files.")
|
||||
parser.add_argument("--services", default="", help="Comma separated service names. Defaults to all inventory services.")
|
||||
parser.add_argument("--hosts", default="", help="Optional comma separated hosts assigned to the selected service(s).")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
inventory = load_inventory(Path(args.inventory))
|
||||
rendered_dir = Path(args.rendered_dir).expanduser().resolve() if args.rendered_dir else None
|
||||
errors: list[str] = []
|
||||
for service_name in selected_service_names(inventory, args.services):
|
||||
errors.extend(validate_service_templates(service_name))
|
||||
if rendered_dir is None:
|
||||
continue
|
||||
for host_name in selected_hosts(inventory["services"][service_name], args.hosts):
|
||||
errors.extend(validate_rendered_config(service_name, host_name, rendered_dir))
|
||||
if errors:
|
||||
print(json.dumps({"ok": False, "errors": errors}, ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps({"ok": True}, ensure_ascii=False))
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
39
server/admin/Dockerfile
Normal file
39
server/admin/Dockerfile
Normal file
@ -0,0 +1,39 @@
|
||||
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 api/go.mod api/go.sum ./api/
|
||||
COPY server/admin/go.mod server/admin/go.sum ./server/admin/
|
||||
WORKDIR /src/server/admin
|
||||
RUN GOWORK=off go mod download
|
||||
|
||||
WORKDIR /src
|
||||
COPY api ./api
|
||||
COPY server/admin ./server/admin
|
||||
|
||||
WORKDIR /src/server/admin
|
||||
RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server ./cmd/server
|
||||
|
||||
FROM alpine:3.20
|
||||
|
||||
ENV TZ=UTC
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache ca-certificates wget && adduser -D -g '' appuser
|
||||
|
||||
COPY --from=builder /out/server /app/server
|
||||
COPY server/admin/configs/config.yaml /app/config.yaml
|
||||
COPY server/admin/migrations /app/migrations
|
||||
|
||||
USER appuser
|
||||
|
||||
EXPOSE 13100
|
||||
|
||||
ENTRYPOINT ["/app/server"]
|
||||
CMD ["-config", "/app/config.yaml"]
|
||||
@ -74,12 +74,6 @@ func main() {
|
||||
if err := ensureDatabase(cfg.MySQLDSN); err != nil {
|
||||
fatalRuntime("ensure_mysql_database_failed", err)
|
||||
}
|
||||
if err := ensureDatabase(cfg.WalletMySQLDSN); err != nil {
|
||||
fatalRuntime("ensure_wallet_mysql_database_failed", err)
|
||||
}
|
||||
if err := ensureDatabase(cfg.RoomMySQLDSN); err != nil {
|
||||
fatalRuntime("ensure_room_mysql_database_failed", err)
|
||||
}
|
||||
|
||||
dbLogger := logger.New(log.New(logging.StdWriter(), "", 0), logger.Config{
|
||||
LogLevel: logger.Warn,
|
||||
|
||||
68
server/admin/configs/config.tencent.example.yaml
Normal file
68
server/admin/configs/config.tencent.example.yaml
Normal file
@ -0,0 +1,68 @@
|
||||
service_name: admin-server
|
||||
node_id: admin-hk-lighthouse
|
||||
environment: prod
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
include_request_body: false
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
http_addr: "127.0.0.1:13100"
|
||||
mysql_dsn: "admin_user:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"
|
||||
user_mysql_dsn: "user_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC"
|
||||
wallet_mysql_dsn: "wallet_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC"
|
||||
room_mysql_dsn: "room_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC"
|
||||
mysql_auto_migrate: false
|
||||
migrations:
|
||||
enabled: true
|
||||
# TAT/binary 部署使用宿主机路径;Docker 镜像部署时改为 /app/migrations。
|
||||
dir: "/opt/hyapp/admin-server/migrations"
|
||||
allow_checksum_repair: false
|
||||
jwt_secret: "REPLACE_WITH_AT_LEAST_32_RANDOM_CHARS"
|
||||
access_token_ttl: "30m"
|
||||
refresh_token_ttl: "168h"
|
||||
cors_allowed_origins:
|
||||
- "http://43.128.56.40"
|
||||
- "http://hyapp-platform.global-interaction.com"
|
||||
- "https://hyapp-platform.global-interaction.com"
|
||||
refresh_cookie_secure: true
|
||||
bootstrap:
|
||||
# 仅首次初始化或显式 -bootstrap 时打开;生产常驻进程不要每次启动重放 seed。
|
||||
enabled: false
|
||||
seed_demo_data: false
|
||||
username: "admin"
|
||||
name: "admin"
|
||||
team: "平台运维"
|
||||
password: "REPLACE_WITH_TEMP_BOOTSTRAP_PASSWORD"
|
||||
user_service:
|
||||
addr: "10.2.1.16:13005"
|
||||
request_timeout: "3s"
|
||||
wallet_service:
|
||||
addr: "10.2.1.15:13004"
|
||||
request_timeout: "3s"
|
||||
activity_service:
|
||||
addr: "10.2.1.16:13006"
|
||||
request_timeout: "3s"
|
||||
game_service:
|
||||
addr: "10.2.1.16:13008"
|
||||
request_timeout: "3s"
|
||||
tencent-cos:
|
||||
enabled: true
|
||||
secret-id: "REPLACE_ME"
|
||||
secret-key: "REPLACE_ME"
|
||||
bucket-name: "REPLACE_ME"
|
||||
region: "me-saudi-arabia"
|
||||
access-url: "https://media.example.com"
|
||||
object-prefix: "admin"
|
||||
redis:
|
||||
enabled: true
|
||||
addr: "10.2.21.16:6379"
|
||||
password: "REPLACE_ME"
|
||||
db: 0
|
||||
key_prefix: "admin:"
|
||||
jobs:
|
||||
enabled: true
|
||||
worker_interval: "5s"
|
||||
lease_ttl: "2m"
|
||||
max_attempts: 3
|
||||
artifact_dir: "/var/lib/hyapp/admin-server/storage/exports"
|
||||
@ -10,4 +10,4 @@ WHERE code = 'services'
|
||||
DELETE FROM admin_permissions
|
||||
WHERE code IN ('service:view', 'service:create', 'service:update', 'service:operate');
|
||||
|
||||
DROP TABLE IF EXISTS admin_service_assets;
|
||||
-- 生产账号不要求 DROP 权限;服务管理菜单和权限已移除,废弃表即使保留也不再被代码访问。
|
||||
|
||||
@ -10,4 +10,4 @@ WHERE code = 'notifications'
|
||||
DELETE FROM admin_permissions
|
||||
WHERE code IN ('notification:view', 'notification:read', 'notification:read-all', 'notification:delete');
|
||||
|
||||
DROP TABLE IF EXISTS admin_notifications;
|
||||
-- 生产账号不要求 DROP 权限;通知中心菜单和权限已移除,废弃表即使保留也不再被代码访问。
|
||||
|
||||
146
server/admin/migrations/009_admin_navigation_seed.sql
Normal file
146
server/admin/migrations/009_admin_navigation_seed.sql
Normal file
@ -0,0 +1,146 @@
|
||||
-- Admin navigation is a production contract, not local demo data.
|
||||
-- This migration keeps the current operations/activity/game menus reproducible
|
||||
-- when bootstrap seed is disabled on production services.
|
||||
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||
|
||||
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
|
||||
('金币流水查看', 'coin-ledger:view', 'menu', '', @now_ms, @now_ms),
|
||||
('支付账单查看', 'payment-bill:view', 'menu', '', @now_ms, @now_ms),
|
||||
('内购配置查看', 'payment-product:view', 'menu', '', @now_ms, @now_ms),
|
||||
('内购配置创建', 'payment-product:create', 'button', '', @now_ms, @now_ms),
|
||||
('内购配置更新', 'payment-product:update', 'button', '', @now_ms, @now_ms),
|
||||
('内购配置删除', 'payment-product:delete', 'button', '', @now_ms, @now_ms),
|
||||
('游戏查看', 'game:view', 'menu', '', @now_ms, @now_ms),
|
||||
('游戏创建', 'game:create', 'button', '', @now_ms, @now_ms),
|
||||
('游戏更新', 'game:update', 'button', '', @now_ms, @now_ms),
|
||||
('游戏状态', 'game:status', 'button', '', @now_ms, @now_ms),
|
||||
('每日任务查看', 'daily-task:view', 'menu', '', @now_ms, @now_ms),
|
||||
('每日任务创建', 'daily-task:create', 'button', '', @now_ms, @now_ms),
|
||||
('每日任务更新', 'daily-task:update', 'button', '', @now_ms, @now_ms),
|
||||
('每日任务状态', 'daily-task:status', 'button', '', @now_ms, @now_ms),
|
||||
('注册奖励查看', 'registration-reward:view', 'menu', '', @now_ms, @now_ms),
|
||||
('注册奖励更新', 'registration-reward:update', 'button', '', @now_ms, @now_ms)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
kind = VALUES(kind),
|
||||
description = VALUES(description),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms) VALUES
|
||||
(NULL, 'APP配置', 'app-config', '', 'settings', '', 66, TRUE, @now_ms, @now_ms),
|
||||
(NULL, '运营管理', 'operations', '', 'operations', '', 68, TRUE, @now_ms, @now_ms),
|
||||
(NULL, '支付管理', 'payment', '', 'wallet', '', 69, TRUE, @now_ms, @now_ms),
|
||||
(NULL, '活动管理', 'activities', '', 'campaign', '', 70, TRUE, @now_ms, @now_ms),
|
||||
(NULL, '游戏管理', 'games', '', 'sports_esports', '', 71, TRUE, @now_ms, @now_ms)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
parent_id = VALUES(parent_id),
|
||||
title = VALUES(title),
|
||||
path = VALUES(path),
|
||||
icon = VALUES(icon),
|
||||
permission_code = VALUES(permission_code),
|
||||
sort = VALUES(sort),
|
||||
visible = VALUES(visible),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||
SELECT parent.id, '内购配置', 'payment-recharge-products', '/app-config/recharge-products', 'wallet', 'payment-product:view', 68, TRUE, @now_ms, @now_ms
|
||||
FROM admin_menus parent
|
||||
WHERE parent.code = 'app-config'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
parent_id = VALUES(parent_id),
|
||||
title = VALUES(title),
|
||||
path = VALUES(path),
|
||||
icon = VALUES(icon),
|
||||
permission_code = VALUES(permission_code),
|
||||
sort = VALUES(sort),
|
||||
visible = VALUES(visible),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||
SELECT parent.id, '金币流水', 'operation-coin-ledger', '/operations/coin-ledger', 'receipt', 'coin-ledger:view', 68, TRUE, @now_ms, @now_ms
|
||||
FROM admin_menus parent
|
||||
WHERE parent.code = 'operations'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
parent_id = VALUES(parent_id),
|
||||
title = VALUES(title),
|
||||
path = VALUES(path),
|
||||
icon = VALUES(icon),
|
||||
permission_code = VALUES(permission_code),
|
||||
sort = VALUES(sort),
|
||||
visible = VALUES(visible),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||
SELECT parent.id, '账单列表', 'payment-bill-list', '/payment/bills', 'receipt', 'payment-bill:view', 68, TRUE, @now_ms, @now_ms
|
||||
FROM admin_menus parent
|
||||
WHERE parent.code = 'payment'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
parent_id = VALUES(parent_id),
|
||||
title = VALUES(title),
|
||||
path = VALUES(path),
|
||||
icon = VALUES(icon),
|
||||
permission_code = VALUES(permission_code),
|
||||
sort = VALUES(sort),
|
||||
visible = VALUES(visible),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||
SELECT parent.id, '每日任务', 'daily-task-list', '/activities/daily-tasks', 'task', 'daily-task:view', 69, TRUE, @now_ms, @now_ms
|
||||
FROM admin_menus parent
|
||||
WHERE parent.code = 'activities'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
parent_id = VALUES(parent_id),
|
||||
title = VALUES(title),
|
||||
path = VALUES(path),
|
||||
icon = VALUES(icon),
|
||||
permission_code = VALUES(permission_code),
|
||||
sort = VALUES(sort),
|
||||
visible = VALUES(visible),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||
SELECT parent.id, '注册奖励', 'registration-reward', '/activities/registration-reward', 'gift', 'registration-reward:view', 70, TRUE, @now_ms, @now_ms
|
||||
FROM admin_menus parent
|
||||
WHERE parent.code = 'activities'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
parent_id = VALUES(parent_id),
|
||||
title = VALUES(title),
|
||||
path = VALUES(path),
|
||||
icon = VALUES(icon),
|
||||
permission_code = VALUES(permission_code),
|
||||
sort = VALUES(sort),
|
||||
visible = VALUES(visible),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||
SELECT parent.id, '游戏列表', 'game-list', '/games', 'sports_esports', 'game:view', 70, TRUE, @now_ms, @now_ms
|
||||
FROM admin_menus parent
|
||||
WHERE parent.code = 'games'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
parent_id = VALUES(parent_id),
|
||||
title = VALUES(title),
|
||||
path = VALUES(path),
|
||||
icon = VALUES(icon),
|
||||
permission_code = VALUES(permission_code),
|
||||
sort = VALUES(sort),
|
||||
visible = VALUES(visible),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT INTO admin_roles (name, code, description, created_at_ms, updated_at_ms) VALUES
|
||||
('平台管理员', 'platform-admin', '拥有管理后台全部权限', @now_ms, @now_ms)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
description = VALUES(description),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||
SELECT admin_role.id, admin_permission.id
|
||||
FROM admin_roles admin_role
|
||||
JOIN admin_permissions admin_permission
|
||||
WHERE admin_role.code = 'platform-admin';
|
||||
|
||||
INSERT IGNORE INTO admin_user_roles (user_id, role_id)
|
||||
SELECT admin_user.id, admin_role.id
|
||||
FROM admin_users admin_user
|
||||
JOIN admin_roles admin_role
|
||||
WHERE admin_user.username IN ('admin', 'hyappadmin') AND admin_role.code = 'platform-admin';
|
||||
@ -8,6 +8,32 @@ log:
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
http_addr: ":13000"
|
||||
cors:
|
||||
enabled: true
|
||||
allowed_origins:
|
||||
- "http://localhost:3000"
|
||||
- "http://127.0.0.1:3000"
|
||||
- "http://localhost:5173"
|
||||
- "http://127.0.0.1:5173"
|
||||
- "https://global-interaction.com"
|
||||
- "https://www.global-interaction.com"
|
||||
- "https://api.global-interaction.com"
|
||||
allowed_methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||
allowed_headers:
|
||||
- "Authorization"
|
||||
- "Content-Type"
|
||||
- "X-Request-ID"
|
||||
- "X-App-Code"
|
||||
- "X-HY-App-Code"
|
||||
- "X-App-Package"
|
||||
- "X-Package-Name"
|
||||
- "X-App-Bundle-ID"
|
||||
- "X-Bundle-ID"
|
||||
- "X-App-Platform"
|
||||
- "X-Platform"
|
||||
expose_headers: ["X-Request-ID"]
|
||||
allow_credentials: true
|
||||
max_age_sec: 600
|
||||
jwt_secret: "dev-shared-secret"
|
||||
room_service_addr: "room-service:13001"
|
||||
user_service_addr: "user-service:13005"
|
||||
|
||||
@ -8,6 +8,29 @@ log:
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
http_addr: ":13000"
|
||||
cors:
|
||||
# H5/Web Google 登录会先发 HTTPS preflight;gateway 必须在业务 handler 前返回 CORS 头。
|
||||
enabled: true
|
||||
allowed_origins:
|
||||
- "https://global-interaction.com"
|
||||
- "https://www.global-interaction.com"
|
||||
- "https://api.global-interaction.com"
|
||||
allowed_methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||
allowed_headers:
|
||||
- "Authorization"
|
||||
- "Content-Type"
|
||||
- "X-Request-ID"
|
||||
- "X-App-Code"
|
||||
- "X-HY-App-Code"
|
||||
- "X-App-Package"
|
||||
- "X-Package-Name"
|
||||
- "X-App-Bundle-ID"
|
||||
- "X-Bundle-ID"
|
||||
- "X-App-Platform"
|
||||
- "X-Platform"
|
||||
expose_headers: ["X-Request-ID"]
|
||||
allow_credentials: true
|
||||
max_age_sec: 600
|
||||
jwt_secret: "REPLACE_WITH_GATEWAY_JWT_SECRET"
|
||||
room_service_addr: "room-service.internal:13001"
|
||||
user_service_addr: "user-service.internal:13005"
|
||||
|
||||
@ -8,6 +8,32 @@ log:
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
http_addr: ":13000"
|
||||
cors:
|
||||
enabled: true
|
||||
allowed_origins:
|
||||
- "http://localhost:3000"
|
||||
- "http://127.0.0.1:3000"
|
||||
- "http://localhost:5173"
|
||||
- "http://127.0.0.1:5173"
|
||||
- "https://global-interaction.com"
|
||||
- "https://www.global-interaction.com"
|
||||
- "https://api.global-interaction.com"
|
||||
allowed_methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||
allowed_headers:
|
||||
- "Authorization"
|
||||
- "Content-Type"
|
||||
- "X-Request-ID"
|
||||
- "X-App-Code"
|
||||
- "X-HY-App-Code"
|
||||
- "X-App-Package"
|
||||
- "X-Package-Name"
|
||||
- "X-App-Bundle-ID"
|
||||
- "X-Bundle-ID"
|
||||
- "X-App-Platform"
|
||||
- "X-Platform"
|
||||
expose_headers: ["X-Request-ID"]
|
||||
allow_credentials: true
|
||||
max_age_sec: 600
|
||||
jwt_secret: "dev-shared-secret"
|
||||
room_service_addr: "127.0.0.1:13001"
|
||||
user_service_addr: "127.0.0.1:13005"
|
||||
|
||||
@ -236,7 +236,15 @@ func New(cfg config.Config) (*App, error) {
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Handler: mux,
|
||||
Handler: httptransport.WithCORS(httptransport.CORSConfig{
|
||||
Enabled: cfg.CORS.Enabled,
|
||||
AllowedOrigins: cfg.CORS.AllowedOrigins,
|
||||
AllowedMethods: cfg.CORS.AllowedMethods,
|
||||
AllowedHeaders: cfg.CORS.AllowedHeaders,
|
||||
ExposeHeaders: cfg.CORS.ExposeHeaders,
|
||||
AllowCredentials: cfg.CORS.AllowCredentials,
|
||||
MaxAgeSec: cfg.CORS.MaxAgeSec,
|
||||
}, mux),
|
||||
}
|
||||
|
||||
return &App{
|
||||
|
||||
@ -17,6 +17,7 @@ type Config struct {
|
||||
NodeID string `yaml:"node_id"`
|
||||
Environment string `yaml:"environment"`
|
||||
HTTPAddr string `yaml:"http_addr"`
|
||||
CORS CORSConfig `yaml:"cors"`
|
||||
JWTSecret string `yaml:"jwt_secret"`
|
||||
RoomServiceAddr string `yaml:"room_service_addr"`
|
||||
UserServiceAddr string `yaml:"user_service_addr"`
|
||||
@ -33,6 +34,24 @@ type Config struct {
|
||||
Log logx.Config `yaml:"log"`
|
||||
}
|
||||
|
||||
// CORSConfig 控制浏览器/H5 访问 gateway API 的跨域入口策略。
|
||||
type CORSConfig struct {
|
||||
// Enabled 为 true 时,gateway 在最外层处理浏览器 preflight,避免 OPTIONS 进入业务 handler。
|
||||
Enabled bool `yaml:"enabled"`
|
||||
// AllowedOrigins 是允许携带凭据访问 API 的 Origin 白名单;生产不要配置裸星号。
|
||||
AllowedOrigins []string `yaml:"allowed_origins"`
|
||||
// AllowedMethods 是 preflight 允许的方法集合。
|
||||
AllowedMethods []string `yaml:"allowed_methods"`
|
||||
// AllowedHeaders 是 preflight 允许的请求头集合。
|
||||
AllowedHeaders []string `yaml:"allowed_headers"`
|
||||
// ExposeHeaders 是实际响应暴露给浏览器读取的响应头集合。
|
||||
ExposeHeaders []string `yaml:"expose_headers"`
|
||||
// AllowCredentials 控制浏览器是否允许带 cookie/Authorization 等凭据。
|
||||
AllowCredentials bool `yaml:"allow_credentials"`
|
||||
// MaxAgeSec 是浏览器缓存 preflight 结果的秒数。
|
||||
MaxAgeSec int64 `yaml:"max_age_sec"`
|
||||
}
|
||||
|
||||
// GRPCClientConfig 控制 gateway 访问内部服务的 gRPC transport 策略。
|
||||
type GRPCClientConfig struct {
|
||||
// DefaultTimeout 是没有显式 request deadline 时的 RPC 总预算,包含重试耗时。
|
||||
@ -176,10 +195,39 @@ type TencentCOSConfig struct {
|
||||
// Default 返回本地开发默认配置。
|
||||
func Default() Config {
|
||||
return Config{
|
||||
ServiceName: "gateway-service",
|
||||
NodeID: "gateway-local",
|
||||
Environment: "local",
|
||||
HTTPAddr: ":13000",
|
||||
ServiceName: "gateway-service",
|
||||
NodeID: "gateway-local",
|
||||
Environment: "local",
|
||||
HTTPAddr: ":13000",
|
||||
CORS: CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"https://global-interaction.com",
|
||||
"https://www.global-interaction.com",
|
||||
"https://api.global-interaction.com",
|
||||
},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Request-ID",
|
||||
"X-App-Code",
|
||||
"X-HY-App-Code",
|
||||
"X-App-Package",
|
||||
"X-Package-Name",
|
||||
"X-App-Bundle-ID",
|
||||
"X-Bundle-ID",
|
||||
"X-App-Platform",
|
||||
"X-Platform",
|
||||
},
|
||||
ExposeHeaders: []string{"X-Request-ID"},
|
||||
AllowCredentials: true,
|
||||
MaxAgeSec: 600,
|
||||
},
|
||||
JWTSecret: "gateway-dev-secret",
|
||||
RoomServiceAddr: "127.0.0.1:13001",
|
||||
UserServiceAddr: "127.0.0.1:13005",
|
||||
@ -270,6 +318,22 @@ func (cfg *Config) Normalize() error {
|
||||
if cfg.Environment == "" {
|
||||
cfg.Environment = "local"
|
||||
}
|
||||
cfg.CORS.AllowedOrigins = trimNonEmpty(cfg.CORS.AllowedOrigins)
|
||||
cfg.CORS.AllowedMethods = upperNonEmpty(cfg.CORS.AllowedMethods)
|
||||
cfg.CORS.AllowedHeaders = trimNonEmpty(cfg.CORS.AllowedHeaders)
|
||||
cfg.CORS.ExposeHeaders = trimNonEmpty(cfg.CORS.ExposeHeaders)
|
||||
if len(cfg.CORS.AllowedMethods) == 0 {
|
||||
cfg.CORS.AllowedMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
|
||||
}
|
||||
if len(cfg.CORS.AllowedHeaders) == 0 {
|
||||
cfg.CORS.AllowedHeaders = []string{"Authorization", "Content-Type", "X-Request-ID", "X-App-Code", "X-HY-App-Code", "X-App-Package", "X-Package-Name", "X-App-Bundle-ID", "X-Bundle-ID", "X-App-Platform", "X-Platform"}
|
||||
}
|
||||
if len(cfg.CORS.ExposeHeaders) == 0 {
|
||||
cfg.CORS.ExposeHeaders = []string{"X-Request-ID"}
|
||||
}
|
||||
if cfg.CORS.MaxAgeSec <= 0 {
|
||||
cfg.CORS.MaxAgeSec = 600
|
||||
}
|
||||
cfg.RoomServiceAddr = strings.TrimSpace(cfg.RoomServiceAddr)
|
||||
cfg.UserServiceAddr = strings.TrimSpace(cfg.UserServiceAddr)
|
||||
cfg.WalletServiceAddr = strings.TrimSpace(cfg.WalletServiceAddr)
|
||||
@ -349,3 +413,25 @@ func (cfg *Config) Normalize() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func trimNonEmpty(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func upperNonEmpty(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
if value != "" {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@ -22,6 +22,9 @@ func TestLoad(t *testing.T) {
|
||||
if cfg.RoomServiceAddr != "127.0.0.1:13001" {
|
||||
t.Fatalf("unexpected room addr: %s", cfg.RoomServiceAddr)
|
||||
}
|
||||
if !cfg.CORS.Enabled || !containsString(cfg.CORS.AllowedOrigins, "https://global-interaction.com") || !containsString(cfg.CORS.AllowedHeaders, "X-App-Code") {
|
||||
t.Fatalf("unexpected cors config: %+v", cfg.CORS)
|
||||
}
|
||||
if cfg.GRPCClient.DefaultTimeout != 5*time.Second || cfg.GRPCClient.RetryMaxAttempts != 2 || len(cfg.GRPCClient.RetryableStatusCodes) != 1 || cfg.GRPCClient.RetryableStatusCodes[0] != "UNAVAILABLE" {
|
||||
t.Fatalf("unexpected grpc client policy: %+v", cfg.GRPCClient)
|
||||
}
|
||||
@ -64,6 +67,9 @@ func TestLoadTencentExample(t *testing.T) {
|
||||
if cfg.TencentIM.CallbackURL == "" {
|
||||
t.Fatalf("callback_url template must be present")
|
||||
}
|
||||
if !cfg.CORS.Enabled || !containsString(cfg.CORS.AllowedOrigins, "https://global-interaction.com") || cfg.CORS.MaxAgeSec != 600 {
|
||||
t.Fatalf("tencent cors template must be explicit: %+v", cfg.CORS)
|
||||
}
|
||||
if cfg.AppConfig.MySQLDSN == "" || !strings.Contains(cfg.AppConfig.MySQLDSN, "hyapp_admin") {
|
||||
t.Fatalf("tencent app config mysql dsn template must be explicit: %+v", cfg.AppConfig)
|
||||
}
|
||||
@ -84,6 +90,15 @@ func TestLoadTencentExample(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestLoadRejectsUnsupportedRTCRoomIDType protects the string strRoomId policy at startup.
|
||||
func TestLoadRejectsUnsupportedRTCRoomIDType(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
|
||||
134
services/gateway-service/internal/transport/http/cors.go
Normal file
134
services/gateway-service/internal/transport/http/cors.go
Normal file
@ -0,0 +1,134 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CORSConfig 是 gateway transport 层的浏览器跨域策略;生产只回显白名单 Origin。
|
||||
type CORSConfig struct {
|
||||
Enabled bool
|
||||
AllowedOrigins []string
|
||||
AllowedMethods []string
|
||||
AllowedHeaders []string
|
||||
ExposeHeaders []string
|
||||
AllowCredentials bool
|
||||
MaxAgeSec int64
|
||||
}
|
||||
|
||||
// WithCORS 在业务路由之前处理浏览器 preflight,避免 OPTIONS 被登录/上传等 handler 当成业务请求。
|
||||
func WithCORS(config CORSConfig, next http.Handler) http.Handler {
|
||||
config = normalizeCORSConfig(config)
|
||||
if !config.Enabled {
|
||||
return next
|
||||
}
|
||||
allowedOrigins := allowedOriginSet(config.AllowedOrigins)
|
||||
allowAnyOrigin := allowedOrigins["*"] && !config.AllowCredentials
|
||||
allowedMethods := strings.Join(config.AllowedMethods, ", ")
|
||||
allowedHeaders := strings.Join(config.AllowedHeaders, ", ")
|
||||
exposeHeaders := strings.Join(config.ExposeHeaders, ", ")
|
||||
maxAge := strconv.FormatInt(config.MaxAgeSec, 10)
|
||||
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
origin := strings.TrimSpace(request.Header.Get("Origin"))
|
||||
if origin == "" {
|
||||
next.ServeHTTP(writer, request)
|
||||
return
|
||||
}
|
||||
|
||||
if !allowAnyOrigin && !allowedOrigins[origin] {
|
||||
if isCORSPreflight(request) {
|
||||
http.Error(writer, "cors origin denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(writer, request)
|
||||
return
|
||||
}
|
||||
|
||||
header := writer.Header()
|
||||
header.Add("Vary", "Origin")
|
||||
if isCORSPreflight(request) {
|
||||
header.Add("Vary", "Access-Control-Request-Method")
|
||||
header.Add("Vary", "Access-Control-Request-Headers")
|
||||
}
|
||||
if allowAnyOrigin {
|
||||
header.Set("Access-Control-Allow-Origin", "*")
|
||||
} else {
|
||||
header.Set("Access-Control-Allow-Origin", origin)
|
||||
}
|
||||
if config.AllowCredentials {
|
||||
header.Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
if exposeHeaders != "" {
|
||||
header.Set("Access-Control-Expose-Headers", exposeHeaders)
|
||||
}
|
||||
|
||||
if isCORSPreflight(request) {
|
||||
header.Set("Access-Control-Allow-Methods", allowedMethods)
|
||||
header.Set("Access-Control-Allow-Headers", allowedHeaders)
|
||||
header.Set("Access-Control-Max-Age", maxAge)
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(writer, request)
|
||||
})
|
||||
}
|
||||
|
||||
func isCORSPreflight(request *http.Request) bool {
|
||||
return request.Method == http.MethodOptions && strings.TrimSpace(request.Header.Get("Access-Control-Request-Method")) != ""
|
||||
}
|
||||
|
||||
func allowedOriginSet(origins []string) map[string]bool {
|
||||
set := make(map[string]bool, len(origins))
|
||||
for _, origin := range origins {
|
||||
origin = strings.TrimRight(strings.TrimSpace(origin), "/")
|
||||
if origin != "" {
|
||||
set[origin] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func normalizeCORSConfig(config CORSConfig) CORSConfig {
|
||||
config.AllowedOrigins = trimStrings(config.AllowedOrigins)
|
||||
config.AllowedMethods = upperStrings(config.AllowedMethods)
|
||||
config.AllowedHeaders = trimStrings(config.AllowedHeaders)
|
||||
config.ExposeHeaders = trimStrings(config.ExposeHeaders)
|
||||
if len(config.AllowedMethods) == 0 {
|
||||
config.AllowedMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
|
||||
}
|
||||
if len(config.AllowedHeaders) == 0 {
|
||||
config.AllowedHeaders = []string{"Authorization", "Content-Type", "X-Request-ID", "X-App-Code", "X-HY-App-Code", "X-App-Package", "X-Package-Name", "X-App-Bundle-ID", "X-Bundle-ID", "X-App-Platform", "X-Platform"}
|
||||
}
|
||||
if len(config.ExposeHeaders) == 0 {
|
||||
config.ExposeHeaders = []string{"X-Request-ID"}
|
||||
}
|
||||
if config.MaxAgeSec <= 0 {
|
||||
config.MaxAgeSec = 600
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func trimStrings(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func upperStrings(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
if value != "" {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
101
services/gateway-service/internal/transport/http/cors_test.go
Normal file
101
services/gateway-service/internal/transport/http/cors_test.go
Normal file
@ -0,0 +1,101 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCORSPreflightShortCircuitsBusinessHandler(t *testing.T) {
|
||||
called := false
|
||||
handler := WithCORS(CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"https://global-interaction.com"},
|
||||
AllowedMethods: []string{"POST", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Content-Type", "X-App-Code"},
|
||||
ExposeHeaders: []string{"X-Request-ID"},
|
||||
AllowCredentials: true,
|
||||
MaxAgeSec: 300,
|
||||
}, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
called = true
|
||||
}))
|
||||
|
||||
request := httptest.NewRequest(http.MethodOptions, "/api/v1/auth/third-party/login", nil)
|
||||
request.Header.Set("Origin", "https://global-interaction.com")
|
||||
request.Header.Set("Access-Control-Request-Method", "POST")
|
||||
request.Header.Set("Access-Control-Request-Headers", "content-type,x-app-code")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if called {
|
||||
t.Fatal("preflight must not reach business handler")
|
||||
}
|
||||
if recorder.Code != http.StatusNoContent {
|
||||
t.Fatalf("unexpected preflight status: %d", recorder.Code)
|
||||
}
|
||||
if got := recorder.Header().Get("Access-Control-Allow-Origin"); got != "https://global-interaction.com" {
|
||||
t.Fatalf("unexpected allow origin: %q", got)
|
||||
}
|
||||
if got := recorder.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
||||
t.Fatalf("unexpected allow credentials: %q", got)
|
||||
}
|
||||
if got := recorder.Header().Get("Access-Control-Allow-Headers"); got != "Content-Type, X-App-Code" {
|
||||
t.Fatalf("unexpected allow headers: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSRejectsDisallowedPreflightOrigin(t *testing.T) {
|
||||
called := false
|
||||
handler := WithCORS(CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"https://global-interaction.com"},
|
||||
}, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
called = true
|
||||
}))
|
||||
|
||||
request := httptest.NewRequest(http.MethodOptions, "/api/v1/auth/third-party/login", nil)
|
||||
request.Header.Set("Origin", "https://evil.example")
|
||||
request.Header.Set("Access-Control-Request-Method", "POST")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if called {
|
||||
t.Fatal("denied preflight must not reach business handler")
|
||||
}
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||
}
|
||||
if got := recorder.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Fatalf("disallowed origin must not be echoed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSAddsHeadersForAllowedActualRequest(t *testing.T) {
|
||||
handler := WithCORS(CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"https://global-interaction.com"},
|
||||
ExposeHeaders: []string{"X-Request-ID"},
|
||||
AllowCredentials: true,
|
||||
}, http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("X-Request-ID", "req-test")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", nil)
|
||||
request.Header.Set("Origin", "https://global-interaction.com")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||
}
|
||||
if got := recorder.Header().Get("Access-Control-Allow-Origin"); got != "https://global-interaction.com" {
|
||||
t.Fatalf("unexpected allow origin: %q", got)
|
||||
}
|
||||
if got := recorder.Header().Get("Access-Control-Expose-Headers"); got != "X-Request-ID" {
|
||||
t.Fatalf("unexpected expose headers: %q", got)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user