From 14ec4964a0e2da836fee409238cc631731c19dcf Mon Sep 17 00:00:00 2001 From: xsl Date: Wed, 4 Nov 2026 11:35:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20=E4=B8=BA52=E5=8F=B7=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=A2=9E=E5=8A=A0=E5=AE=A2=E6=88=B7=E7=AB=AF=E9=9D=99?= =?UTF-8?q?=E6=80=81Token=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 请求头X-API-Token默认yonghe1999,可通过interface52_token覆盖;缺少或不匹配返回401/code1009,不进入派发队列。客户端Token不转发到worker,其他接口保持原样。 同步Token输入框、配置示例与调用文档。57项网关测试及临时网关连接真实worker的浏览器验证通过。 --- docs/实现说明.md | 1 + docs/接口52网关接入.md | 12 +++++ docs/接口文档.md | 4 +- docs/网关待改动.md | 2 + gateway/app.py | 20 +++++++- gateway/config.example.json | 1 + gateway/config.py | 5 ++ static/integration.html | 1 + static/test_interface52.html | 3 +- tests/test_gateway_interface52.py | 77 ++++++++++++++++++++++++++++++- 10 files changed, 120 insertions(+), 6 deletions(-) diff --git a/docs/实现说明.md b/docs/实现说明.md index 752313a..04e0813 100644 --- a/docs/实现说明.md +++ b/docs/实现说明.md @@ -99,6 +99,7 @@ - **怎么实现**:复用接口2 的 502 点渲染管线,三档分别用 `hairline_texture[/_high|/_low]` 同名贴图渲染叠图;生发同接口2(ComfyUI inpaint),**黑模板固定取 `hairline_texture_black/`(middle)**,每发型 1 张生发图。`best_hairline_center_point`=眉心 x × 首个选中发型 middle 档发际线 y。 ### 接口52 发际线新规则 `/api/v1/hairline/generate-52`(网关透传) +- **客户端鉴权**:请求头`X-API-Token`,默认`yonghe1999`,可由`gateway/config.json`的`interface52_token`覆盖;不匹配返回HTTP401/code1009且不进入队列。客户端Token不传给worker,内部Token仍由网关添加。 - `main`新增网关路由和`static/test_interface52.html`。GPU worker负责中下庭均值定位、扶正裁切及生发;主备worker均需部署52号实现,网关只透传表单并附加内部Token。 - 非空图片使用原有`*_base64`→`*_url`落盘改写;空图片保留`*_base64: null`。metadata和坐标原样返回,middle叠加到`input_image_url`。旧接口、5号队列优先级及网关IP策略保持原样。 - 参数、空字段与部署步骤见[接口52网关接入](接口52网关接入.md)。worker分支的IP限制与算法不合入本次网关改动。 diff --git a/docs/接口52网关接入.md b/docs/接口52网关接入.md index f83b78d..c46d3e4 100644 --- a/docs/接口52网关接入.md +++ b/docs/接口52网关接入.md @@ -7,12 +7,17 @@ 客户端 → `POST /api/v1/hairline/generate-52` → 网关现有队列和worker池 → worker同路径 → 网关落盘非空图片并返回公网URL。 - 请求体原样转发,网关附加配置中的`X-Internal-Token`;客户端和公开测试页不携带内部密码。 +- 客户端须通过`X-API-Token`传入52号静态Token,默认`yonghe1999`。网关校验后才进入队列;该Token不转发给worker,与内部密码独立。 - 沿用现有主备派发、隔离期、重试、日志和全局并发限制。原有5号插队优先级保持原样,52号走默认队列。 - 5号`/api/v1/hairline/generate`和其他接口保持原样;网关不新增IP限制。 - worker的中下庭规则、扶正、3:4裁切和生发结果由同一次worker响应提供,网关不重新计算坐标。 ## 请求 +请求头必填:`X-API-Token: yonghe1999`。缺少、为空或不匹配时返回HTTP401,响应为`{"code":1009,"message":"未授权:缺少或错误的 X-API-Token","request_id":"gw-…","data":null}`,不读取转发表单、不占用GPU队列。其他接口不增加这项校验。 + +可在`gateway/config.json`设置`"interface52_token": "自定义值"`;旧配置省略该项时自动使用`yonghe1999`。显式配置空值或非字符串会导致启动校验失败,不能用来关闭鉴权。配置修改后重启网关生效,测试页输入框改为相同值。 + 使用`multipart/form-data`;图片三选一:`image_file`、`image_url`、`image_base64`。必填`gender=male/female`和`hair_style=1..7`(支持逗号分隔多选)。 可选参数全部透传: @@ -51,6 +56,7 @@ worker始终返回`*_base64`。网关沿用原有公共改写,不添加52号 ```bash curl -X POST https://hair.xiangsilian.com/api/v1/hairline/generate-52 \ + -H 'X-API-Token: yonghe1999' \ -F 'image_file=@/path/to/photo.jpg' \ -F 'gender=male' -F 'hair_style=1' \ -F 'normalize_image=true' -F 'generate_grow_image=false' @@ -58,8 +64,14 @@ curl -X POST https://hair.xiangsilian.com/api/v1/hairline/generate-52 \ 成功时`code=0`,检查`input_image_url`和`hairline_images[].image_middle_url`可打开、尺寸一致;high/low仍为`*_base64: null`。访问测试页时无需输入内部Token。 +测试页新增“客户端Token”输入框,默认`yonghe1999`,只作为`X-API-Token`请求头发送;自定义后端Token时应同步填写该值。 + ## 验证 `tests/test_gateway_interface52.py`覆盖5/52路由分离、完整表单透传、由网关注入内部Token、真实公共转发函数的图片落盘与URL可访问、空字段保持原样和队列槽释放。现有pool、serialization、monitor、reqlog测试用于防止回归。 +静态Token测试覆盖默认值、自定义覆盖、缺失/错误/空Token、内部Token不能代替客户端Token、未通过时不派发、无效配置启动失败以及客户端Token不透传至worker。 + +加入客户端Token后的验证:57项网关测试通过;临时网关实测缺失/错误Token返回401,测试页携带默认Token成功调用真实worker并显示7型图片,旧5号页面正常。测试关闭生发,未改动正在运行的GPU worker。 + 2026-11-04验证结果:上述网关测试共42项通过。另起本机临时网关连接当前真实GPU worker,浏览器验证旧5号仍返回三档,52号女性7型均返回可访问的图片URL,长图输出1062×1416,矩形叠加与空字段正常,页面请求不携带内部Token,无脚本错误。本轮关闭生发以验证网关链路,未重新运行ComfyUI生发;临时网关验证后关闭。 diff --git a/docs/接口文档.md b/docs/接口文档.md index 4605ccb..a5ba891 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -87,7 +87,7 @@ | 1006 | 文件超出大小限制 | 单文件超过 1 MB | | 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) | | 1008 | 图片格式不支持 | 非 JPG / PNG | -| 1009 | 未授权 | 缺少或错误的 `X-Internal-Token`(`/api/*` 路径鉴权) | +| 1009 | 未授权 | worker内部`X-Internal-Token`错误;网关52号缺少或错误的客户端`X-API-Token`。HTTP401 | --- @@ -526,6 +526,8 @@ **请求**:`POST /api/v1/hairline/generate-52`(网关透传至同路径GPU worker) +**客户端鉴权**:请求头必填`X-API-Token: yonghe1999`,缺少、为空或不匹配返回HTTP401/code1009,不进入worker队列。默认值可通过网关`gateway/config.json`的`interface52_token`覆盖,修改后重启网关;内部`X-Internal-Token`仍由网关注入,客户端无需提供。该校验仅用于网关52号。 + **职责**:GPU worker返回base64并完成算法处理,网关负责透传参数、附加内部Token、保存非空图片及返回公网URL。网关不增加IP限制;网关出口IP须已在worker白名单内。部署与完整字段约定见[接口52网关接入](接口52网关接入.md)。 ### 图片扶正与标准构图 diff --git a/docs/网关待改动.md b/docs/网关待改动.md index 12f6341..822597f 100644 --- a/docs/网关待改动.md +++ b/docs/网关待改动.md @@ -7,6 +7,8 @@ ## 接口52(2026-11-04 已接入main) +客户端新增请求头`X-API-Token`校验,默认`yonghe1999`,可在网关配置`interface52_token`覆盖;缺少或不匹配返回HTTP401/code1009,不进入队列。其他接口不增加该校验。 + 新增`POST /api/v1/hairline/generate-52`,由网关转发到GPU worker同路径。5号保持原样,52号的`normalize_image`等参数原样透传;非空图片沿用公共base64→URL改写,空图片仍保留`*_base64: null`。不在网关增加IP限制,不合入worker算法。 测试页`/static/test_interface52.html`不包含内部密码。部署前确认主/备worker均支持52号,并检查生发请求的上游超时。详见[接口52网关接入](接口52网关接入.md)。 diff --git a/gateway/app.py b/gateway/app.py index 450a3e9..b317daa 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -6,13 +6,14 @@ import asyncio import base64 +import hmac import json import logging from contextlib import asynccontextmanager from pathlib import Path from typing import Optional -from fastapi import FastAPI, File, Form, Request, UploadFile +from fastapi import FastAPI, File, Form, Header, Request, UploadFile from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles @@ -674,12 +675,27 @@ async def hairline_generate(request: Request): @app.post("/api/v1/hairline/generate-52", tags=["人脸分析"], summary="接口52:发际线新规则与人像裁切") -async def hairline_generate_52(request: Request): +async def hairline_generate_52( + request: Request, + api_token: Optional[str] = Header(default=None, alias="X-API-Token", + description="52号接口客户端Token,默认yonghe1999"), +): """透传图片三选一、gender、hair_style及normalize_image等参数至GPU worker。 worker负责中下庭均值定位、扶正裁切和生发;网关沿用原有派发、鉴权与图片URL改写。 空图片保持原有 *_base64: null 字段。接口5保持原样。 + 客户端须传X-API-Token;缺少或不匹配时返回HTTP401/code1009,不进入派发队列。 """ + from uuid import uuid4 + from gateway.config import DEFAULTS, get_config + + expected = get_config().get("interface52_token", DEFAULTS["interface52_token"]) + if (not isinstance(expected, str) or not expected or not api_token + or not hmac.compare_digest(api_token.encode("utf-8"), expected.encode("utf-8"))): + return JSONResponse(status_code=401, content={ + "code": 1009, "message": "未授权:缺少或错误的 X-API-Token", + "request_id": f"gw-{uuid4().hex[:8]}", "data": None, + }) return await _proxy(request, "/api/v1/hairline/generate-52") diff --git a/gateway/config.example.json b/gateway/config.example.json index aa757af..1782edf 100644 --- a/gateway/config.example.json +++ b/gateway/config.example.json @@ -1,4 +1,5 @@ { + "interface52_token": "yonghe1999", "workers": [ "http://127.0.0.1:28187", "http://127.0.0.1:28188" diff --git a/gateway/config.py b/gateway/config.py index c533b08..7751d4a 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -13,6 +13,7 @@ logger = logging.getLogger("gateway.config") CONFIG_PATH = Path(__file__).resolve().parent / "config.json" DEFAULTS = { + "interface52_token": "yonghe1999", "public_base_url": "https://hair.xiangsilian.com", "static_dir": "static/annotations", "primary_workers": [], # 主 worker 地址列表(须为 workers 的子集);空 = 全部视为主 worker @@ -76,6 +77,10 @@ def load_config() -> dict: cfg = _deep_merge(DEFAULTS, raw) # --- 校验 --- + client_token = cfg["interface52_token"] + if not isinstance(client_token, str) or not client_token: + raise ValueError("配置错误: interface52_token 必须是非空字符串") + workers: List[str] = cfg.get("workers", []) if not workers: raise ValueError("配置错误: workers 列表不能为空,至少需要一个 worker 地址") diff --git a/static/integration.html b/static/integration.html index 49fd429..7b929b3 100644 --- a/static/integration.html +++ b/static/integration.html @@ -267,6 +267,7 @@ console.log(features.gene_style); // "自然型"

