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()