diff --git a/docs/接口42-52-72网关鉴权.md b/docs/接口42-52-72网关鉴权.md index 149c0de..bde9a68 100644 --- a/docs/接口42-52-72网关鉴权.md +++ b/docs/接口42-52-72网关鉴权.md @@ -1,12 +1,13 @@ -# 42、52、72 号接口网关鉴权 +# 22、42、52、72 号接口网关鉴权 | 接口 | 方法与路径 | 执行位置 | |---|---|---| +| 22 | `POST /api/v1/hair/grow-22` | 网关转发至 Worker,发际线预览和生发图转成 URL | | 42 | `POST /api/v1/face/features-42` | 网关按接口 52 的规则扶正裁切,再用接口 4 的分析结果和 Ft/Zy 判定生成特征 | | 52 | `POST /api/v1/hairline/generate-52` | 网关转发至 worker | | 72 | `POST /api/v1/face/b-end-points` | 网关转发至 worker,底图与标注层转成 URL | -客户端调用这三个接口时,在原请求上添加 `Authorization: Bearer `。缺少、过期、签名无效或误用刷新令牌时,返回 HTTP 401 和业务码 `1009`。网关验证通过后,52 和 72 继续使用独立的 `X-Internal-Token` 转发至 worker,客户端令牌不会传给 worker。 +客户端调用这些接口时,在原请求上添加 `Authorization: Bearer `。缺少、过期、签名无效或误用刷新令牌时,返回 HTTP 401 和业务码 `1009`。网关验证通过后,22、52、72 通过现有 Worker 池转发,内部转发头由网关提供。 ## 登录 @@ -32,4 +33,6 @@ 网关保留原有 Ark、MediaPipe 与 BiSeNet 配置,42 的裁切还使用 Pillow。部署 72 前,确认所有可接收该请求的 worker 已部署含 72 号的版本(例如 `ubuntu3090_debug` 的 `c9a47a7` 或后续版本)。52、72 沿用现有 worker 池与图片 URL 改写流程。 -网关测试页为 `/static/test_interface42.html`、`/static/test_interface52.html` 和 `/static/test_interface72.html`。页面可登录、手动刷新令牌并提交图片,访问令牌到期时自动刷新。「测试鉴权」使用 `GET /api/v1/auth/check` 验证无令牌请求返回 HTTP 401、有效令牌请求返回 HTTP 200,测试时无需上传照片或调用 worker。 +部署接口 22 前,确认 Worker 池的主备节点均包含接口 22(例如 `ubuntu3090_debug` 上的 `b84a506` 或后续版本)。Worker 启用 IP 白名单时,配置网关直连地址。 + +网关测试页为 `/static/test_interface22.html`、`/static/test_interface42.html`、`/static/test_interface52.html` 和 `/static/test_interface72.html`。页面可登录、手动刷新令牌并提交图片,访问令牌到期时自动刷新。「测试鉴权」调用 `GET /api/v1/auth/check`,验证无令牌请求返回 HTTP 401、有效令牌请求返回 HTTP 200。 diff --git a/docs/接口文档.md b/docs/接口文档.md index ccd2dd5..79a6909 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -17,6 +17,7 @@ | 1 四庭七眼测量 | POST | `/api/v1/face/measure` | | 6 四庭七眼测量 v2 | POST | `/api/v1/face/measure-v2` | | 2 C 端生发 | POST | `/api/v1/hair/grow` | +| 22 生发密度与发际线高度 | POST | `/api/v1/hair/grow-22` | | 3 B 端生发 | POST | `/api/v1/hair/grow-b` | | 4 用户特征 | POST | `/api/v1/face/features` | | 42 用户特征与两项判定 | POST | `/api/v1/face/features-42` | @@ -360,6 +361,21 @@ --- +## 接口 22 生发密度与发际线高度 + +`POST /api/v1/hair/grow-22` 需要现有对接账号签发的 `Authorization: Bearer `。 [账号登录与刷新](接口42-52-72网关鉴权.md) + +表单图片字段三选一,`gender` 为 `female` 或 `male`,`hair_style` 用逗号分隔发型序号(两种性别均为 `1–7`)。 + +| 字段 | 取值 | +|---|---| +| `hair_density` | 必填,至少一档。稀疏 `sparse`、正常 `normal`、浓密 `dense`。多选时重复提交同名字段。 | +| `hairline_height` | 默认 `0`,范围 `[-1, 1]`。负值提高发际线,正值降低发际线。 | + +每个「发型 × 密度」组合返回一项,包含 `order`、`hairline_type`、`hair_density`、发际线透明 PNG `image_url` 和生发图 `grown_image_url`。单项生发失败时保留 `grown_image_base64: null`。 + +[网关测试页](/static/test_interface22.html)登录后可直接生图。首次生成使用默认高度 `0`,出图后通过同源接口 52 加载发际线预览,再调整形状、高度和密度。 + ## 接口 3:B 端生发接口 **说明**:医生/操作端在用户照片上用马克笔标注目标发际线后,**只需上传这一张划线图**。系统检测划线 → diff --git a/gateway/app.py b/gateway/app.py index 726d3c6..27d875f 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -250,6 +250,7 @@ async def index(): "test_pages": { "if1_measure": "/static/test_interface1.html", "if2_hair_grow": "/static/test_interface2.html", + "if22_hair_grow": "/static/test_interface22.html", "if3_hair_grow_b": "/static/test_interface3.html", "if4_features": "/static/test_interface4.html", "if42_features": "/static/test_interface42.html", @@ -581,6 +582,14 @@ async def hair_grow(request: Request): return await _proxy(request, "/api/v1/hair/grow") +@app.post("/api/v1/hair/grow-22", summary="接口22:生发密度与发际线高度", tags=["生发"]) +async def hair_grow_22(request: Request, auth_error=Depends(client_auth_error)): + """鉴权后透传图片、发型、可重复的 hair_density 和 hairline_height 至 GPU Worker。""" + if auth_error is not None: + return auth_error + return await _proxy(request, "/api/v1/hair/grow-22") + + @app.post("/api/v1/hair/grow-b", tags=["生发"], deprecated=True) async def hair_grow_b(request: Request): """接口3:B端生发(已屏蔽)""" diff --git a/gateway/client_auth.py b/gateway/client_auth.py index ae62824..756ca5a 100644 --- a/gateway/client_auth.py +++ b/gateway/client_auth.py @@ -149,7 +149,7 @@ async def client_auth_error(credentials: HTTPAuthorizationCredentials | None = S @router.get("/check", summary="验证当前访问令牌") async def check(auth_error=Depends(client_auth_error)): - """检查与 42、52、72 相同的 Bearer 令牌,无需提交图片。""" + """检查受保护业务接口使用的 Bearer 令牌。""" if auth_error is not None: return auth_error return _response(0, "访问令牌有效") diff --git a/static/integration.html b/static/integration.html index 3abc924..391a70a 100644 --- a/static/integration.html +++ b/static/integration.html @@ -55,9 +55,10 @@
通用约定 - 42/52/72 鉴权 + 22/42/52/72 鉴权 接口1 接口2 + 接口22 接口3 接口4 接口42 @@ -78,7 +79,7 @@ 请求方式业务接口使用 POST。验证令牌使用 GET。 编码UTF-8 Content-Type传图使用 multipart/form-data,登录和刷新使用 application/json - 客户端鉴权仅 42、52、72 需要 Bearer 访问令牌。其他原有业务接口沿用现有接入方式。 + 客户端鉴权22、42、52、72 使用 Bearer 访问令牌。 图片参数三选一:image_file(文件上传)/ image_url(URL)/ image_base64(base64 + 前缀) 图片格式JPG / PNG,单人正面照 请求体上限2MB(nginx 层限制)。超限直接返回 HTTP 413,不会进入业务逻辑。建议图片文件 ≤1MB;image_base64 传图会膨胀约 33%,编码前原图建议 ≤1.4MB。手机直拍照片通常 3~8MB,上传前务必先压缩(如最长边缩到 1500px、JPEG 质量 80)。image_url 方式图片在远端,不受此限制 @@ -95,9 +96,9 @@
-

