diff --git a/docs/接口42-52-72网关鉴权.md b/docs/接口42-52-72网关鉴权.md index dbef631..149c0de 100644 --- a/docs/接口42-52-72网关鉴权.md +++ b/docs/接口42-52-72网关鉴权.md @@ -32,4 +32,4 @@ 网关保留原有 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`,页面先登录再提交,访问令牌到期时自动刷新。 +网关测试页为 `/static/test_interface42.html`、`/static/test_interface52.html` 和 `/static/test_interface72.html`。页面可登录、手动刷新令牌并提交图片,访问令牌到期时自动刷新。「测试鉴权」使用 `GET /api/v1/auth/check` 验证无令牌请求返回 HTTP 401、有效令牌请求返回 HTTP 200,测试时无需上传照片或调用 worker。 diff --git a/face_analysis/face_ratios.py b/face_analysis/face_ratios.py index e37a0d1..2976960 100644 --- a/face_analysis/face_ratios.py +++ b/face_analysis/face_ratios.py @@ -65,10 +65,15 @@ def detect_framed_ratio_points(image): def _detect_ratio_points(image, landmarks): from hairline.face_landmarks import to_landmark_list + from .calibration import normalized_to_pixel from .special_points import detect_special_points - data = detect_special_points(image, landmarks_override=to_landmark_list(landmarks)) + override = to_landmark_list(landmarks) + data = detect_special_points(image, landmarks_override=override) points = data["points"] + height, width = image.shape[:2] + x, y = normalized_to_pixel(override.landmark[454], width, height) + points["face_edge_454"] = {"x": round(x), "y": round(y)} data["ft_original_points"] = {key: points[key] for key in FT_POINTS} a, b = (points[key] for key in FT_POINTS) if a is not None and b is not None: diff --git a/face_analysis/special_points.py b/face_analysis/special_points.py index b5c116c..d57af28 100644 --- a/face_analysis/special_points.py +++ b/face_analysis/special_points.py @@ -30,7 +30,7 @@ GROUPS = { "mouth": [0, 37, 40, 67, 91, 84, 17, 405, 375, 409, 269], "nose_midline": [9, 168, 197, 5, 4, 194, 278, 48, 437, 217], "cheek": [101, 117, 123, 187, 205, 50, 330, 346, 352, 280, 425], - "face_edge": [162, 234, 58, 136, 149, 152, 378, 365, 288, 401, 366, 389, 251, 454], + "face_edge": [162, 234, 58, 136, 149, 152, 378, 365, 288, 401, 366, 389, 251], } # 发际线点定义:字段名 → 参与计算的 (起点ID, 方向点ID) 射线组;单射线直接取交点, diff --git a/face_features.py b/face_features.py index 89d5c0d..0ca47d6 100644 --- a/face_features.py +++ b/face_features.py @@ -141,7 +141,7 @@ def _local_face_shape(image_bytes: bytes = None, image_url: str = None) -> str: return shape -def analyze_features(image_bytes: bytes = None, image_url: str = None): +def analyze_features(image_bytes: bytes = None, image_url: str = None, *, accept_age_alias=False): """豆包分析 6 项特征 + 本机 MediaPipe 覆盖 face_shape。 Returns: dict —— 7 个英文字段(face_shape/eyebrow_shape/facial_age/ @@ -177,7 +177,7 @@ def analyze_features(image_bytes: bytes = None, image_url: str = None): return None # 豆包 5 项 + 本地脸型覆盖 feats = {en: raw[zh] for zh, en in _KEY_MAP.items() if zh in raw} - if "facial_age" not in feats and "面部年龄区间" in raw: + if accept_age_alias and "facial_age" not in feats and "面部年龄区间" in raw: feats["facial_age"] = raw["面部年龄区间"] try: feats["face_shape"] = _local_face_shape(image_bytes, image_url) diff --git a/gateway/app.py b/gateway/app.py index 0b4bffa..726d3c6 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -686,7 +686,10 @@ async def _face_features_response(request, image_file, image_url, image_base64, image_url = None try: - feats = await run_in_threadpool(analyze_features, img_bytes, image_url) + if include_ratios: + feats = await run_in_threadpool(analyze_features, img_bytes, image_url, accept_age_alias=True) + else: + feats = await run_in_threadpool(analyze_features, img_bytes, image_url) except Exception as ex: logger.exception("接口4 豆包调用失败") return JSONResponse(status_code=200, content={ diff --git a/gateway/client_auth.py b/gateway/client_auth.py index 88e8726..ae62824 100644 --- a/gateway/client_auth.py +++ b/gateway/client_auth.py @@ -10,7 +10,7 @@ import secrets import time from uuid import uuid4 -from fastapi import APIRouter, Security +from fastapi import APIRouter, Depends, Security from fastapi.responses import JSONResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel, Field @@ -145,3 +145,11 @@ async def client_auth_error(credentials: HTTPAuthorizationCredentials | None = S if not _valid(credentials.credentials, secret, "access"): return _unauthorized("访问令牌无效或已过期") return None + + +@router.get("/check", summary="验证当前访问令牌") +async def check(auth_error=Depends(client_auth_error)): + """检查与 42、52、72 相同的 Bearer 令牌,无需提交图片。""" + if auth_error is not None: + return auth_error + return _response(0, "访问令牌有效") diff --git a/static/client_auth.js b/static/client_auth.js index 3c42c8f..e06697f 100644 --- a/static/client_auth.js +++ b/static/client_auth.js @@ -1,7 +1,9 @@ (() => { const form = document.getElementById("client-auth"); const message = document.getElementById("client-auth-status"); - const button = form.querySelector("button"); + const loginButton = form.querySelector('button[type="submit"]'); + const checkButton = document.getElementById("client-auth-check"); + const refreshButton = document.getElementById("client-auth-refresh"); let accessToken; let refreshToken; let accessExpiry = 0; @@ -19,23 +21,30 @@ return result.data; } - form.addEventListener("submit", async (event) => { - event.preventDefault(); - button.disabled = true; - const data = new FormData(form); + async function runAction(action) { + loginButton.disabled = checkButton.disabled = refreshButton.disabled = true; try { + await action(); + } catch (error) { + message.textContent = error.message; + } finally { + loginButton.disabled = checkButton.disabled = false; + refreshButton.disabled = !refreshToken; + } + } + + form.addEventListener("submit", (event) => { + event.preventDefault(); + const data = new FormData(form); + form.elements.namedItem("password").value = ""; + runAction(async () => { const result = await requestToken("login", { username: data.get("username"), password: data.get("password"), }); refreshToken = result.refresh_token; message.textContent = "已登录。访问令牌过期时会自动刷新。"; - } catch (error) { - message.textContent = error.message; - } finally { - form.elements.namedItem("password").value = ""; - button.disabled = false; - } + }); }); async function refresh() { @@ -44,11 +53,31 @@ await requestToken("refresh", { refresh_token: refreshToken }); } catch (error) { accessToken = refreshToken = undefined; + refreshButton.disabled = true; message.textContent = "请重新登录。"; throw error; } } + checkButton.addEventListener("click", () => runAction(async () => { + const path = "/api/v1/auth/check"; + const anonymous = await fetch(path); + if (anonymous.status !== 401) throw new Error("无令牌检查返回 HTTP " + anonymous.status); + if (!accessToken) { + message.textContent = "无令牌已拒绝(HTTP 401)。登录后可验证访问令牌。"; + return; + } + const response = await fetch(path, { headers: { Authorization: "Bearer " + accessToken } }); + const result = await response.json(); + if (!response.ok || result.code !== 0) throw new Error(result.message || "访问令牌检查失败"); + message.textContent = "鉴权通过:无令牌返回 HTTP 401,有效令牌返回 HTTP 200。"; + })); + + refreshButton.addEventListener("click", () => runAction(async () => { + await refresh(); + message.textContent = "访问令牌已刷新,有效期 15 分钟。"; + })); + window.hairClientFetch = async (path, options) => { if (Date.now() >= accessExpiry || !accessToken) await refresh(); const send = () => { diff --git a/static/test_interface42.html b/static/test_interface42.html index 4a5f027..f834ff2 100644 --- a/static/test_interface42.html +++ b/static/test_interface42.html @@ -63,10 +63,12 @@

🔬 接口42 — 用户特征分析与两项判定 测试

POST /api/v1/face/features-42  |  质检 → 扶正裁切 → 面部特征与两项判定  |  data.features 为 JSON 字符串

-
+ + + 登录后可调用接口。
diff --git a/static/test_interface52.html b/static/test_interface52.html index c0fb5fd..affe0d5 100644 --- a/static/test_interface52.html +++ b/static/test_interface52.html @@ -103,10 +103,12 @@

上传正面照,选择性别与发型,查看按中下庭均值定位的设计曲线和生发效果。

目标上庭高度 =(中庭高度 + 下庭高度)÷ 2
上传图先扶正9→152,再裁切为高约 3.3333×L、宽 2.5×L,人物比原3L规则缩小约10%;L 是9号点到152号点的像素距离,眉心位置沿用参考图比例。长图会在保留人脸的前提下裁为3:4,其余空间不足时仅扶正。从眉心向上定位,各发型中点对齐同一目标。high、low 已停用,旧字段返回 null。
-
+ + + 登录后可调用接口。
diff --git a/static/test_interface72.html b/static/test_interface72.html index 09e5a22..0639a1b 100644 --- a/static/test_interface72.html +++ b/static/test_interface72.html @@ -32,10 +32,12 @@

72 号接口 · B 端五个关键点

上传正面照,查看扶正裁切后的六个白点、四条虚线和厘米标注。

-
+ + + 登录后可调用接口。
diff --git a/tests/test_gateway_client_auth.py b/tests/test_gateway_client_auth.py index bfd75e1..26e2a12 100644 --- a/tests/test_gateway_client_auth.py +++ b/tests/test_gateway_client_auth.py @@ -13,6 +13,7 @@ gateway = importlib.import_module("gateway.app") 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"] @@ -58,10 +59,13 @@ def test_login_expiry_tampering_and_refresh(client, monkeypatch): access, refresh = data["access_token"], data["refresh_token"] headers = {"Authorization": f"Bearer {access}"} assert client.post(path, headers=headers).status_code == 200 + assert client.get(CHECK).status_code == 401 + assert client.get(CHECK, headers=headers).json()["code"] == 0 assert client.post(path, headers={"Authorization": f"Bearer {access}x"}).status_code == 401 assert client.post(REFRESH, json={"refresh_token": access}).status_code == 401 clock.now += data["expires_in"] assert client.post(path, headers=headers).status_code == 401 + assert client.get(CHECK, headers=headers).status_code == 401 response = client.post(REFRESH, json={"refresh_token": refresh}) assert response.status_code == 200 and response.headers["cache-control"] == "no-store" refreshed = response.json()["data"] @@ -92,4 +96,27 @@ def test_features42_combines_processed_photo_with_main_features(client, monkeypa "zygoma_expansion": True, "temple_hollowing": True, } prepared.assert_awaited_once_with(b"raw", None) - analyzer.assert_called_once_with(b"processed", None) + 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): + schema = client.get("/openapi.json").json() + protected = { + path + for path, methods in schema["paths"].items() + for method in methods.values() + if isinstance(method, dict) and {"ClientBearer": []} in method.get("security", []) + } + assert protected == {*PROTECTED, CHECK} + + +def test_original_features_needs_no_bearer_and_preserves_legacy_analysis(client, monkeypatch): + analyzer = Mock(return_value={"face_shape": "鹅蛋脸", "contour_type": "轮廓适中"}) + monkeypatch.setattr("face_features.analyze_features", analyzer) + prepared = AsyncMock() + monkeypatch.setattr(gateway, "_prepare_features_42", prepared) + response = client.post("/api/v1/face/features", files={"image_file": ("face.jpg", b"raw")}) + assert response.status_code == 200 and response.json()["code"] == 0 + assert json.loads(response.json()["data"]["features"]) == analyzer.return_value + analyzer.assert_called_once_with(b"raw", None) + prepared.assert_not_awaited()