52. 发际线新规则与裁切  POST  /api/v1/hairline/generate-52

+

客户端请求头必填 X-API-Token: yonghe1999。缺少或不匹配返回HTTP401、code=1009。可在网关配置interface52_token覆盖默认值;内部Token由网关添加。

网关透传至GPU worker执行。中下庭均值定位 middle,支持9→152扶正、人像裁切和矩形发际线预览。接口5保留原有三档实现;新功能使用此独立接口。

基础图片参数、gender、hair_style、use_mask、prompt、generate_grow_image沿用接口5。新增可选normalize_image,默认false,传true开启扶正与裁切;测试页显式开启。

非空图片返回*_url,空图片沿用*_base64: null。另有height_rule、framing、guide_landmarks、原图及处理后底图。middle和坐标使用当前底图,high/low字段为null。标准裁切高约3.3333L、宽2.5L;长图在保留人脸的前提下裁为3:4。

diff --git a/static/test_interface52.html b/static/test_interface52.html index d1843ba..7ab64ac 100644 --- a/static/test_interface52.html +++ b/static/test_interface52.html @@ -106,6 +106,7 @@
+
@@ -230,7 +231,7 @@ async function submitTest() { const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value); fd.append('hair_style', checked.join(',')); fd.append('generate_grow_image', $('genGrowImg').checked ? 'true' : 'false'); fd.append('normalize_image', 'true'); const _reqStart = performance.now(); try { - const r = await fetch(API_BASE + '/api/v1/hairline/generate-52', { method:'POST', body:fd }); + const r = await fetch(API_BASE + '/api/v1/hairline/generate-52', { method:'POST', headers: { 'X-API-Token': $('apiToken').value }, body:fd }); const json = await r.json(); const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2); $('jsonContent').textContent = JSON.stringify(json, null, 2); diff --git a/tests/test_gateway_interface52.py b/tests/test_gateway_interface52.py index 64b314f..b1871f7 100644 --- a/tests/test_gateway_interface52.py +++ b/tests/test_gateway_interface52.py @@ -15,6 +15,75 @@ from gateway import config, forward gateway = importlib.import_module("gateway.app") PATH = "/api/v1/hairline/generate-52" +CLIENT_HEADERS = {"X-API-Token": "yonghe1999"} + + +@pytest.mark.parametrize("headers", [ + {}, {"X-API-Token": ""}, {"X-API-Token": "wrong"}, + {"X-API-Token": "yonghe1999 "}, {"X-Internal-Token": "yonghe1999"}, +]) +def test_invalid_client_token_is_rejected_before_dispatch(monkeypatch, headers): + monkeypatch.setattr(config, "get_config", lambda: {}) + proxy = AsyncMock() + monkeypatch.setattr(gateway, "_proxy", proxy) + client = TestClient(gateway.app) + try: + response = client.post(PATH, headers=headers) + assert response.status_code == 401 + body = response.json() + assert body["code"] == 1009 and body["data"] is None + assert body["request_id"].startswith("gw-") + finally: + client.close() + proxy.assert_not_awaited() + + +def test_custom_token_replaces_default_only_on_interface52(monkeypatch): + monkeypatch.setattr(config, "get_config", lambda: {"interface52_token": "custom-client-token"}) + proxy = AsyncMock(return_value={"code": 0, "data": None}) + monkeypatch.setattr(gateway, "_proxy", proxy) + client = TestClient(gateway.app) + try: + assert client.post(PATH, headers=CLIENT_HEADERS).status_code == 401 + assert client.post(PATH, headers={"X-API-Token": "custom-client-token"}).status_code == 200 + assert client.post("/api/v1/hairline/generate").status_code == 200 + finally: + client.close() + assert proxy.await_count == 2 + + +@pytest.mark.parametrize("token", ["", None, 123, []]) +def test_invalid_configured_token_cannot_disable_auth(monkeypatch, token): + monkeypatch.setattr(config, "get_config", lambda: {"interface52_token": token}) + proxy = AsyncMock() + monkeypatch.setattr(gateway, "_proxy", proxy) + client = TestClient(gateway.app) + try: + assert client.post(PATH, headers=CLIENT_HEADERS).status_code == 401 + finally: + client.close() + proxy.assert_not_awaited() + + +@pytest.mark.parametrize("token", ["", None, 123, []]) +def test_startup_rejects_invalid_client_token_configuration(tmp_path, monkeypatch, token): + import json + path = tmp_path / "config.json" + path.write_text(json.dumps({"interface52_token": token})) + monkeypatch.setattr(config, "CONFIG_PATH", path) + monkeypatch.setattr(config, "_config_cache", None) + with pytest.raises(ValueError, match="interface52_token"): + config.load_config() + + +def test_existing_config_gets_default_client_token(tmp_path, monkeypatch): + import json + path = tmp_path / "config.json" + path.write_text(json.dumps({"workers": ["http://worker"], "shared_password": "test-internal-token", + "static_dir": str(tmp_path)})) + monkeypatch.setattr(config, "CONFIG_PATH", path) + monkeypatch.setattr(config, "_config_cache", None) + assert config.load_config()["interface52_token"] == "yonghe1999" def image_bytes(fmt): @@ -46,11 +115,12 @@ def proxy_setup(tmp_path, monkeypatch): @pytest.mark.parametrize("path", ["/api/v1/hairline/generate", PATH]) def test_hairline_routes_forward_to_their_own_worker_path(monkeypatch, path): + monkeypatch.setattr(config, "get_config", lambda: {}) proxy = AsyncMock(return_value={"code": 0, "data": None}) monkeypatch.setattr(gateway, "_proxy", proxy) client = TestClient(gateway.app, client=("203.0.113.9", 50000)) try: - assert client.post(path).json() == {"code": 0, "data": None} + assert client.post(path, headers=CLIENT_HEADERS if path == PATH else {}).json() == {"code": 0, "data": None} finally: client.close() proxy.assert_awaited_once() @@ -93,6 +163,7 @@ def test_interface52_raw_forwarding_and_served_image_urls(proxy_setup, monkeypat client = TestClient(gateway.app, client=("203.0.113.9", 50000)) try: result = client.post(PATH, content=raw, headers={ + **CLIENT_HEADERS, "Content-Type": source.headers["content-type"], "X-Internal-Token": "untrusted-client-token"}) assert result.status_code == 200 data = result.json()["data"] @@ -119,6 +190,7 @@ def test_interface52_raw_forwarding_and_served_image_urls(proxy_setup, monkeypat assert calls[0].headers["content-type"] == source.headers["content-type"] assert calls[0].headers["x-internal-token"] == cfg["shared_password"] assert calls[0].headers.get("x-forwarded-for") is None + assert calls[0].headers.get("x-api-token") is None acquire.assert_awaited_once_with(cfg, 0) release.assert_called_once() release_worker.assert_awaited_once() @@ -132,7 +204,7 @@ def test_worker_ip_rejection_is_preserved(proxy_setup, monkeypatch): monkeypatch.setattr(forward, "get_client", lambda: upstream) client = TestClient(gateway.app) try: - response = client.post(PATH, data={"gender": "male", "hair_style": "1"}) + response = client.post(PATH, headers=CLIENT_HEADERS, data={"gender": "male", "hair_style": "1"}) assert response.status_code == 403 and response.json() == body finally: client.close() @@ -148,6 +220,7 @@ def test_public_interface52_page_has_no_internal_password(): page = client.get("/static/test_interface52.html") assert page.status_code == 200 assert PATH in page.text and "X-Internal-Token" not in page.text + assert 'id="apiToken"' in page.text and "X-API-Token" in page.text assert "localhost:8187" not in page.text assert "test_interface52_compare.html" not in page.text assert client.get("/static/hairline_reference_box.js").status_code == 200