From c30a136102bc990127b1609ea4311657b625072f Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Tue, 15 Sep 2026 23:26:02 +0800 Subject: [PATCH] Add versioned HTTP gateway routing by business domain --- .env.example | 1 + README.md | 76 ++++++++++++++++++++++++--- deploy/nginx.conf.example | 39 ++++++++++++++ src/adcloud_server/api/__init__.py | 0 src/adcloud_server/api/v1/__init__.py | 0 src/adcloud_server/api/v1/ads.py | 11 ++++ src/adcloud_server/api/v1/events.py | 11 ++++ src/adcloud_server/api/v1/identity.py | 11 ++++ src/adcloud_server/api/v1/router.py | 9 ++++ src/adcloud_server/api/v1/users.py | 11 ++++ src/adcloud_server/main.py | 50 +++++++++++------- src/adcloud_server/schemas.py | 11 ++++ tests/test_gateway.py | 61 +++++++++++++++++++++ 13 files changed, 263 insertions(+), 28 deletions(-) create mode 100644 .env.example create mode 100644 deploy/nginx.conf.example create mode 100644 src/adcloud_server/api/__init__.py create mode 100644 src/adcloud_server/api/v1/__init__.py create mode 100644 src/adcloud_server/api/v1/ads.py create mode 100644 src/adcloud_server/api/v1/events.py create mode 100644 src/adcloud_server/api/v1/identity.py create mode 100644 src/adcloud_server/api/v1/router.py create mode 100644 src/adcloud_server/api/v1/users.py create mode 100644 src/adcloud_server/schemas.py create mode 100644 tests/test_gateway.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4365bf5 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:5174 diff --git a/README.md b/README.md index 66c07bc..4c6b0a7 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,86 @@ 面向开发者和 coding agent 的广告云埋点与广告推送后端。使用 FastAPI 开发,依赖和虚拟环境统一由 **uv** 管理。 -当前为开发脚手架,提供健康检查与 API 文档;埋点与广告推送功能待实现。 +## HTTP Gateway -## 开发 +```text +HTTPS :443 (Nginx,终止 TLS) + ├── /v1/events/* → events 路由模块 + ├── /v1/users/* → users 路由模块 + ├── /v1/ads/* → ads 路由模块 + ├── /v1/identity/* → identity 路由模块 + └── /v1/... → 后续业务模块 + ↓ + FastAPI 127.0.0.1:8000 +``` + +目前使用模块化单体:四个业务模块共用一个 FastAPI 进程和版本入口。 +Nginx 为各业务域定义了独立 upstream,后续拆分服务时可以替换地址。 +Gateway 保留完整路径和查询参数,不剥离 `/v1` 或业务前缀。 +未知路径返回 404,不支持的 HTTP 方法返回 405。 + +| 路径 | 模块职责 | 当前已实现接口 | +| --- | --- | --- | +| `/v1/events/*` | 埋点事件接收与查询 | `GET /v1/events/health` | +| `/v1/users/*` | 平台用户资料管理 | `GET /v1/users/health` | +| `/v1/ads/*` | 广告管理与投放 | `GET /v1/ads/health` | +| `/v1/identity/*` | 登录、令牌与身份权限 | `GET /v1/identity/health` | + +各模块健康接口用于验证路由挂载,返回 `{"status":"ok","module":"events"}` 等响应;不代表数据库或外部依赖已就绪。 +当前阶段仅实现 Gateway 和模块入口,尚未实现业务写入、数据持久化、登录或鉴权。 + +```text +src/adcloud_server/ + main.py # 应用工厂、CORS、系统健康接口 + schemas.py # 公共响应模型 + api/v1/router.py # /v1 统一入口和模块注册 + api/v1/events.py + api/v1/users.py + api/v1/ads.py + api/v1/identity.py +``` + +增加新模块时,在 `api/v1/` 新增 `APIRouter`,并在 `router.py` 中注册前缀和 OpenAPI 标签。 +若部署为独立服务,再增加对应的 Gateway upstream 和 location。 + +## 本地开发 ```sh -uv sync +uv sync --locked uv run uvicorn adcloud_server.main:app --reload --host 127.0.0.1 --port 8000 ``` -- 健康检查:`http://localhost:8000/health` +- 系统健康检查:`http://localhost:8000/health`(保留 SDK 和控制台兼容) +- 模块探测:`http://localhost:8000/v1/events/health` - API 文档:`http://localhost:8000/docs` - OpenAPI:`http://localhost:8000/openapi.json` -默认允许来自 `http://localhost:3000`、`http://localhost:5173` 的浏览器请求。 -通过环境变量配置逗号分隔的允许来源: +默认允许来自 `http://localhost:3000`、`http://localhost:5173`、`http://localhost:5174` 的浏览器请求。 +`CORS_ORIGINS` 接受逗号分隔的来源;环境配置示例见 `.env.example`。 ```sh -CORS_ORIGINS=https://your-frontend.example uv run uvicorn adcloud_server.main:app +CORS_ORIGINS=https://advertiser.example,https://developer.example uv run uvicorn adcloud_server.main:app ``` +CORS 已配置写请求方法和 `Authorization`、`Content-Type`、`X-Request-ID` 请求头。 +允许预检不代表相应业务端点已实现;CORS 也不代替身份认证和权限校验。 + +## HTTPS 入口配置 + +`deploy/nginx.conf.example` 是放入 Nginx `http {}` 上下文的配置模板。 +替换 `server_name` 和证书文件路径,然后在目标服务器使用 `nginx -t` 检查,加载配置。 +模板未在本机启动 443,也未申请或部署证书。API 文档仅通过内部 FastAPI 地址访问。 + +Nginx 在公网边缘重写转发头,FastAPI 只监听回环地址。目标服务器启动后端: + +```sh +uv run uvicorn adcloud_server.main:app --host 127.0.0.1 --port 8000 --proxy-headers --forwarded-allow-ips 127.0.0.1 +``` + +模板允许最大 1 MiB 请求体,连接超时 5 秒、读写超时 30 秒;可按后续埋点批量策略调整。 + +参考:[FastAPI 多模块路由](https://fastapi.tiangolo.com/tutorial/bigger-applications/)、[Nginx proxy_pass 路径规则](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_pass)。 + ## 检查 ```sh @@ -30,4 +90,4 @@ uv run ruff check . uv run ruff format --check . ``` -提交 `uv.lock`,在其他电脑使用 `uv sync --locked` 复现依赖。 +提交 `uv.lock`,使用 `uv sync --locked` 复现依赖。 diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example new file mode 100644 index 0000000..bdbf1e3 --- /dev/null +++ b/deploy/nginx.conf.example @@ -0,0 +1,39 @@ +# Include this file inside the nginx http {} block. +# Replace server_name and certificate paths before deployment. +# All domains currently run in one modular FastAPI process. +upstream adcloud_events { server 127.0.0.1:8000; } +upstream adcloud_users { server 127.0.0.1:8000; } +upstream adcloud_ads { server 127.0.0.1:8000; } +upstream adcloud_identity { server 127.0.0.1:8000; } +upstream adcloud_api { server 127.0.0.1:8000; } + +server { + listen 443 ssl; + server_name api.adcloud.example; + + ssl_certificate /etc/nginx/tls/adcloud/fullchain.pem; + ssl_certificate_key /etc/nginx/tls/adcloud/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + client_max_body_size 1m; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + # This gateway is the public edge: overwrite untrusted forwarded headers. + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_connect_timeout 5s; + proxy_read_timeout 30s; + proxy_send_timeout 30s; + + # No URI suffix on proxy_pass: preserve /v1/... and query strings. + location ^~ /v1/events/ { proxy_pass http://adcloud_events; } + location ^~ /v1/users/ { proxy_pass http://adcloud_users; } + location ^~ /v1/ads/ { proxy_pass http://adcloud_ads; } + location ^~ /v1/identity/ { proxy_pass http://adcloud_identity; } + # Future domains are handled by the versioned FastAPI router. + location /v1/ { proxy_pass http://adcloud_api; } + location = /health { proxy_pass http://adcloud_api; } + location / { return 404; } +} diff --git a/src/adcloud_server/api/__init__.py b/src/adcloud_server/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/adcloud_server/api/v1/__init__.py b/src/adcloud_server/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/adcloud_server/api/v1/ads.py b/src/adcloud_server/api/v1/ads.py new file mode 100644 index 0000000..ecafcda --- /dev/null +++ b/src/adcloud_server/api/v1/ads.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +from adcloud_server.schemas import ModuleHealthResponse + +router = APIRouter() + + +@router.get("/health", response_model=ModuleHealthResponse) +def health() -> ModuleHealthResponse: + """Verify routing to the ads module; not a dependency readiness check.""" + return ModuleHealthResponse(module="ads") diff --git a/src/adcloud_server/api/v1/events.py b/src/adcloud_server/api/v1/events.py new file mode 100644 index 0000000..4e0595b --- /dev/null +++ b/src/adcloud_server/api/v1/events.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +from adcloud_server.schemas import ModuleHealthResponse + +router = APIRouter() + + +@router.get("/health", response_model=ModuleHealthResponse) +def health() -> ModuleHealthResponse: + """Verify routing to the events module; not a dependency readiness check.""" + return ModuleHealthResponse(module="events") diff --git a/src/adcloud_server/api/v1/identity.py b/src/adcloud_server/api/v1/identity.py new file mode 100644 index 0000000..cc7def3 --- /dev/null +++ b/src/adcloud_server/api/v1/identity.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +from adcloud_server.schemas import ModuleHealthResponse + +router = APIRouter() + + +@router.get("/health", response_model=ModuleHealthResponse) +def health() -> ModuleHealthResponse: + """Verify routing to the identity module; not a dependency readiness check.""" + return ModuleHealthResponse(module="identity") diff --git a/src/adcloud_server/api/v1/router.py b/src/adcloud_server/api/v1/router.py new file mode 100644 index 0000000..1ed7a1c --- /dev/null +++ b/src/adcloud_server/api/v1/router.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter + +from adcloud_server.api.v1 import ads, events, identity, users + +router = APIRouter(prefix="/v1") +router.include_router(events.router, prefix="/events", tags=["events"]) +router.include_router(users.router, prefix="/users", tags=["users"]) +router.include_router(ads.router, prefix="/ads", tags=["ads"]) +router.include_router(identity.router, prefix="/identity", tags=["identity"]) diff --git a/src/adcloud_server/api/v1/users.py b/src/adcloud_server/api/v1/users.py new file mode 100644 index 0000000..60df253 --- /dev/null +++ b/src/adcloud_server/api/v1/users.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +from adcloud_server.schemas import ModuleHealthResponse + +router = APIRouter() + + +@router.get("/health", response_model=ModuleHealthResponse) +def health() -> ModuleHealthResponse: + """Verify routing to the users module; not a dependency readiness check.""" + return ModuleHealthResponse(module="users") diff --git a/src/adcloud_server/main.py b/src/adcloud_server/main.py index f626b85..50e52a2 100644 --- a/src/adcloud_server/main.py +++ b/src/adcloud_server/main.py @@ -1,29 +1,39 @@ import os -from typing import Literal from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel -app = FastAPI(title="AdCloud Server", version="0.1.0") -app.add_middleware( - CORSMiddleware, - allow_origins=[ - origin.strip() - for origin in os.getenv( - "CORS_ORIGINS", "http://localhost:3000,http://localhost:5173" - ).split(",") - if origin.strip() - ], - allow_methods=["GET"], - allow_headers=["*"], -) +from adcloud_server.api.v1.router import router as v1_router +from adcloud_server.schemas import HealthResponse -class HealthResponse(BaseModel): - status: Literal["ok"] = "ok" +def create_app() -> FastAPI: + application = FastAPI( + title="AdCloud Server", + version="0.1.0", + description="Versioned HTTP API for AdCloud tracking and ad delivery.", + redirect_slashes=False, + ) + application.add_middleware( + CORSMiddleware, + allow_origins=[ + origin.strip() + for origin in os.getenv( + "CORS_ORIGINS", + "http://localhost:3000,http://localhost:5173,http://localhost:5174", + ).split(",") + if origin.strip() + ], + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type", "X-Request-ID"], + ) + application.include_router(v1_router) + + @application.get("/health", response_model=HealthResponse, tags=["system"]) + def health() -> HealthResponse: + return HealthResponse() + + return application -@app.get("/health", response_model=HealthResponse) -def health() -> HealthResponse: - return HealthResponse() +app = create_app() diff --git a/src/adcloud_server/schemas.py b/src/adcloud_server/schemas.py new file mode 100644 index 0000000..0fce8ef --- /dev/null +++ b/src/adcloud_server/schemas.py @@ -0,0 +1,11 @@ +from typing import Literal + +from pydantic import BaseModel + + +class HealthResponse(BaseModel): + status: Literal["ok"] = "ok" + + +class ModuleHealthResponse(HealthResponse): + module: Literal["events", "users", "ads", "identity"] diff --git a/tests/test_gateway.py b/tests/test_gateway.py new file mode 100644 index 0000000..3e40a8f --- /dev/null +++ b/tests/test_gateway.py @@ -0,0 +1,61 @@ +import pytest +from fastapi.testclient import TestClient + +from adcloud_server.main import create_app + + +@pytest.fixture +def client(): + with TestClient(create_app()) as test_client: + yield test_client + + +@pytest.mark.parametrize("module", ["events", "users", "ads", "identity"]) +def test_versioned_module_routing(client, module): + response = client.get(f"/v1/{module}/health?probe=1") + assert response.status_code == 200 + assert response.json() == {"status": "ok", "module": module} + + +@pytest.mark.parametrize( + "path", + ["/events/health", "/v2/events/health", "/v1/unknown/health", "/v1/events/unknown"], +) +def test_unknown_routes_return_404(client, path): + assert client.get(path).status_code == 404 + + +def test_unsupported_method(client): + assert client.post("/v1/events/health").status_code == 405 + + +def test_write_request_preflight(client): + response = client.options( + "/v1/events/health", + headers={ + "Origin": "http://localhost:5174", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "authorization,content-type,x-request-id", + }, + ) + assert response.status_code == 200 + assert response.headers["access-control-allow-origin"] == "http://localhost:5174" + assert "POST" in response.headers["access-control-allow-methods"] + + +def test_openapi_groups(client): + schema = client.get("/openapi.json").json() + for module in ["events", "users", "ads", "identity"]: + operation = schema["paths"][f"/v1/{module}/health"]["get"] + assert operation["tags"] == [module] + + +def test_cors_configuration(monkeypatch): + monkeypatch.setenv( + "CORS_ORIGINS", " https://console.example, ,https://dev.example " + ) + with TestClient(create_app()) as client: + accepted = client.get("/health", headers={"Origin": "https://dev.example"}) + denied = client.get("/health", headers={"Origin": "http://localhost:5173"}) + assert accepted.headers["access-control-allow-origin"] == "https://dev.example" + assert "access-control-allow-origin" not in denied.headers