feat(gateway): 接入接口22与账号鉴权测试页
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
# 42、52、72 号接口网关鉴权
|
||||
# 22、42、52、72 号接口网关鉴权
|
||||
|
||||
| 接口 | 方法与路径 | 执行位置 |
|
||||
|---|---|---|
|
||||
| 22 | `POST /api/v1/hair/grow-22` | 网关转发至 Worker,发际线预览和生发图转成 URL |
|
||||
| 42 | `POST /api/v1/face/features-42` | 网关按接口 52 的规则扶正裁切,再用接口 4 的分析结果和 Ft/Zy 判定生成特征 |
|
||||
| 52 | `POST /api/v1/hairline/generate-52` | 网关转发至 worker |
|
||||
| 72 | `POST /api/v1/face/b-end-points` | 网关转发至 worker,底图与标注层转成 URL |
|
||||
|
||||
客户端调用这三个接口时,在原请求上添加 `Authorization: Bearer <access_token>`。缺少、过期、签名无效或误用刷新令牌时,返回 HTTP 401 和业务码 `1009`。网关验证通过后,52 和 72 继续使用独立的 `X-Internal-Token` 转发至 worker,客户端令牌不会传给 worker。
|
||||
客户端调用这些接口时,在原请求上添加 `Authorization: Bearer <access_token>`。缺少、过期、签名无效或误用刷新令牌时,返回 HTTP 401 和业务码 `1009`。网关验证通过后,22、52、72 通过现有 Worker 池转发,内部转发头由网关提供。
|
||||
|
||||
## 登录
|
||||
|
||||
@@ -32,4 +33,6 @@
|
||||
|
||||
网关保留原有 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`。页面可登录、手动刷新令牌并提交图片,访问令牌到期时自动刷新。「测试鉴权」使用 `GET /api/v1/auth/check` 验证无令牌请求返回 HTTP 401、有效令牌请求返回 HTTP 200,测试时无需上传照片或调用 worker。
|
||||
部署接口 22 前,确认 Worker 池的主备节点均包含接口 22(例如 `ubuntu3090_debug` 上的 `b84a506` 或后续版本)。Worker 启用 IP 白名单时,配置网关直连地址。
|
||||
|
||||
网关测试页为 `/static/test_interface22.html`、`/static/test_interface42.html`、`/static/test_interface52.html` 和 `/static/test_interface72.html`。页面可登录、手动刷新令牌并提交图片,访问令牌到期时自动刷新。「测试鉴权」调用 `GET /api/v1/auth/check`,验证无令牌请求返回 HTTP 401、有效令牌请求返回 HTTP 200。
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
| 1 四庭七眼测量 | POST | `/api/v1/face/measure` |
|
||||
| 6 四庭七眼测量 v2 | POST | `/api/v1/face/measure-v2` |
|
||||
| 2 C 端生发 | POST | `/api/v1/hair/grow` |
|
||||
| 22 生发密度与发际线高度 | POST | `/api/v1/hair/grow-22` |
|
||||
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
|
||||
| 4 用户特征 | POST | `/api/v1/face/features` |
|
||||
| 42 用户特征与两项判定 | POST | `/api/v1/face/features-42` |
|
||||
@@ -360,6 +361,21 @@
|
||||
|
||||
---
|
||||
|
||||
## 接口 22 生发密度与发际线高度
|
||||
|
||||
`POST /api/v1/hair/grow-22` 需要现有对接账号签发的 `Authorization: Bearer <access_token>`。 [账号登录与刷新](接口42-52-72网关鉴权.md)
|
||||
|
||||
表单图片字段三选一,`gender` 为 `female` 或 `male`,`hair_style` 用逗号分隔发型序号(两种性别均为 `1–7`)。
|
||||
|
||||
| 字段 | 取值 |
|
||||
|---|---|
|
||||
| `hair_density` | 必填,至少一档。稀疏 `sparse`、正常 `normal`、浓密 `dense`。多选时重复提交同名字段。 |
|
||||
| `hairline_height` | 默认 `0`,范围 `[-1, 1]`。负值提高发际线,正值降低发际线。 |
|
||||
|
||||
每个「发型 × 密度」组合返回一项,包含 `order`、`hairline_type`、`hair_density`、发际线透明 PNG `image_url` 和生发图 `grown_image_url`。单项生发失败时保留 `grown_image_base64: null`。
|
||||
|
||||
[网关测试页](/static/test_interface22.html)登录后可直接生图。首次生成使用默认高度 `0`,出图后通过同源接口 52 加载发际线预览,再调整形状、高度和密度。
|
||||
|
||||
## 接口 3:B 端生发接口
|
||||
|
||||
**说明**:医生/操作端在用户照片上用马克笔标注目标发际线后,**只需上传这一张划线图**。系统检测划线 →
|
||||
|
||||
@@ -250,6 +250,7 @@ async def index():
|
||||
"test_pages": {
|
||||
"if1_measure": "/static/test_interface1.html",
|
||||
"if2_hair_grow": "/static/test_interface2.html",
|
||||
"if22_hair_grow": "/static/test_interface22.html",
|
||||
"if3_hair_grow_b": "/static/test_interface3.html",
|
||||
"if4_features": "/static/test_interface4.html",
|
||||
"if42_features": "/static/test_interface42.html",
|
||||
@@ -581,6 +582,14 @@ async def hair_grow(request: Request):
|
||||
return await _proxy(request, "/api/v1/hair/grow")
|
||||
|
||||
|
||||
@app.post("/api/v1/hair/grow-22", summary="接口22:生发密度与发际线高度", tags=["生发"])
|
||||
async def hair_grow_22(request: Request, auth_error=Depends(client_auth_error)):
|
||||
"""鉴权后透传图片、发型、可重复的 hair_density 和 hairline_height 至 GPU Worker。"""
|
||||
if auth_error is not None:
|
||||
return auth_error
|
||||
return await _proxy(request, "/api/v1/hair/grow-22")
|
||||
|
||||
|
||||
@app.post("/api/v1/hair/grow-b", tags=["生发"], deprecated=True)
|
||||
async def hair_grow_b(request: Request):
|
||||
"""接口3:B端生发(已屏蔽)"""
|
||||
|
||||
@@ -149,7 +149,7 @@ async def client_auth_error(credentials: HTTPAuthorizationCredentials | None = S
|
||||
|
||||
@router.get("/check", summary="验证当前访问令牌")
|
||||
async def check(auth_error=Depends(client_auth_error)):
|
||||
"""检查与 42、52、72 相同的 Bearer 令牌,无需提交图片。"""
|
||||
"""检查受保护业务接口使用的 Bearer 令牌。"""
|
||||
if auth_error is not None:
|
||||
return auth_error
|
||||
return _response(0, "访问令牌有效")
|
||||
|
||||
+19
-4
@@ -55,9 +55,10 @@
|
||||
|
||||
<div class="toc">
|
||||
<a href="#overview">通用约定</a>
|
||||
<a href="#client-auth">42/52/72 鉴权</a>
|
||||
<a href="#client-auth">22/42/52/72 鉴权</a>
|
||||
<a href="#if1">接口1</a>
|
||||
<a href="#if2">接口2</a>
|
||||
<a href="#if22">接口22</a>
|
||||
<a href="#if3">接口3</a>
|
||||
<a href="#if4">接口4</a>
|
||||
<a href="#if42">接口42</a>
|
||||
@@ -78,7 +79,7 @@
|
||||
<tr><td>请求方式</td><td>业务接口使用 POST。验证令牌使用 GET。</td></tr>
|
||||
<tr><td>编码</td><td>UTF-8</td></tr>
|
||||
<tr><td>Content-Type</td><td>传图使用 <code>multipart/form-data</code>,登录和刷新使用 <code>application/json</code></td></tr>
|
||||
<tr><td>客户端鉴权</td><td>仅 42、52、72 需要 <a class="link" href="#client-auth">Bearer 访问令牌</a>。其他原有业务接口沿用现有接入方式。</td></tr>
|
||||
<tr><td>客户端鉴权</td><td>22、42、52、72 使用 <a class="link" href="#client-auth">Bearer 访问令牌</a>。</td></tr>
|
||||
<tr><td>图片参数</td><td>三选一:<code>image_file</code>(文件上传)/ <code>image_url</code>(URL)/ <code>image_base64</code>(base64 + 前缀)</td></tr>
|
||||
<tr><td>图片格式</td><td>JPG / PNG,单人正面照</td></tr>
|
||||
<tr><td>请求体上限</td><td><strong>2MB</strong>(nginx 层限制)。超限直接返回 HTTP <strong>413</strong>,不会进入业务逻辑。建议图片文件 ≤1MB;<code>image_base64</code> 传图会膨胀约 33%,编码前原图建议 ≤1.4MB。手机直拍照片通常 3~8MB,<strong>上传前务必先压缩</strong>(如最长边缩到 1500px、JPEG 质量 80)。<code>image_url</code> 方式图片在远端,不受此限制</td></tr>
|
||||
@@ -95,9 +96,9 @@
|
||||
</div>
|
||||
|
||||
<div class="card" id="client-auth">
|
||||
<h2>42/52/72 客户端鉴权</h2>
|
||||
<h2>22/42/52/72 客户端鉴权</h2>
|
||||
<div class="card-body">
|
||||
<p>使用同一个访问令牌调用 <a href="#if42">42</a>、<a href="#if52">52</a> 和 <a href="#if72">72</a>。账号为 <code>yonghe</code>,密码由对接人员提供。客户端请求只需发送 <code>Authorization: Bearer <access_token></code>。</p>
|
||||
<p>使用同一个访问令牌调用 <a href="#if22">22</a>、<a href="#if42">42</a>、<a href="#if52">52</a> 和 <a href="#if72">72</a>。账号为 <code>yonghe</code>,密码由对接人员提供。客户端发送 <code>Authorization: Bearer <access_token></code>。</p>
|
||||
|
||||
<div class="guide-step">
|
||||
<h3>1. 登录获取令牌</h3>
|
||||
@@ -219,6 +220,19 @@ const { code, data } = await res.json();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="if22">
|
||||
<h2>22. 生发密度与发际线高度 <span class="badge post">POST</span> <code>/api/v1/hair/grow-22</code></h2>
|
||||
<div class="card-body">
|
||||
<p>使用 <a href="#client-auth">Bearer 访问令牌</a>。图片字段三选一,发型序号男女均为 1–7。</p>
|
||||
<pre>curl -sS "$BASE_URL/api/v1/hair/grow-22" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-F "image_file=@./portrait.jpg" -F "gender=female" -F "hair_style=1,3" \
|
||||
-F "hair_density=sparse" -F "hair_density=dense" -F "hairline_height=0"</pre>
|
||||
<p>至少选择一档密度,多选时重复提交 <code>hair_density</code>。高度在 −1 到 +1 之间,API 的负值上移、正值下移。每个发型与密度组合返回一项,图片按网关规则转成 URL。</p>
|
||||
<p><a href="/static/test_interface22.html" class="link">打开接口 22 测试页</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== 接口3 ======== -->
|
||||
<div class="card" id="if3">
|
||||
<h2>3. B端生发(医生端) <span class="badge post">POST</span> <code>/api/v1/hair/grow-b</code></h2>
|
||||
@@ -446,6 +460,7 @@ console.log(features.gene_style); // "自然型"</pre>
|
||||
<tr><th>接口</th><th>测试页</th><th>功能</th></tr>
|
||||
<tr><td>1. 四庭七眼</td><td><a href="/static/test_interface1.html" class="link">/static/test_interface1.html</a></td><td>上传照片 → 原图+标注叠加,底图/标注开关,指标卡片</td></tr>
|
||||
<tr><td>2. C端生发</td><td><a href="/static/test_interface2.html" class="link">/static/test_interface2.html</a></td><td>上传+性别 → 方案一覧(原图/叠加/生发),双图对比</td></tr>
|
||||
<tr><td>22. 生发密度与发际线高度</td><td><a href="/static/test_interface22.html" class="link">/static/test_interface22.html</a></td><td>登录后生成,支持密度多选和发际线高度预览</td></tr>
|
||||
<tr><td>3. B端生发</td><td><a href="/static/test_interface3.html" class="link">/static/test_interface3.html</a></td><td>划线图上传 → 生发效果图</td></tr>
|
||||
<tr><td>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 7项面部特征 + 原始JSON</td></tr>
|
||||
<tr><td>42. 用户特征与两项判定</td><td><a href="/static/test_interface42.html" class="link">/static/test_interface42.html</a></td><td>登录、测试鉴权、刷新令牌、上传照片</td></tr>
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>接口 22 · 生发测试</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #f3f5f8; color: #172334; font: 15px/1.5 system-ui, sans-serif; }
|
||||
main { max-width: 1100px; margin: 32px auto; padding: 0 16px; }
|
||||
h1 { margin: 0 0 4px; font-size: 28px; }
|
||||
h2 { margin: 0 0 12px; font-size: 18px; }
|
||||
.muted, figcaption { color: #526075; }
|
||||
.card { background: white; border: 1px solid #dce3eb; border-radius: 12px; padding: 18px; margin: 16px 0; }
|
||||
#client-auth { display: flex; flex-wrap: wrap; align-items: end; gap: 8px 12px; }
|
||||
#client-auth-status { flex-basis: 100%; margin: 0; }
|
||||
.fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 14px; }
|
||||
.field { display: grid; gap: 5px; align-content: start; }
|
||||
input, select, button { font: inherit; }
|
||||
.field input, select { min-width: 0; width: 100%; padding: 6px 8px; }
|
||||
fieldset { margin: 16px 0; border: 1px solid #dce3eb; border-radius: 8px; }
|
||||
#sourceFields { margin-top: 0; padding: 0; border: 0; min-width: 0; }
|
||||
.choices { display: flex; flex-wrap: wrap; gap: 8px 18px; }
|
||||
.choices label { white-space: nowrap; }
|
||||
button { background: #1757ab; color: white; border: 0; border-radius: 8px; padding: 8px 16px; cursor: pointer; }
|
||||
button:disabled { opacity: .6; cursor: wait; }
|
||||
.status { margin-bottom: 0; }
|
||||
.status[data-error="true"] { color: #a02020; }
|
||||
.images { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 12px; }
|
||||
.images figure { max-width: 380px; }
|
||||
.result-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 122px), 1fr)); gap: 10px; }
|
||||
.result-tile { min-width: 0; overflow: hidden; background: white; border: 1px solid #dce3eb; border-radius: 8px; }
|
||||
.result-tile img { display: block; width: 100%; height: auto; }
|
||||
.result-tile figcaption { display: grid; gap: 2px; margin: 0; padding: 6px 8px; font-size: 12px; }
|
||||
.result-tile strong { color: #172334; }
|
||||
.preview-editor { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px; margin: 16px 0; }
|
||||
.preview-editor:has(#previews > figure:only-child) { grid-template-columns: minmax(0, 380px) auto; justify-content: start; }
|
||||
.height-controls { display: grid; gap: 6px; padding-top: 27px; }
|
||||
.height-scale { display: flex; gap: 6px; height: clamp(170px, 42vw, 300px); }
|
||||
#height { writing-mode: vertical-lr; direction: rtl; width: 24px; height: 100%; margin: 0; }
|
||||
.height-presets { display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.height-presets label { display: flex; align-items: center; gap: 3px; cursor: pointer; white-space: nowrap; }
|
||||
figure { margin: 0; min-width: 0; }
|
||||
figcaption { margin-bottom: 6px; }
|
||||
.stack { position: relative; overflow: hidden; border-radius: 6px; }
|
||||
.stack img, .stack canvas { width: 100%; height: auto; display: block; border-radius: 6px; }
|
||||
.preview-layer, .preview-clip, .stack .overlay, .crop-border { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.preview-layer { transform: translateY(calc(-1 * var(--height-adjustment) * var(--max-shift))); }
|
||||
.crop-border polygon { fill: none; stroke: #e33399; stroke-width: 2; stroke-dasharray: 6 4; vector-effect: non-scaling-stroke; }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
@media (max-width: 560px) { .images { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
<script src="/static/client_auth.js" defer></script>
|
||||
<script src="/static/img_downscale.js?v=2" defer></script>
|
||||
<script src="/static/hairline_reference_box.js?v=2" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>接口 22 生发测试</h1>
|
||||
<div class="muted"><code>POST /api/v1/hair/grow-22</code></div>
|
||||
<form id="client-auth" class="card" method="post" action="/api/v1/auth/login" aria-label="账号登录">
|
||||
<label class="field">用户名
|
||||
<input name="username" autocomplete="username" required>
|
||||
</label>
|
||||
<label class="field">密码
|
||||
<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>
|
||||
<p id="client-auth-status" class="muted" role="status">请使用对接账号登录后生图。</p>
|
||||
</form>
|
||||
<form id="generateRequest" class="card">
|
||||
<fieldset id="sourceFields" aria-label="照片与性别">
|
||||
<div class="fields">
|
||||
<label class="field">照片(JPG / PNG)
|
||||
<input type="file" name="image_file" accept="image/jpeg,image/png,.jpg,.jpeg,.png" required>
|
||||
</label>
|
||||
<label class="field">性别
|
||||
<select name="gender"><option value="female">女性</option><option value="male">男性</option></select>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset id="styleFields">
|
||||
<legend>发际线形状(可多选)</legend>
|
||||
<div id="styles" class="choices"></div>
|
||||
<p id="generationPreviewStatus" class="status" role="status"></p>
|
||||
</fieldset>
|
||||
<p id="firstRunHint" class="muted">首次按默认高度 0 生图,出图后可预览和调整发际线高度。</p>
|
||||
<div id="heightEditor" hidden>
|
||||
<p class="muted">勾选右侧档位后按所选高度生图。未勾选时,使用滑块当前高度。</p>
|
||||
<div class="preview-editor">
|
||||
<div id="previews" class="images" aria-label="52 号裁切发际线预览"></div>
|
||||
<div class="height-controls">
|
||||
<label for="height">高度 <output id="heightValue" for="height">0</output></label>
|
||||
<div class="height-scale">
|
||||
<input type="range" id="height" name="hairline_height" min="-1" max="1" step="0.1" value="0">
|
||||
<div class="height-presets" role="group" aria-label="预设高度(可多选)">
|
||||
<label><input type="checkbox" name="height_preset" value="1"> +1</label>
|
||||
<label><input type="checkbox" name="height_preset" value="0.5"> +0.5</label>
|
||||
<label><input type="checkbox" name="height_preset" value="0"> 0</label>
|
||||
<label><input type="checkbox" name="height_preset" value="-0.5"> −0.5</label>
|
||||
<label><input type="checkbox" name="height_preset" value="-1"> −1</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend>生发密度(至少选择一档)</legend>
|
||||
<div class="choices">
|
||||
<label><input type="checkbox" name="hair_density" value="sparse"> 稀疏</label>
|
||||
<label><input type="checkbox" name="hair_density" value="normal" checked> 正常</label>
|
||||
<label><input type="checkbox" name="hair_density" value="dense"> 浓密</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<button id="submit" type="submit">生成生发效果</button>
|
||||
<p id="status" class="status" role="status"></p>
|
||||
</form>
|
||||
<section id="results" class="result-grid" aria-label="生发结果"></section>
|
||||
<details class="card" id="response" hidden>
|
||||
<summary>响应 JSON(省略图片 Base64)</summary>
|
||||
<pre id="json"></pre>
|
||||
</details>
|
||||
</main>
|
||||
<script>
|
||||
const generateForm = document.getElementById('generateRequest');
|
||||
const sourceFields = document.getElementById('sourceFields');
|
||||
const firstRunHint = document.getElementById('firstRunHint');
|
||||
const heightEditor = document.getElementById('heightEditor');
|
||||
const previews = document.getElementById('previews');
|
||||
const height = document.getElementById('height');
|
||||
const heightPresets = generateForm.querySelectorAll('[name="height_preset"]');
|
||||
const styles = document.getElementById('styles');
|
||||
const styleFields = document.getElementById('styleFields');
|
||||
const generationPreviewStatus = document.getElementById('generationPreviewStatus');
|
||||
const statusBar = document.getElementById('status');
|
||||
const results = document.getElementById('results');
|
||||
const response = document.getElementById('response');
|
||||
const submit = document.getElementById('submit');
|
||||
const densityLabels = { sparse: '稀疏', normal: '正常', dense: '浓密' };
|
||||
const styleNames = {
|
||||
female: ['椭圆', '花瓣', '心形', '直线', '波浪', '大花瓣', '古典花瓣'],
|
||||
male: ['椭圆', '倒弧', 'M 形', '直线', '心形', '柔和花瓣', '波浪'],
|
||||
};
|
||||
let prepared;
|
||||
let previewRevision = 0;
|
||||
|
||||
function renderStyleChoices(container, gender, selected) {
|
||||
container.replaceChildren(...styleNames[gender].map((name, index) => {
|
||||
const label = document.createElement('label');
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.name = 'hair_style';
|
||||
checkbox.value = index + 1;
|
||||
checkbox.checked = selected.includes(checkbox.value);
|
||||
label.append(checkbox, ' ' + (index + 1) + '. ' + name);
|
||||
return label;
|
||||
}));
|
||||
}
|
||||
|
||||
function imageSource(url, base64, mime) {
|
||||
if (url) return url;
|
||||
if (!base64) return '';
|
||||
return base64.startsWith('data:') ? base64 : 'data:' + mime + ';base64,' + base64;
|
||||
}
|
||||
|
||||
function imagePanel(title, src, preview) {
|
||||
const figure = document.createElement('figure');
|
||||
const caption = document.createElement('figcaption');
|
||||
caption.textContent = title;
|
||||
figure.append(caption);
|
||||
if (!src) {
|
||||
figure.append(preview?.error || '未返回图片');
|
||||
return figure;
|
||||
}
|
||||
const stack = document.createElement('div');
|
||||
stack.className = 'stack';
|
||||
const image = new Image();
|
||||
image.src = src;
|
||||
image.alt = title;
|
||||
stack.append(image);
|
||||
if (preview) {
|
||||
const movingLayer = document.createElement('div');
|
||||
movingLayer.className = 'preview-layer';
|
||||
movingLayer.style.setProperty('--max-shift', preview.maxShift + '%');
|
||||
const clip = document.createElement('div');
|
||||
clip.className = 'preview-clip';
|
||||
const polygonPoints = preview.corners.map(([x, y]) =>
|
||||
(100 * x / preview.width) + '% ' + (100 * y / preview.height) + '%');
|
||||
clip.style.clipPath = 'polygon(' + polygonPoints.join(', ') + ')';
|
||||
const layer = new Image();
|
||||
layer.src = preview.overlay;
|
||||
layer.alt = '发际线';
|
||||
layer.className = 'overlay';
|
||||
clip.append(layer);
|
||||
movingLayer.append(clip);
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
const border = document.createElementNS(ns, 'svg');
|
||||
border.classList.add('crop-border');
|
||||
border.setAttribute('viewBox', '0 0 ' + preview.width + ' ' + preview.height);
|
||||
border.setAttribute('aria-label', '发际线裁切边界');
|
||||
const polygon = document.createElementNS(ns, 'polygon');
|
||||
polygon.setAttribute('points', preview.corners.map(point => point.join(',')).join(' '));
|
||||
border.append(polygon);
|
||||
movingLayer.append(border);
|
||||
stack.append(movingLayer);
|
||||
}
|
||||
figure.append(stack);
|
||||
return figure;
|
||||
}
|
||||
|
||||
async function loadCroppedPreviews(data, selected) {
|
||||
const formData = new FormData();
|
||||
for (const [key, value] of data) formData.set(key, value);
|
||||
formData.set('hair_style', selected.join(','));
|
||||
formData.set('normalize_image', 'true');
|
||||
formData.set('generate_grow_image', 'false');
|
||||
const reply = await window.hairClientFetch('/api/v1/hairline/generate-52', { method: 'POST', body: formData });
|
||||
const body = await reply.json();
|
||||
if (!reply.ok || body.code !== 0) {
|
||||
throw new Error(body.message || 'HTTP ' + reply.status);
|
||||
}
|
||||
const preview = body.data;
|
||||
const base = imageSource(preview.input_image_url, preview.input_image_base64, 'image/jpeg');
|
||||
const { width, height } = preview;
|
||||
const { corners, landmarks } = window.hairlineReferenceBox(preview.guide_landmarks);
|
||||
// Convert the interface 22 scale (10% of brow-to-chin distance) to a percentage of the image height.
|
||||
const maxShift = 100 * 0.10 * Math.abs(landmarks[152][1] - landmarks[9][1]) / height;
|
||||
const items = preview.hairline_images.map(item => {
|
||||
const overlay = imageSource(item.image_middle_url, item.image_middle_base64, 'image/png');
|
||||
return { order: item.order, ...((base && overlay)
|
||||
? { base, corners, width, height, maxShift, overlay }
|
||||
: { error: '接口 52 未返回完整预览图片' }) };
|
||||
});
|
||||
return { items, framing: preview.framing };
|
||||
}
|
||||
|
||||
function makePreviewPanels(items, gender) {
|
||||
const names = styleNames[gender];
|
||||
return new Map(items.filter(item => item.base).map(item => [
|
||||
String(item.order), imagePanel('#' + item.order + ' · ' + names[item.order - 1], item.base, item),
|
||||
]));
|
||||
}
|
||||
|
||||
async function replacePreviewBases(images, panels, framing, fallback = false) {
|
||||
const byOrder = new Map(images.map(item => [String(item.order), item]));
|
||||
const targets = [...panels].flatMap(([order, panel]) => {
|
||||
const image = byOrder.get(order) || (fallback ? images[0] : undefined);
|
||||
return image ? [[image, panel]] : [];
|
||||
});
|
||||
if (!targets.length) return 0;
|
||||
const { source_to_processed: matrix, source_size: source, output_size: output } = framing || {};
|
||||
if (!matrix || !source || !output) return targets.length;
|
||||
const [[a, c, e], [b, d, f]] = matrix;
|
||||
const updated = await Promise.all(targets.map(async ([item, panel]) => {
|
||||
const base = panel.querySelector('.stack > img');
|
||||
if (!base) return false;
|
||||
try {
|
||||
const generated = new Image();
|
||||
generated.src = imageSource(item.grown_image_url, item.grown_image_base64, 'image/jpeg');
|
||||
await generated.decode();
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = output.width;
|
||||
canvas.height = output.height;
|
||||
canvas.setAttribute('role', 'img');
|
||||
canvas.setAttribute('aria-label', base.alt + ' · 默认高度生发底图');
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
ctx.setTransform(a, b, c, d, e, f);
|
||||
ctx.drawImage(generated, 0, 0, source.width, source.height);
|
||||
base.replaceWith(canvas);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
return updated.filter(ok => !ok).length;
|
||||
}
|
||||
|
||||
function renderResult(item, submittedHeight, gender) {
|
||||
const tile = document.createElement('figure');
|
||||
tile.className = 'result-tile';
|
||||
const image = new Image();
|
||||
image.loading = 'lazy';
|
||||
image.src = imageSource(item.grown_image_url, item.grown_image_base64, 'image/jpeg');
|
||||
const name = styleNames[gender][item.order - 1] || item.hairline_type;
|
||||
const style = '#' + item.order + ' ' + name + ' · ' + (gender === 'male' ? '男性' : '女性');
|
||||
const details = (densityLabels[item.hair_density] || item.hair_density) + ' · 高度 ' + submittedHeight;
|
||||
image.alt = style + ',' + details;
|
||||
const caption = document.createElement('figcaption');
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = style;
|
||||
const parameters = document.createElement('span');
|
||||
parameters.textContent = details;
|
||||
caption.append(title, parameters);
|
||||
tile.append(image, caption);
|
||||
return tile;
|
||||
}
|
||||
|
||||
async function requestGrowth(data) {
|
||||
const reply = await window.hairClientFetch('/api/v1/hair/grow-22', { method: 'POST', body: data });
|
||||
const body = await reply.json();
|
||||
if (!reply.ok || body.code !== 0) {
|
||||
throw new Error(body.message || 'HTTP ' + reply.status);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function updatePreviewHeight() {
|
||||
document.getElementById('heightValue').value = height.value;
|
||||
previews.style.setProperty('--height-adjustment', height.value);
|
||||
}
|
||||
|
||||
height.addEventListener('input', updatePreviewHeight);
|
||||
heightPresets.forEach(preset => preset.addEventListener('change', () => {
|
||||
if (preset.checked) {
|
||||
height.value = preset.value;
|
||||
updatePreviewHeight();
|
||||
}
|
||||
}));
|
||||
updatePreviewHeight();
|
||||
function renderSourceStyles() {
|
||||
renderStyleChoices(styles, generateForm.elements.gender.value, ['1']);
|
||||
}
|
||||
|
||||
generateForm.elements.gender.addEventListener('change', renderSourceStyles);
|
||||
renderSourceStyles();
|
||||
|
||||
sourceFields.addEventListener('change', () => {
|
||||
prepared = undefined;
|
||||
heightEditor.hidden = true;
|
||||
firstRunHint.hidden = false;
|
||||
previews.replaceChildren();
|
||||
results.replaceChildren();
|
||||
response.hidden = true;
|
||||
statusBar.textContent = '';
|
||||
generationPreviewStatus.textContent = '';
|
||||
submit.disabled = false;
|
||||
height.value = '0';
|
||||
heightPresets.forEach(preset => { preset.checked = false; });
|
||||
updatePreviewHeight();
|
||||
});
|
||||
|
||||
async function refreshStylePreviews(selected) {
|
||||
const current = prepared;
|
||||
if (!current) return;
|
||||
const revision = ++previewRevision;
|
||||
const showSelected = () => previews.replaceChildren(...selected.flatMap(order =>
|
||||
current.panels.has(order) ? [current.panels.get(order)] : []));
|
||||
showSelected();
|
||||
generationPreviewStatus.dataset.error = 'false';
|
||||
generationPreviewStatus.textContent = selected.length ? '' : '请至少选择一种发际线形状。';
|
||||
const missing = selected.filter(order => !current.panels.has(order));
|
||||
submit.disabled = !selected.length || !!missing.length;
|
||||
if (!missing.length) return;
|
||||
generationPreviewStatus.textContent = '正在更新发际线预览…';
|
||||
try {
|
||||
const { items, framing } = await loadCroppedPreviews(current.data, missing);
|
||||
const panels = makePreviewPanels(items, current.data.get('gender'));
|
||||
const unmatched = await replacePreviewBases(current.defaultImages, panels, framing, true);
|
||||
if (current !== prepared || revision !== previewRevision) return;
|
||||
for (const [order, panel] of panels) current.panels.set(order, panel);
|
||||
showSelected();
|
||||
if (missing.some(order => !panels.has(order))) {
|
||||
throw new Error('接口 52 未返回全部所选形状。');
|
||||
}
|
||||
generationPreviewStatus.textContent = unmatched ? '部分生成图无法对齐,已显示对应的裁切原照。' : '';
|
||||
} catch (error) {
|
||||
if (current !== prepared || revision !== previewRevision) return;
|
||||
generationPreviewStatus.dataset.error = 'true';
|
||||
generationPreviewStatus.textContent = '更新发际线预览失败:' + error.message;
|
||||
} finally {
|
||||
if (current === prepared && revision === previewRevision) submit.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
styles.addEventListener('change', () => {
|
||||
refreshStylePreviews(new FormData(generateForm).getAll('hair_style'));
|
||||
});
|
||||
|
||||
generateForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (submit.disabled) return;
|
||||
const data = new FormData(generateForm);
|
||||
const selectedStyles = data.getAll('hair_style');
|
||||
if (!selectedStyles.length) {
|
||||
statusBar.dataset.error = 'true';
|
||||
statusBar.textContent = '请至少选择一种发际线形状。';
|
||||
return;
|
||||
}
|
||||
const densities = data.getAll('hair_density');
|
||||
if (!densities.length) {
|
||||
statusBar.dataset.error = 'true';
|
||||
statusBar.textContent = '请至少选择一档生发密度。';
|
||||
return;
|
||||
}
|
||||
const selectedHeights = data.getAll('height_preset');
|
||||
const firstRun = !prepared;
|
||||
const submittedHeights = firstRun ? ['0'] : (selectedHeights.length ? selectedHeights : [data.get('hairline_height')]);
|
||||
data.delete('height_preset');
|
||||
data.set('hair_style', selectedStyles.join(','));
|
||||
sourceFields.disabled = styleFields.disabled = submit.disabled = true;
|
||||
statusBar.dataset.error = 'false';
|
||||
results.replaceChildren();
|
||||
response.hidden = true;
|
||||
const reports = [];
|
||||
const failedHeights = [];
|
||||
const defaultImages = new Map();
|
||||
const planned = submittedHeights.length * densities.length * selectedStyles.length;
|
||||
let generated = 0;
|
||||
const start = performance.now();
|
||||
try {
|
||||
statusBar.textContent = '正在准备照片…';
|
||||
const file = firstRun
|
||||
? await window.downscaleImageFile(data.get('image_file'))
|
||||
: prepared.data.get('image_file');
|
||||
data.set('image_file', file);
|
||||
for (const [index, submittedHeight] of submittedHeights.entries()) {
|
||||
statusBar.textContent = '正在生成 ' + (index + 1) + '/' + submittedHeights.length + ' 档 · 高度 ' + submittedHeight + '…';
|
||||
// The slider is positive up, while the API uses positive image Y (down).
|
||||
data.set('hairline_height', String(-Number(submittedHeight)));
|
||||
try {
|
||||
const body = await requestGrowth(data);
|
||||
const images = body.data.results.filter(item => item.grown_image_url || item.grown_image_base64);
|
||||
results.append(...images.map(item => renderResult(item, submittedHeight, data.get('gender'))));
|
||||
generated += images.length;
|
||||
if (firstRun) {
|
||||
for (const item of images) {
|
||||
if (!defaultImages.has(item.order) || item.hair_density === 'normal') {
|
||||
const { order, grown_image_url, grown_image_base64 } = item;
|
||||
defaultImages.set(order, { order, grown_image_url, grown_image_base64 });
|
||||
}
|
||||
}
|
||||
}
|
||||
reports.push(JSON.stringify({ height: Number(submittedHeight), response: body }, (key, value) =>
|
||||
key.endsWith('_base64') && typeof value === 'string' ? '[图片已省略]' : value, 2));
|
||||
} catch (error) {
|
||||
failedHeights.push(submittedHeight);
|
||||
reports.push(JSON.stringify({ height: Number(submittedHeight), error: error.message }, null, 2));
|
||||
}
|
||||
}
|
||||
document.getElementById('json').textContent = '[\n' + reports.join(',\n') + '\n]';
|
||||
response.hidden = false;
|
||||
const seconds = ((performance.now() - start) / 1000).toFixed(1);
|
||||
statusBar.dataset.error = String(generated !== planned);
|
||||
statusBar.textContent = '生发成功 ' + generated + '/' + planned + ' 张 · ' + seconds + ' 秒'
|
||||
+ (failedHeights.length ? ' · 请求失败的高度 ' + failedHeights.join('、') : '');
|
||||
if (firstRun && generated) {
|
||||
const source = new FormData();
|
||||
source.set('image_file', file);
|
||||
source.set('gender', data.get('gender'));
|
||||
prepared = { data: source, panels: new Map(), defaultImages: [...defaultImages.values()] };
|
||||
heightEditor.hidden = false;
|
||||
firstRunHint.hidden = true;
|
||||
await refreshStylePreviews(selectedStyles);
|
||||
}
|
||||
} catch (error) {
|
||||
statusBar.dataset.error = 'true';
|
||||
statusBar.textContent = '生发失败:' + error.message;
|
||||
} finally {
|
||||
sourceFields.disabled = styleFields.disabled = submit.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -14,7 +14,7 @@ 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"]
|
||||
PROTECTED = [FEATURES_42, "/api/v1/hairline/generate-52", "/api/v1/face/b-end-points", "/api/v1/hair/grow-22"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -33,7 +33,7 @@ def login(client):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", PROTECTED)
|
||||
def test_all_three_routes_reject_missing_static_and_refresh_tokens(client, monkeypatch, path):
|
||||
def test_protected_routes_reject_missing_static_and_refresh_tokens(client, monkeypatch, path):
|
||||
proxy = AsyncMock()
|
||||
features = AsyncMock()
|
||||
monkeypatch.setattr(gateway, "_proxy", proxy)
|
||||
@@ -99,7 +99,7 @@ def test_features42_combines_processed_photo_with_main_features(client, monkeypa
|
||||
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):
|
||||
def test_bearer_requirement_is_scoped_to_protected_business_routes_and_check(client):
|
||||
schema = client.get("/openapi.json").json()
|
||||
protected = {
|
||||
path
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Gateway-only interface 52 integration: raw forwarding, token and image URLs."""
|
||||
"""Gateway integration for interfaces 52 and 22: raw forwarding, token and image URLs."""
|
||||
import asyncio
|
||||
import base64
|
||||
import importlib
|
||||
import io
|
||||
from contextlib import closing
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
@@ -15,6 +16,9 @@ from gateway import config, forward
|
||||
|
||||
gateway = importlib.import_module("gateway.app")
|
||||
PATH = "/api/v1/hairline/generate-52"
|
||||
GROW22 = "/api/v1/hair/grow-22"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_headers(monkeypatch):
|
||||
monkeypatch.setenv("HAIR_API_TOKEN_SECRET", "gateway-test-signing-secret-at-least-32")
|
||||
@@ -167,3 +171,66 @@ def test_public_interface52_page_has_no_internal_password():
|
||||
assert client.get("/static/hairline_reference_box.js").status_code == 200
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_interface22_forwards_repeated_density_fields_and_serves_image_urls(
|
||||
proxy_setup, monkeypatch, client_headers,
|
||||
):
|
||||
cfg, acquire, release, release_worker, unhealthy = proxy_setup
|
||||
png, jpg = image_bytes("PNG"), image_bytes("JPEG")
|
||||
calls = []
|
||||
|
||||
def worker(request):
|
||||
calls.append(request)
|
||||
return httpx.Response(200, json={"code": 0, "data": {"results": [
|
||||
{"order": 1, "hairline_type": "ellipse", "hair_density": "sparse",
|
||||
"image_base64": base64.b64encode(png).decode(),
|
||||
"grown_image_base64": base64.b64encode(jpg).decode()},
|
||||
{"order": 1, "hairline_type": "ellipse", "hair_density": "dense",
|
||||
"image_base64": base64.b64encode(png).decode(), "grown_image_base64": None},
|
||||
]}})
|
||||
|
||||
upstream = httpx.AsyncClient(transport=httpx.MockTransport(worker))
|
||||
monkeypatch.setattr(forward, "get_client", lambda: upstream)
|
||||
source = httpx.Request(
|
||||
"POST", "http://testserver" + GROW22,
|
||||
files={"image_file": ("face.png", png, "image/png")},
|
||||
data={"gender": "female", "hair_style": "1", "hairline_height": "-0.5",
|
||||
"hair_density": ["sparse", "dense"]},
|
||||
)
|
||||
raw = source.read()
|
||||
assert raw.count(b'name="hair_density"') == 2
|
||||
try:
|
||||
with closing(TestClient(gateway.app)) as client:
|
||||
response = client.post(GROW22, content=raw, headers={
|
||||
**client_headers, "Content-Type": source.headers["content-type"],
|
||||
})
|
||||
assert response.status_code == 200
|
||||
sparse, dense = response.json()["data"]["results"]
|
||||
assert [sparse["hair_density"], dense["hair_density"]] == ["sparse", "dense"]
|
||||
assert client.get(sparse["image_url"]).content == png
|
||||
assert client.get(sparse["grown_image_url"]).content == jpg
|
||||
assert dense["grown_image_base64"] is None
|
||||
finally:
|
||||
asyncio.run(upstream.aclose())
|
||||
assert len(calls) == 1
|
||||
assert str(calls[0].url) == "http://worker" + GROW22
|
||||
assert calls[0].content == raw
|
||||
assert calls[0].headers["x-internal-token"] == cfg["shared_password"]
|
||||
assert "authorization" not in calls[0].headers
|
||||
acquire.assert_awaited_once_with(cfg, 0)
|
||||
release.assert_called_once()
|
||||
release_worker.assert_awaited_once()
|
||||
unhealthy.assert_not_awaited()
|
||||
|
||||
|
||||
def test_interface22_page_uses_gateway_login_and_authenticated_requests():
|
||||
with closing(TestClient(gateway.app)) as client:
|
||||
page = client.get("/static/test_interface22.html")
|
||||
assert page.status_code == 200
|
||||
assert 'id="client-auth"' in page.text
|
||||
for path in (GROW22, PATH):
|
||||
assert f"window.hairClientFetch('{path}'" in page.text
|
||||
assert "X-Internal-Token" not in page.text
|
||||
assert "localhost:8187" not in page.text
|
||||
assert client.get("/static/client_auth.js").status_code == 200
|
||||
|
||||
Reference in New Issue
Block a user