diff --git a/docs/多机部署与无感发布.md b/deploy/README.md similarity index 83% rename from docs/多机部署与无感发布.md rename to deploy/README.md index 83cd62a5..588ffd52 100644 --- a/docs/多机部署与无感发布.md +++ b/deploy/README.md @@ -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//configs/config.tencent.example.yaml` 为准。开发时新增配置字段的动作必须同时更新三处: + +1. `services//configs/config.yaml`:本地直跑默认配置。 +2. `services//configs/config.docker.yaml`:本地 Docker/Compose 配置。 +3. `services//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`。 diff --git a/deploy/standalone/README.md b/deploy/standalone/README.md index 9410526a..ec1023dd 100644 --- a/deploy/standalone/README.md +++ b/deploy/standalone/README.md @@ -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 写进每个服务配置。 diff --git a/deploy/standalone/docker/admin-platform.env.example b/deploy/standalone/docker/admin-platform.env.example new file mode 100644 index 00000000..e95acbb2 --- /dev/null +++ b/deploy/standalone/docker/admin-platform.env.example @@ -0,0 +1,3 @@ +IMAGE=10.2.1.3:18082/hyapp/admin-platform:REPLACE_TAG +CONTAINER_NAME=hyapp-admin-platform +STOP_TIMEOUT_SEC=15 diff --git a/deploy/standalone/docker/admin-server.env.example b/deploy/standalone/docker/admin-server.env.example new file mode 100644 index 00000000..cea565c3 --- /dev/null +++ b/deploy/standalone/docker/admin-server.env.example @@ -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 diff --git a/deploy/standalone/docker/notice-service.env.example b/deploy/standalone/docker/notice-service.env.example new file mode 100644 index 00000000..ee8b4347 --- /dev/null +++ b/deploy/standalone/docker/notice-service.env.example @@ -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 diff --git a/deploy/standalone/systemd/hyapp-admin-platform.service b/deploy/standalone/systemd/hyapp-admin-platform.service new file mode 100644 index 00000000..cd3e9ca8 --- /dev/null +++ b/deploy/standalone/systemd/hyapp-admin-platform.service @@ -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 diff --git a/deploy/standalone/systemd/hyapp-admin-server.service b/deploy/standalone/systemd/hyapp-admin-server.service new file mode 100644 index 00000000..80d7e78d --- /dev/null +++ b/deploy/standalone/systemd/hyapp-admin-server.service @@ -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 diff --git a/deploy/standalone/systemd/hyapp-notice-service.service b/deploy/standalone/systemd/hyapp-notice-service.service new file mode 100644 index 00000000..bab6fcd6 --- /dev/null +++ b/deploy/standalone/systemd/hyapp-notice-service.service @@ -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 diff --git a/deploy/tencent-tat/README.md b/deploy/tencent-tat/README.md index b5eb9331..36ee98f5 100644 --- a/deploy/tencent-tat/README.md +++ b/deploy/tencent-tat/README.md @@ -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//config.yaml`。腾讯云 MySQL、Redis、MQ、JWT、IM/RTC/COS 等密钥不要提交到 Git。 +配置字段契约以 `services//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 ``` diff --git a/deploy/tencent-tat/hyapp_tat_deploy.py b/deploy/tencent-tat/hyapp_tat_deploy.py index 52305b5f..d1373f02 100644 --- a/deploy/tencent-tat/hyapp_tat_deploy.py +++ b/deploy/tencent-tat/hyapp_tat_deploy.py @@ -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 """ diff --git a/deploy/tencent-tat/inventory.prod.example.json b/deploy/tencent-tat/inventory.prod.example.json index 818719e1..53aec041 100644 --- a/deploy/tencent-tat/inventory.prod.example.json +++ b/deploy/tencent-tat/inventory.prod.example.json @@ -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", diff --git a/deploy/tencent-tat/tests/test_hyapp_tat_deploy.py b/deploy/tencent-tat/tests/test_hyapp_tat_deploy.py index df6b486a..4b7d141f 100644 --- a/deploy/tencent-tat/tests/test_hyapp_tat_deploy.py +++ b/deploy/tencent-tat/tests/test_hyapp_tat_deploy.py @@ -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: diff --git a/deploy/tencent-tat/validate_rendered_configs.py b/deploy/tencent-tat/validate_rendered_configs.py new file mode 100644 index 00000000..ed7272bd --- /dev/null +++ b/deploy/tencent-tat/validate_rendered_configs.py @@ -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 //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:])) diff --git a/server/admin/Dockerfile b/server/admin/Dockerfile new file mode 100644 index 00000000..7c7079a4 --- /dev/null +++ b/server/admin/Dockerfile @@ -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"] diff --git a/server/admin/cmd/server/main.go b/server/admin/cmd/server/main.go index 8765bcf6..137626e8 100644 --- a/server/admin/cmd/server/main.go +++ b/server/admin/cmd/server/main.go @@ -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, diff --git a/server/admin/configs/config.tencent.example.yaml b/server/admin/configs/config.tencent.example.yaml new file mode 100644 index 00000000..789b67d8 --- /dev/null +++ b/server/admin/configs/config.tencent.example.yaml @@ -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" diff --git a/server/admin/migrations/005_remove_service_management.sql b/server/admin/migrations/005_remove_service_management.sql index 2c7ea5f8..5d3f1fd4 100644 --- a/server/admin/migrations/005_remove_service_management.sql +++ b/server/admin/migrations/005_remove_service_management.sql @@ -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 权限;服务管理菜单和权限已移除,废弃表即使保留也不再被代码访问。 diff --git a/server/admin/migrations/008_remove_notification_center.sql b/server/admin/migrations/008_remove_notification_center.sql index 42db1ace..085a0755 100644 --- a/server/admin/migrations/008_remove_notification_center.sql +++ b/server/admin/migrations/008_remove_notification_center.sql @@ -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 权限;通知中心菜单和权限已移除,废弃表即使保留也不再被代码访问。 diff --git a/server/admin/migrations/009_admin_navigation_seed.sql b/server/admin/migrations/009_admin_navigation_seed.sql new file mode 100644 index 00000000..bc72566f --- /dev/null +++ b/server/admin/migrations/009_admin_navigation_seed.sql @@ -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'; diff --git a/services/gateway-service/configs/config.docker.yaml b/services/gateway-service/configs/config.docker.yaml index 9349481b..e8851af5 100644 --- a/services/gateway-service/configs/config.docker.yaml +++ b/services/gateway-service/configs/config.docker.yaml @@ -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" diff --git a/services/gateway-service/configs/config.tencent.example.yaml b/services/gateway-service/configs/config.tencent.example.yaml index 8d7b49b7..bc61b45e 100644 --- a/services/gateway-service/configs/config.tencent.example.yaml +++ b/services/gateway-service/configs/config.tencent.example.yaml @@ -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" diff --git a/services/gateway-service/configs/config.yaml b/services/gateway-service/configs/config.yaml index ce582b89..7b2003cd 100644 --- a/services/gateway-service/configs/config.yaml +++ b/services/gateway-service/configs/config.yaml @@ -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" diff --git a/services/gateway-service/internal/app/app.go b/services/gateway-service/internal/app/app.go index b0639e24..91c779e1 100644 --- a/services/gateway-service/internal/app/app.go +++ b/services/gateway-service/internal/app/app.go @@ -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{ diff --git a/services/gateway-service/internal/config/config.go b/services/gateway-service/internal/config/config.go index cf1d0cc8..33065c7a 100644 --- a/services/gateway-service/internal/config/config.go +++ b/services/gateway-service/internal/config/config.go @@ -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 +} diff --git a/services/gateway-service/internal/config/config_test.go b/services/gateway-service/internal/config/config_test.go index 417b3002..bbc76388 100644 --- a/services/gateway-service/internal/config/config_test.go +++ b/services/gateway-service/internal/config/config_test.go @@ -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") diff --git a/services/gateway-service/internal/transport/http/cors.go b/services/gateway-service/internal/transport/http/cors.go new file mode 100644 index 00000000..e8a6664a --- /dev/null +++ b/services/gateway-service/internal/transport/http/cors.go @@ -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 +} diff --git a/services/gateway-service/internal/transport/http/cors_test.go b/services/gateway-service/internal/transport/http/cors_test.go new file mode 100644 index 00000000..527565de --- /dev/null +++ b/services/gateway-service/internal/transport/http/cors_test.go @@ -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) + } +}