42/52/72 客户端鉴权

+

22/42/52/72 客户端鉴权

-

使用同一个访问令牌调用 42、52 和 72。账号为 yonghe,密码由对接人员提供。客户端请求只需发送 Authorization: Bearer <access_token>。

+

使用同一个访问令牌调用 22、42、52 和 72。账号为 yonghe,密码由对接人员提供。客户端发送 Authorization: Bearer <access_token>。

1. 登录获取令牌

@@ -219,6 +220,19 @@ const { code, data } = await res.json();
+
+

22. 生发密度与发际线高度  POST  /api/v1/hair/grow-22

+
+

使用 Bearer 访问令牌。图片字段三选一,发型序号男女均为 1–7。

+
curl -sS "$BASE_URL/api/v1/hair/grow-22" \
+  -H "Authorization: Bearer $ACCESS_TOKEN" \
+  -F "image_file=@./portrait.jpg" -F "gender=female" -F "hair_style=1,3" \
+  -F "hair_density=sparse" -F "hair_density=dense" -F "hairline_height=0"
+

至少选择一档密度,多选时重复提交 hair_density。高度在 −1 到 +1 之间,API 的负值上移、正值下移。每个发型与密度组合返回一项,图片按网关规则转成 URL。

+

打开接口 22 测试页

