fix(gateway): 补齐鉴权测试页并保持旧接口兼容
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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={
|
||||
|
||||
@@ -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
@@ -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 = () => {
|
||||
|
||||
@@ -63,10 +63,12 @@
|
||||
<h1>🔬 接口42 — 用户特征分析与两项判定 测试</h1>
|
||||
<p class="subtitle">POST /api/v1/face/features-42 | 质检 → 扶正裁切 → 面部特征与两项判定 | 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user