This commit is contained in:
zhx 2026-05-14 09:57:01 +08:00
parent 20bef24a12
commit f6a0fa5f29
27 changed files with 947 additions and 26 deletions

View File

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

View File

@ -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 写进每个服务配置。

View 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

View 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

View 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

View 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

View 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

View 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

View File

@ -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 或太慢,把构建放到 CIdeploy 服务器只负责调度发布。
@ -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
```

View File

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

View File

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

View File

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

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

View File

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

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

View File

@ -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 权限;服务管理菜单和权限已移除,废弃表即使保留也不再被代码访问。

View File

@ -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 权限;通知中心菜单和权限已移除,废弃表即使保留也不再被代码访问。

View 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';

View File

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

View File

@ -8,6 +8,29 @@ log:
include_response_body: false
max_payload_bytes: 2048
http_addr: ":13000"
cors:
# H5/Web Google 登录会先发 HTTPS preflightgateway 必须在业务 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"

View File

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

View File

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

View File

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

View File

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

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

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