+
+
+

3. B端生发(医生端) POST  /api/v1/hair/grow-b

@@ -446,6 +460,7 @@ console.log(features.gene_style); // "自然型" 接口测试页功能 1. 四庭七眼/static/test_interface1.html上传照片 → 原图+标注叠加,底图/标注开关,指标卡片 2. C端生发/static/test_interface2.html上传+性别 → 方案一覧(原图/叠加/生发),双图对比 + 22. 生发密度与发际线高度/static/test_interface22.html登录后生成,支持密度多选和发际线高度预览 3. B端生发/static/test_interface3.html划线图上传 → 生发效果图 4. 用户特征/static/test_interface4.html上传照片 → 7项面部特征 + 原始JSON 42. 用户特征与两项判定/static/test_interface42.html登录、测试鉴权、刷新令牌、上传照片 diff --git a/static/test_interface22.html b/static/test_interface22.html new file mode 100644 index 0000000..1298c95 --- /dev/null +++ b/static/test_interface22.html @@ -0,0 +1,466 @@ + + + + + +接口 22 · 生发测试 + + + + + + +
+

接口 22 生发测试

+
POST /api/v1/hair/grow-22
+
+ + + + + +

请使用对接账号登录后生图。

+
+
+
+
+ + +
+
+
+ 发际线形状(可多选) +
+

+
+

首次按默认高度 0 生图,出图后可预览和调整发际线高度。

+ +
+ 生发密度(至少选择一档) +
+ + + +
+
+ +

