feat(gateway): 接入52号发际线接口与公开测试页

新增52号同路径转发,沿用现有主备派发、鉴权、队列和图片URL改写。空图片保留既有base64字段名,不带入worker算法及IP限制。

同步52号测试页、图片解析和矩形叠加依赖,公开页面不包含内部Token。补全网关契约与部署文档,Nginx示例读取超时延长至660秒。

验证:42项网关测试通过;临时网关连接真实GPU worker,5号三档与52号七型URL、长图裁切和边框均通过浏览器检查。
This commit is contained in:
xsl
2026-11-04 11:15:58 +08:00
parent 44a6082938
commit 544c2a83d6
12 changed files with 918 additions and 2 deletions
+155
View File
@@ -0,0 +1,155 @@
"""Gateway-only interface 52 integration: raw forwarding, token and image URLs."""
import asyncio
import base64
import importlib
import io
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import httpx
from PIL import Image
import pytest
from fastapi.testclient import TestClient
from gateway import config, forward
gateway = importlib.import_module("gateway.app")
PATH = "/api/v1/hairline/generate-52"
def image_bytes(fmt):
buf = io.BytesIO()
Image.new("RGB", (6, 8), (127, 127, 127)).save(buf, format=fmt)
return buf.getvalue()
@pytest.fixture
def proxy_setup(tmp_path, monkeypatch):
output = tmp_path / "annotations"
output.mkdir()
cfg = {"shared_password": "private-gateway-token", "public_base_url": "http://testserver",
"static_dir": str(output), "dispatch": {"request_timeout_seconds": 600, "max_retries": 0}}
monkeypatch.setattr(config, "get_config", lambda: cfg)
acquire = AsyncMock(return_value=True)
release = Mock()
monkeypatch.setattr(forward, "acquire_global_slot", acquire)
monkeypatch.setattr(forward, "release_global_slot", release)
monkeypatch.setattr(forward, "acquire_worker", AsyncMock(return_value=SimpleNamespace(url="http://worker")))
release_worker = AsyncMock()
monkeypatch.setattr(forward, "release_worker", release_worker)
unhealthy = AsyncMock()
monkeypatch.setattr(forward, "mark_worker_unhealthy", unhealthy)
static = next(route.app for route in gateway.app.routes if getattr(route, "name", None) == "static")
monkeypatch.setattr(static, "all_directories", [str(tmp_path), *static.all_directories])
return cfg, acquire, release, release_worker, unhealthy
@pytest.mark.parametrize("path", ["/api/v1/hairline/generate", PATH])
def test_hairline_routes_forward_to_their_own_worker_path(monkeypatch, path):
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}
finally:
client.close()
proxy.assert_awaited_once()
assert proxy.await_args.args[1] == path
@pytest.mark.parametrize("generate_grow_image", [False, True])
def test_interface52_raw_forwarding_and_served_image_urls(proxy_setup, monkeypatch, generate_grow_image):
cfg, acquire, release, release_worker, unhealthy = proxy_setup
png, jpg = image_bytes("PNG"), image_bytes("JPEG")
metadata = {"coordinate_space": "processed_image", "width": 6, "height": 8,
"framing": {"status": "reference_crop", "axis_x_error_px": 0},
"height_rule": {"target_center": {"x": 3.0, "y": 2.0}},
"guide_landmarks": {"9": {"x": 3.0, "y": 3.0}},
"best_hairline_center_point": {"x": 3, "y": 2},
"high_hairline_center_point": None, "low_hairline_center_point": None,
"face_measure": None}
worker_json = {"code": 0, "data": {**metadata,
"input_image_base64": base64.b64encode(jpg).decode(),
"original_image_base64": base64.b64encode(jpg).decode(),
"hairline_images": [{"hairline_type": "ellipse", "order": 1,
"height_placement": {"target_error_px": 0.01},
"image_middle_base64": base64.b64encode(png).decode(),
"image_high_base64": None, "image_low_base64": None,
"grown_image_base64": base64.b64encode(jpg).decode() if generate_grow_image else None}]}}
calls = []
def worker(request):
calls.append(request)
return httpx.Response(200, json=worker_json)
upstream = httpx.AsyncClient(transport=httpx.MockTransport(worker))
monkeypatch.setattr(forward, "get_client", lambda: upstream)
fields = {"gender": "male", "hair_style": "1,3", "normalize_image": "true",
"generate_grow_image": str(generate_grow_image).lower(), "use_mask": "false",
"prompt": "test prompt"}
source = httpx.Request("POST", "http://testserver" + PATH,
files={"image_file": ("face.png", png, "image/png")}, data=fields)
raw = source.read()
client = TestClient(gateway.app, client=("203.0.113.9", 50000))
try:
result = client.post(PATH, content=raw, headers={
"Content-Type": source.headers["content-type"], "X-Internal-Token": "untrusted-client-token"})
assert result.status_code == 200
data = result.json()["data"]
assert {key: data[key] for key in metadata} == metadata
item = data["hairline_images"][0]
assert item["image_high_base64"] is None and item["image_low_base64"] is None
assert "image_high_url" not in item and "image_low_url" not in item
assert item["height_placement"] == {"target_error_px": 0.01}
for key, expected in [("input_image_url", jpg), ("original_image_url", jpg)]:
assert client.get(data[key]).content == expected
assert data[key].endswith(".jpg")
assert client.get(item["image_middle_url"]).content == png
assert item["image_middle_url"].endswith(".png")
if generate_grow_image:
assert client.get(item["grown_image_url"]).content == jpg
else:
assert item["grown_image_base64"] is None and "grown_image_url" not in item
finally:
client.close()
asyncio.run(upstream.aclose())
assert len(calls) == 1
assert str(calls[0].url) == "http://worker" + PATH
assert calls[0].content == raw
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
acquire.assert_awaited_once_with(cfg, 0)
release.assert_called_once()
release_worker.assert_awaited_once()
unhealthy.assert_not_awaited()
def test_worker_ip_rejection_is_preserved(proxy_setup, monkeypatch):
_, _, release, release_worker, unhealthy = proxy_setup
body = {"code": 1010, "message": "来源IP不在接口白名单", "data": None}
upstream = httpx.AsyncClient(transport=httpx.MockTransport(lambda request: httpx.Response(403, json=body)))
monkeypatch.setattr(forward, "get_client", lambda: upstream)
client = TestClient(gateway.app)
try:
response = client.post(PATH, data={"gender": "male", "hair_style": "1"})
assert response.status_code == 403 and response.json() == body
finally:
client.close()
asyncio.run(upstream.aclose())
release.assert_called_once()
release_worker.assert_awaited_once()
unhealthy.assert_not_awaited()
def test_public_interface52_page_has_no_internal_password():
client = TestClient(gateway.app)
try:
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 "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
finally:
client.close()