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 判定生成特征 |
|
| 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。
|
||||||
|
|||||||
@@ -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 端生发接口
|
||||||
|
|
||||||
**说明**:医生/操作端在用户照片上用马克笔标注目标发际线后,**只需上传这一张划线图**。系统检测划线 →
|
**说明**:医生/操作端在用户照片上用马克笔标注目标发际线后,**只需上传这一张划线图**。系统检测划线 →
|
||||||
|
|||||||
@@ -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端生发(已屏蔽)"""
|
||||||
|
|||||||
@@ -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
@@ -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 <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">
|
<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. 生发密度与发际线高度 <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 ======== -->
|
<!-- ======== 接口3 ======== -->
|
||||||
<div class="card" id="if3">
|
<div class="card" id="if3">
|
||||||
<h2>3. B端生发(医生端) <span class="badge post">POST</span> <code>/api/v1/hair/grow-b</code></h2>
|
<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><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>
|
||||||
|
|||||||
@@ -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"
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user