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 改写流程。 网关保留原有 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): def _detect_ratio_points(image, landmarks):
from hairline.face_landmarks import to_landmark_list from hairline.face_landmarks import to_landmark_list
from .calibration import normalized_to_pixel
from .special_points import detect_special_points 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"] 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} data["ft_original_points"] = {key: points[key] for key in FT_POINTS}
a, b = (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: 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], "mouth": [0, 37, 40, 67, 91, 84, 17, 405, 375, 409, 269],
"nose_midline": [9, 168, 197, 5, 4, 194, 278, 48, 437, 217], "nose_midline": [9, 168, 197, 5, 4, 194, 278, 48, 437, 217],
"cheek": [101, 117, 123, 187, 205, 50, 330, 346, 352, 280, 425], "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) 射线组;单射线直接取交点, # 发际线点定义:字段名 → 参与计算的 (起点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 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。 """豆包分析 6 项特征 + 本机 MediaPipe 覆盖 face_shape。
Returns: dict —— 7 个英文字段(face_shape/eyebrow_shape/facial_age/ 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 return None
# 豆包 5 项 + 本地脸型覆盖 # 豆包 5 项 + 本地脸型覆盖
feats = {en: raw[zh] for zh, en in _KEY_MAP.items() if zh in raw} 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["面部年龄区间"] feats["facial_age"] = raw["面部年龄区间"]
try: try:
feats["face_shape"] = _local_face_shape(image_bytes, image_url) 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 image_url = None
try: 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: except Exception as ex:
logger.exception("接口4 豆包调用失败") logger.exception("接口4 豆包调用失败")
return JSONResponse(status_code=200, content={ return JSONResponse(status_code=200, content={
+9 -1
View File
@@ -10,7 +10,7 @@ import secrets
import time import time
from uuid import uuid4 from uuid import uuid4
from fastapi import APIRouter, Security from fastapi import APIRouter, Depends, Security
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field 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"): if not _valid(credentials.credentials, secret, "access"):
return _unauthorized("访问令牌无效或已过期") return _unauthorized("访问令牌无效或已过期")
return None 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 form = document.getElementById("client-auth");
const message = document.getElementById("client-auth-status"); 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 accessToken;
let refreshToken; let refreshToken;
let accessExpiry = 0; let accessExpiry = 0;
@@ -19,23 +21,30 @@
return result.data; return result.data;
} }
form.addEventListener("submit", async (event) => { async function runAction(action) {
event.preventDefault(); loginButton.disabled = checkButton.disabled = refreshButton.disabled = true;
button.disabled = true;
const data = new FormData(form);
try { 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", { const result = await requestToken("login", {
username: data.get("username"), username: data.get("username"),
password: data.get("password"), password: data.get("password"),
}); });
refreshToken = result.refresh_token; refreshToken = result.refresh_token;
message.textContent = "已登录。访问令牌过期时会自动刷新。"; message.textContent = "已登录。访问令牌过期时会自动刷新。";
} catch (error) { });
message.textContent = error.message;
} finally {
form.elements.namedItem("password").value = "";
button.disabled = false;
}
}); });
async function refresh() { async function refresh() {
@@ -44,11 +53,31 @@
await requestToken("refresh", { refresh_token: refreshToken }); await requestToken("refresh", { refresh_token: refreshToken });
} catch (error) { } catch (error) {
accessToken = refreshToken = undefined; accessToken = refreshToken = undefined;
refreshButton.disabled = true;
message.textContent = "请重新登录。"; message.textContent = "请重新登录。";
throw error; 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) => { window.hairClientFetch = async (path, options) => {
if (Date.now() >= accessExpiry || !accessToken) await refresh(); if (Date.now() >= accessExpiry || !accessToken) await refresh();
const send = () => { const send = () => {
+3 -1
View File
@@ -63,10 +63,12 @@
<h1>🔬 接口42 — 用户特征分析与两项判定 测试</h1> <h1>🔬 接口42 — 用户特征分析与两项判定 测试</h1>
<p class="subtitle">POST /api/v1/face/features-42 &nbsp;|&nbsp; 质检 → 扶正裁切 → 面部特征与两项判定 &nbsp;|&nbsp; data.features 为 JSON 字符串</p> <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="username" autocomplete="username" required></label>
<label>密码 <input name="password" type="password" autocomplete="current-password" required></label> <label>密码 <input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">登录</button> <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> <span id="client-auth-status" role="status">登录后可调用接口。</span>
</form> </form>
<script src="/static/client_auth.js" defer></script> <script src="/static/client_auth.js" defer></script>
+3 -1
View File
@@ -103,10 +103,12 @@
<p class="subtitle">上传正面照,选择性别与发型,查看按中下庭均值定位的设计曲线和生发效果。</p> <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> <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="username" autocomplete="username" required></label>
<label>密码 <input name="password" type="password" autocomplete="current-password" required></label> <label>密码 <input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">登录</button> <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> <span id="client-auth-status" role="status">登录后可调用接口。</span>
</form> </form>
<script src="/static/client_auth.js" defer></script> <script src="/static/client_auth.js" defer></script>
+3 -1
View File
@@ -32,10 +32,12 @@
<main> <main>
<h1>72 号接口 · B 端五个关键点</h1> <h1>72 号接口 · B 端五个关键点</h1>
<p class="note">上传正面照,查看扶正裁切后的六个白点、四条虚线和厘米标注。</p> <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="username" autocomplete="username" required></label>
<label>密码 <input name="password" type="password" autocomplete="current-password" required></label> <label>密码 <input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">登录</button> <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> <span id="client-auth-status" role="status">登录后可调用接口。</span>
</form> </form>
<script src="/static/client_auth.js" defer></script> <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" LOGIN = "/api/v1/auth/login"
REFRESH = "/api/v1/auth/refresh" REFRESH = "/api/v1/auth/refresh"
FEATURES_42 = "/api/v1/face/features-42" 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"]
@@ -58,10 +59,13 @@ def test_login_expiry_tampering_and_refresh(client, monkeypatch):
access, refresh = data["access_token"], data["refresh_token"] access, refresh = data["access_token"], data["refresh_token"]
headers = {"Authorization": f"Bearer {access}"} headers = {"Authorization": f"Bearer {access}"}
assert client.post(path, headers=headers).status_code == 200 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(path, headers={"Authorization": f"Bearer {access}x"}).status_code == 401
assert client.post(REFRESH, json={"refresh_token": access}).status_code == 401 assert client.post(REFRESH, json={"refresh_token": access}).status_code == 401
clock.now += data["expires_in"] clock.now += data["expires_in"]
assert client.post(path, headers=headers).status_code == 401 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}) response = client.post(REFRESH, json={"refresh_token": refresh})
assert response.status_code == 200 and response.headers["cache-control"] == "no-store" assert response.status_code == 200 and response.headers["cache-control"] == "no-store"
refreshed = response.json()["data"] 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, "zygoma_expansion": True, "temple_hollowing": True,
} }
prepared.assert_awaited_once_with(b"raw", None) 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()