feat(gateway): 接入接口22与账号鉴权测试页

This commit is contained in:
xsl
2026-11-09 21:42:16 +08:00
parent 5eca74c9c0
commit 6d003dbf80
8 changed files with 588 additions and 12 deletions
+6 -3
View File
@@ -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 判定生成特征 | | 42 | `POST /api/v1/face/features-42` | 网关按接口 52 的规则扶正裁切,再用接口 4 的分析结果和 Ft/Zy 判定生成特征 |
| 52 | `POST /api/v1/hairline/generate-52` | 网关转发至 worker | | 52 | `POST /api/v1/hairline/generate-52` | 网关转发至 worker |
| 72 | `POST /api/v1/face/b-end-points` | 网关转发至 worker,底图与标注层转成 URL | | 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 改写流程。 网关保留原有 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。
+16
View File
@@ -17,6 +17,7 @@
| 1 四庭七眼测量 | POST | `/api/v1/face/measure` | | 1 四庭七眼测量 | POST | `/api/v1/face/measure` |
| 6 四庭七眼测量 v2 | POST | `/api/v1/face/measure-v2` | | 6 四庭七眼测量 v2 | POST | `/api/v1/face/measure-v2` |
| 2 C 端生发 | POST | `/api/v1/hair/grow` | | 2 C 端生发 | POST | `/api/v1/hair/grow` |
| 22 生发密度与发际线高度 | POST | `/api/v1/hair/grow-22` |
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` | | 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
| 4 用户特征 | POST | `/api/v1/face/features` | | 4 用户特征 | POST | `/api/v1/face/features` |
| 42 用户特征与两项判定 | POST | `/api/v1/face/features-42` | | 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 端生发接口 ## 接口 3:B 端生发接口
**说明**:医生/操作端在用户照片上用马克笔标注目标发际线后,**只需上传这一张划线图**。系统检测划线 → **说明**:医生/操作端在用户照片上用马克笔标注目标发际线后,**只需上传这一张划线图**。系统检测划线 →
+9
View File
@@ -250,6 +250,7 @@ async def index():
"test_pages": { "test_pages": {
"if1_measure": "/static/test_interface1.html", "if1_measure": "/static/test_interface1.html",
"if2_hair_grow": "/static/test_interface2.html", "if2_hair_grow": "/static/test_interface2.html",
"if22_hair_grow": "/static/test_interface22.html",
"if3_hair_grow_b": "/static/test_interface3.html", "if3_hair_grow_b": "/static/test_interface3.html",
"if4_features": "/static/test_interface4.html", "if4_features": "/static/test_interface4.html",
"if42_features": "/static/test_interface42.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") 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) @app.post("/api/v1/hair/grow-b", tags=["生发"], deprecated=True)
async def hair_grow_b(request: Request): async def hair_grow_b(request: Request):
"""接口3:B端生发(已屏蔽)""" """接口3:B端生发(已屏蔽)"""
+1 -1
View File
@@ -149,7 +149,7 @@ async def client_auth_error(credentials: HTTPAuthorizationCredentials | None = S
@router.get("/check", summary="验证当前访问令牌") @router.get("/check", summary="验证当前访问令牌")
async def check(auth_error=Depends(client_auth_error)): async def check(auth_error=Depends(client_auth_error)):
"""检查与 42、52、72 相同的 Bearer 令牌,无需提交图片。""" """检查受保护业务接口使用的 Bearer 令牌。"""
if auth_error is not None: if auth_error is not None:
return auth_error return auth_error
return _response(0, "访问令牌有效") return _response(0, "访问令牌有效")
+19 -4
View File
@@ -55,9 +55,10 @@
<div class="toc"> <div class="toc">
<a href="#overview">通用约定</a> <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="#if1">接口1</a>
<a href="#if2">接口2</a> <a href="#if2">接口2</a>
<a href="#if22">接口22</a>
<a href="#if3">接口3</a> <a href="#if3">接口3</a>
<a href="#if4">接口4</a> <a href="#if4">接口4</a>
<a href="#if42">接口42</a> <a href="#if42">接口42</a>
@@ -78,7 +79,7 @@
<tr><td>请求方式</td><td>业务接口使用 POST。验证令牌使用 GET。</td></tr> <tr><td>请求方式</td><td>业务接口使用 POST。验证令牌使用 GET。</td></tr>
<tr><td>编码</td><td>UTF-8</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>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>三选一:<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>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> <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>
<div class="card" id="client-auth"> <div class="card" id="client-auth">
<h2>42/52/72 客户端鉴权</h2> <h2>22/42/52/72 客户端鉴权</h2>
<div class="card-body"> <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 &lt;access_token&gt;</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 &lt;access_token&gt;</code>。</p>
<div class="guide-step"> <div class="guide-step">
<h3>1. 登录获取令牌</h3> <h3>1. 登录获取令牌</h3>
@@ -219,6 +220,19 @@ const { code, data } = await res.json();
</div> </div>
</div> </div>
<div class="card" id="if22">
<h2>22. 生发密度与发际线高度 &nbsp;<span class="badge post">POST</span> &nbsp;<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 ======== --> <!-- ======== 接口3 ======== -->
<div class="card" id="if3"> <div class="card" id="if3">
<h2>3. B端生发(医生端)&nbsp;<span class="badge post">POST</span> &nbsp;<code>/api/v1/hair/grow-b</code></h2> <h2>3. B端生发(医生端)&nbsp;<span class="badge post">POST</span> &nbsp;<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><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>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>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>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>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> <tr><td>42. 用户特征与两项判定</td><td><a href="/static/test_interface42.html" class="link">/static/test_interface42.html</a></td><td>登录、测试鉴权、刷新令牌、上传照片</td></tr>
+466
View File
@@ -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>
+3 -3
View File
@@ -14,7 +14,7 @@ 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" 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 @pytest.fixture
@@ -33,7 +33,7 @@ def login(client):
@pytest.mark.parametrize("path", PROTECTED) @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() proxy = AsyncMock()
features = AsyncMock() features = AsyncMock()
monkeypatch.setattr(gateway, "_proxy", proxy) 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) 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() schema = client.get("/openapi.json").json()
protected = { protected = {
path path
+68 -1
View File
@@ -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 asyncio
import base64 import base64
import importlib import importlib
import io import io
from contextlib import closing
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
@@ -15,6 +16,9 @@ from gateway import config, forward
gateway = importlib.import_module("gateway.app") gateway = importlib.import_module("gateway.app")
PATH = "/api/v1/hairline/generate-52" PATH = "/api/v1/hairline/generate-52"
GROW22 = "/api/v1/hair/grow-22"
@pytest.fixture @pytest.fixture
def client_headers(monkeypatch): def client_headers(monkeypatch):
monkeypatch.setenv("HAIR_API_TOKEN_SECRET", "gateway-test-signing-secret-at-least-32") 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 assert client.get("/static/hairline_reference_box.js").status_code == 200
finally: finally:
client.close() 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