feat(gateway): 为52号接口增加客户端静态Token校验
请求头X-API-Token默认yonghe1999,可通过interface52_token覆盖;缺少或不匹配返回401/code1009,不进入派发队列。客户端Token不转发到worker,其他接口保持原样。 同步Token输入框、配置示例与调用文档。57项网关测试及临时网关连接真实worker的浏览器验证通过。
This commit is contained in:
@@ -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限制与算法不合入本次网关改动。
|
||||
|
||||
@@ -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生发;临时网关验证后关闭。
|
||||
|
||||
+3
-1
@@ -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)。
|
||||
|
||||
### 图片扶正与标准构图
|
||||
|
||||
@@ -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)。
|
||||
|
||||
+18
-2
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"interface52_token": "yonghe1999",
|
||||
"workers": [
|
||||
"http://127.0.0.1:28187",
|
||||
"http://127.0.0.1:28188"
|
||||
|
||||
@@ -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 地址")
|
||||
|
||||
@@ -267,6 +267,7 @@ console.log(features.gene_style); // "自然型"</pre>
|
||||
<div class="card" id="if52">
|
||||
<h2>52. 发际线新规则与裁切 <span class="badge post">POST</span> <code>/api/v1/hairline/generate-52</code></h2>
|
||||
<div class="card-body">
|
||||
<p>客户端请求头必填 <code>X-API-Token: yonghe1999</code>。缺少或不匹配返回HTTP401、code=1009。可在网关配置<code>interface52_token</code>覆盖默认值;内部Token由网关添加。</p>
|
||||
<p class="desc">网关透传至GPU worker执行。中下庭均值定位 middle,支持9→152扶正、人像裁切和矩形发际线预览。接口5保留原有三档实现;新功能使用此独立接口。</p>
|
||||
<p>基础图片参数、<code>gender</code>、<code>hair_style</code>、<code>use_mask</code>、<code>prompt</code>、<code>generate_grow_image</code>沿用接口5。新增可选<code>normalize_image</code>,默认false,传true开启扶正与裁切;测试页显式开启。</p>
|
||||
<p style="margin-top:12px">非空图片返回<code>*_url</code>,空图片沿用<code>*_base64: null</code>。另有<code>height_rule</code>、<code>framing</code>、<code>guide_landmarks</code>、原图及处理后底图。middle和坐标使用当前底图,high/low字段为null。标准裁切高约3.3333L、宽2.5L;长图在保留人脸的前提下裁为3:4。</p>
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="upload-row">
|
||||
<div class="form-group"><label for="apiToken">客户端 Token</label><input id="apiToken" type="password" value="yonghe1999" autocomplete="off"></div>
|
||||
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div>
|
||||
<div class="form-group">
|
||||
<label>性别</label>
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user