+
+
+ +
+ + + diff --git a/tests/test_gateway_client_auth.py b/tests/test_gateway_client_auth.py index 26e2a12..717c207 100644 --- a/tests/test_gateway_client_auth.py +++ b/tests/test_gateway_client_auth.py @@ -14,7 +14,7 @@ LOGIN = "/api/v1/auth/login" REFRESH = "/api/v1/auth/refresh" FEATURES_42 = "/api/v1/face/features-42" CHECK = "/api/v1/auth/check" -PROTECTED = [FEATURES_42, "/api/v1/hairline/generate-52", "/api/v1/face/b-end-points"] +PROTECTED = [FEATURES_42, "/api/v1/hairline/generate-52", "/api/v1/face/b-end-points", "/api/v1/hair/grow-22"] @pytest.fixture @@ -33,7 +33,7 @@ def login(client): @pytest.mark.parametrize("path", PROTECTED) -def test_all_three_routes_reject_missing_static_and_refresh_tokens(client, monkeypatch, path): +def test_protected_routes_reject_missing_static_and_refresh_tokens(client, monkeypatch, path): proxy = AsyncMock() features = AsyncMock() monkeypatch.setattr(gateway, "_proxy", proxy) @@ -99,7 +99,7 @@ def test_features42_combines_processed_photo_with_main_features(client, monkeypa analyzer.assert_called_once_with(b"processed", None, accept_age_alias=True) -def test_bearer_requirement_is_scoped_to_three_business_routes_and_check(client): +def test_bearer_requirement_is_scoped_to_protected_business_routes_and_check(client): schema = client.get("/openapi.json").json() protected = { path diff --git a/tests/test_gateway_interface52.py b/tests/test_gateway_interface52.py index cd45815..ce7b7d0 100644 --- a/tests/test_gateway_interface52.py +++ b/tests/test_gateway_interface52.py @@ -1,8 +1,9 @@ -"""Gateway-only interface 52 integration: raw forwarding, token and image URLs.""" +"""Gateway integration for interfaces 52 and 22: raw forwarding, token and image URLs.""" import asyncio import base64 import importlib import io +from contextlib import closing from types import SimpleNamespace from unittest.mock import AsyncMock, Mock @@ -15,6 +16,9 @@ from gateway import config, forward gateway = importlib.import_module("gateway.app") PATH = "/api/v1/hairline/generate-52" +GROW22 = "/api/v1/hair/grow-22" + + @pytest.fixture def client_headers(monkeypatch): monkeypatch.setenv("HAIR_API_TOKEN_SECRET", "gateway-test-signing-secret-at-least-32") @@ -167,3 +171,66 @@ def test_public_interface52_page_has_no_internal_password(): assert client.get("/static/hairline_reference_box.js").status_code == 200 finally: client.close() + + +def test_interface22_forwards_repeated_density_fields_and_serves_image_urls( + proxy_setup, monkeypatch, client_headers, +): + cfg, acquire, release, release_worker, unhealthy = proxy_setup + png, jpg = image_bytes("PNG"), image_bytes("JPEG") + calls = [] + + def worker(request): + calls.append(request) + return httpx.Response(200, json={"code": 0, "data": {"results": [ + {"order": 1, "hairline_type": "ellipse", "hair_density": "sparse", + "image_base64": base64.b64encode(png).decode(), + "grown_image_base64": base64.b64encode(jpg).decode()}, + {"order": 1, "hairline_type": "ellipse", "hair_density": "dense", + "image_base64": base64.b64encode(png).decode(), "grown_image_base64": None}, + ]}}) + + upstream = httpx.AsyncClient(transport=httpx.MockTransport(worker)) + monkeypatch.setattr(forward, "get_client", lambda: upstream) + source = httpx.Request( + "POST", "http://testserver" + GROW22, + files={"image_file": ("face.png", png, "image/png")}, + data={"gender": "female", "hair_style": "1", "hairline_height": "-0.5", + "hair_density": ["sparse", "dense"]}, + ) + raw = source.read() + assert raw.count(b'name="hair_density"') == 2 + try: + with closing(TestClient(gateway.app)) as client: + response = client.post(GROW22, content=raw, headers={ + **client_headers, "Content-Type": source.headers["content-type"], + }) + assert response.status_code == 200 + sparse, dense = response.json()["data"]["results"] + assert [sparse["hair_density"], dense["hair_density"]] == ["sparse", "dense"] + assert client.get(sparse["image_url"]).content == png + assert client.get(sparse["grown_image_url"]).content == jpg + assert dense["grown_image_base64"] is None + finally: + asyncio.run(upstream.aclose()) + assert len(calls) == 1 + assert str(calls[0].url) == "http://worker" + GROW22 + assert calls[0].content == raw + assert calls[0].headers["x-internal-token"] == cfg["shared_password"] + assert "authorization" not in calls[0].headers + acquire.assert_awaited_once_with(cfg, 0) + release.assert_called_once() + release_worker.assert_awaited_once() + unhealthy.assert_not_awaited() + + +def test_interface22_page_uses_gateway_login_and_authenticated_requests(): + with closing(TestClient(gateway.app)) as client: + page = client.get("/static/test_interface22.html") + assert page.status_code == 200 + assert 'id="client-auth"' in page.text + for path in (GROW22, PATH): + assert f"window.hairClientFetch('{path}'" in page.text + assert "X-Internal-Token" not in page.text + assert "localhost:8187" not in page.text + assert client.get("/static/client_auth.js").status_code == 200