fix(gateway): 补齐鉴权测试页并保持旧接口兼容

This commit is contained in:
xsl
2026-11-07 21:11:17 +08:00
parent 51baf96d78
commit c7add84d91
11 changed files with 100 additions and 22 deletions
+1 -1
View File
@@ -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。
+6 -1
View File
@@ -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:
+1 -1
View File
@@ -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) 射线组;单射线直接取交点,
+2 -2
View File
@@ -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)
+4 -1
View File
@@ -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={
+9 -1
View File
@@ -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, "访问令牌有效")
+40 -11
View File
@@ -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 = () => {
+3 -1
View File
@@ -63,10 +63,12 @@
<h1>🔬 接口42 — 用户特征分析与两项判定 测试</h1>
<p class="subtitle">POST /api/v1/face/features-42 &nbsp;|&nbsp; 质检 → 扶正裁切 → 面部特征与两项判定 &nbsp;|&nbsp; data.features 为 JSON 字符串</p>
<form id="client-auth" style="display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:12px 0">
<form id="client-auth" method="post" action="/api/v1/auth/login" style="display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:12px 0">
<label>用户名 <input name="username" autocomplete="username" required></label>
<label>密码 <input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">登录</button>
<button id="client-auth-check" type="button">测试鉴权</button>
<button id="client-auth-refresh" type="button" disabled>刷新令牌</button>
<span id="client-auth-status" role="status">登录后可调用接口。</span>
</form>
<script src="/static/client_auth.js" defer></script>
+3 -1
View File
@@ -103,10 +103,12 @@
<p class="subtitle">上传正面照,选择性别与发型,查看按中下庭均值定位的设计曲线和生发效果。</p>
<div class="rule-banner"><strong>目标上庭高度 =(中庭高度 + 下庭高度)÷ 2</strong><br>上传图先扶正9→152,再裁切为高约 3.3333×L、宽 2.5×L,人物比原3L规则缩小约10%;L 是9号点到152号点的像素距离,眉心位置沿用参考图比例。长图会在保留人脸的前提下裁为3:4,其余空间不足时仅扶正。从眉心向上定位,各发型中点对齐同一目标。high、low 已停用,旧字段返回 null。</div>
<form id="client-auth" style="display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:12px 0">
<form id="client-auth" method="post" action="/api/v1/auth/login" style="display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:12px 0">
<label>用户名 <input name="username" autocomplete="username" required></label>
<label>密码 <input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">登录</button>
<button id="client-auth-check" type="button">测试鉴权</button>
<button id="client-auth-refresh" type="button" disabled>刷新令牌</button>
<span id="client-auth-status" role="status">登录后可调用接口。</span>
</form>
<script src="/static/client_auth.js" defer></script>
+3 -1
View File
@@ -32,10 +32,12 @@
<main>
<h1>72 号接口 · B 端五个关键点</h1>
<p class="note">上传正面照,查看扶正裁切后的六个白点、四条虚线和厘米标注。</p>
<form id="client-auth" style="display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:12px 0">
<form id="client-auth" method="post" action="/api/v1/auth/login" style="display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:12px 0">
<label>用户名 <input name="username" autocomplete="username" required></label>
<label>密码 <input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">登录</button>
<button id="client-auth-check" type="button">测试鉴权</button>
<button id="client-auth-refresh" type="button" disabled>刷新令牌</button>
<span id="client-auth-status" role="status">登录后可调用接口。</span>
</form>
<script src="/static/client_auth.js" defer></script>
+28 -1
View File
@@ -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()