feat(gateway): 接入42和72并为42/52/72启用可刷新令牌

This commit is contained in:
xsl
2026-11-07 20:50:22 +08:00
parent dc53a0ecfe
commit 51baf96d78
24 changed files with 1312 additions and 126 deletions
+1
View File
@@ -7,6 +7,7 @@
|------|------|
| [实现说明.md](实现说明.md) | **实现总览**:架构、五个接口怎么实现、base64→URL 映射、错误码、部署/环境要点。先读这份。 |
| [接口文档.md](接口文档.md) | 对外 API 契约(字段 / 错误码)。字段命名的唯一权威。 |
| [42/52/72 网关鉴权](接口42-52-72网关鉴权.md) | 登录、过期、刷新、令牌签名配置与测试页。 |
| [接口52网关接入.md](接口52网关接入.md) | 52号请求透传、图片URL与空字段约定、公开测试页及网关部署步骤。 |
| [旷视具体需求.md](旷视具体需求.md) | 原始需求。 |
| [../OFFLINE_ASSETS.md](../OFFLINE_ASSETS.md) | worker 离线模型权重/字体清单(内网部署)。 |
+1 -1
View File
@@ -99,7 +99,7 @@
- **怎么实现**:复用接口2 的 502 点渲染管线,三档分别用 `hairline_texture[/_high|/_low]` 同名贴图渲染叠图;生发同接口2(ComfyUI inpaint),**黑模板固定取 `hairline_texture_black/`(middle)**,每发型 1 张生发图。`best_hairline_center_point`=眉心 x × 首个选中发型 middle 档发际线 y。
### 接口52 发际线新规则 `/api/v1/hairline/generate-52`(网关透传)
- **客户端鉴权**:请求头`X-API-Token`,默认`yonghe1999`,可由`gateway/config.json`的`interface52_token`覆盖;不匹配返回HTTP401/code1009且不进入队列。客户端Token不传给worker,内部Token仍由网关添加。
- **客户端鉴权**:登录后发送 `Authorization: Bearer <access_token>`。校验失败返回 HTTP 401、`code=1009`,不进入队列。详见[客户端鉴权](接口42-52-72网关鉴权.md)。
- `main`新增网关路由和`static/test_interface52.html`。GPU worker负责中下庭均值定位、扶正裁切及生发;主备worker均需部署52号实现,网关只透传表单并附加内部Token。
- 非空图片使用原有`*_base64`→`*_url`落盘改写;空图片保留`*_base64: null`。metadata和坐标原样返回,middle叠加到`input_image_url`。旧接口、5号队列优先级及网关IP策略保持原样。
- 参数、空字段与部署步骤见[接口52网关接入](接口52网关接入.md)。worker分支的IP限制与算法不合入本次网关改动。
+35
View File
@@ -0,0 +1,35 @@
# 42、52、72 号接口网关鉴权
| 接口 | 方法与路径 | 执行位置 |
|---|---|---|
| 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。
## 登录
`POST /api/v1/auth/login`,请求体为 JSON,包含 `username` 和 `password`。当前用户名为 `yonghe`,密码使用约定的服务端固定值。
成功时 HTTP 200、`code=0`,`data` 包含:
| 字段 | 值 |
|---|---|
| `access_token` | 访问令牌 |
| `token_type` | `Bearer` |
| `expires_in` | 900 秒 |
| `refresh_token` | 刷新令牌 |
| `refresh_expires_in` | 604800 秒,即 7 天 |
## 刷新
`POST /api/v1/auth/refresh`,请求体为 `{"refresh_token":"登录返回的刷新令牌"}`。成功后返回新的 `access_token`、`token_type` 和 `expires_in`。登录时的刷新令牌可以在原有效期内继续使用。到期后重新登录。登录或刷新失败时,响应包含 `code`、`message`、`request_id` 和 `data: null`。
## 网关配置
在 `main` 的网关环境中设置 `HAIR_API_TOKEN_SECRET`,或在未跟踪的 `gateway/config.json` 中设置 `api_token_secret`。使用独立且稳定的至少 32 字节随机值。签名密钥缺失时,签发或刷新返回 HTTP 503 和业务码 `1007`。更换签名密钥会使已签发令牌失效。
网关保留原有 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`,页面先登录再提交,访问令牌到期时自动刷新。
+5 -5
View File
@@ -7,16 +7,16 @@
客户端 → `POST /api/v1/hairline/generate-52` → 网关现有队列和worker池 → worker同路径 → 网关落盘非空图片并返回公网URL。
- 请求体原样转发,网关附加配置中的`X-Internal-Token`;客户端和公开测试页不携带内部密码。
- 客户端须通过`X-API-Token`传入52号静态Token,默认`yonghe1999`。网关校验后才进入队列;该Token不转发给worker,与内部密码独立。
- 客户端须登录取得访问令牌,通过 `Authorization: Bearer <access_token>` 发送。网关鉴权后进入队列。
- 沿用现有主备派发、隔离期、重试、日志和全局并发限制。原有5号插队优先级保持原样,52号走默认队列。
- 5号`/api/v1/hairline/generate`和其他接口保持原样;网关不新增IP限制。
- worker的中下庭规则、扶正、3:4裁切和生发结果由同一次worker响应提供,网关不重新计算坐标。
## 请求
请求头必填:`X-API-Token: yonghe1999`。缺少、为空或不匹配时返回HTTP401,响应为`{"code":1009,"message":"未授权:缺少或错误的 X-API-Token","request_id":"gw-…","data":null}`,不读取转发表单、不占用GPU队列。其他接口不增加这项校验。
缺少、无效或过期的访问令牌返回 HTTP 401、`code=1009`。登录、刷新与部署配置见[客户端鉴权](接口42-52-72网关鉴权.md)。
可在`gateway/config.json`设置`"interface52_token": "自定义值"`;旧配置省略该项时自动使用`yonghe1999`。显式配置空值或非字符串会导致启动校验失败,不能用来关闭鉴权。配置修改后重启网关生效,测试页输入框改为相同值。
使用`multipart/form-data`;图片三选一:`image_file`、`image_url`、`image_base64`。必填`gender=male/female`和`hair_style=1..7`(支持逗号分隔多选)。
@@ -56,7 +56,7 @@ worker始终返回`*_base64`。网关沿用原有公共改写,不添加52号
```bash
curl -X POST https://hair.xiangsilian.com/api/v1/hairline/generate-52 \
-H 'X-API-Token: yonghe1999' \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-F 'image_file=@/path/to/photo.jpg' \
-F 'gender=male' -F 'hair_style=1' \
-F 'normalize_image=true' -F 'generate_grow_image=false'
@@ -64,7 +64,7 @@ curl -X POST https://hair.xiangsilian.com/api/v1/hairline/generate-52 \
成功时`code=0`,检查`input_image_url`和`hairline_images[].image_middle_url`可打开、尺寸一致;high/low仍为`*_base64: null`。访问测试页时无需输入内部Token。
测试页新增“客户端Token”输入框,默认`yonghe1999`,只作为`X-API-Token`请求头发送;自定义后端Token时应同步填写该值。
测试页先登录再提交图片,访问令牌过期时使用当前刷新令牌自动换取新的访问令牌。
## 验证
+89 -2
View File
@@ -19,6 +19,8 @@
| 2 C 端生发 | POST | `/api/v1/hair/grow` |
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
| 4 用户特征 | POST | `/api/v1/face/features` |
| 42 用户特征与两项判定 | POST | `/api/v1/face/features-42` |
| 72 B 端五个关键点 | POST | `/api/v1/face/b-end-points` |
| 5 发际线 PNG 生成 | POST | `/api/v1/hairline/generate` |
| 52 发际线新规则 | POST | `/api/v1/hairline/generate-52` |
@@ -87,7 +89,7 @@
| 1006 | 文件超出大小限制 | 单文件超过 1 MB |
| 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) |
| 1008 | 图片格式不支持 | 非 JPG / PNG |
| 1009 | 未授权 | worker内部`X-Internal-Token`错误;网关52号缺少或错误的客户端`X-API-Token`。HTTP401 |
| 1009 | 未授权 | worker内部`X-Internal-Token`错误;网关42/52/72号客户端 Bearer 令牌缺失、无效或过期。HTTP401 |
---
@@ -445,6 +447,91 @@
---
## 接口 72:B 端五个关键点
`POST /api/v1/face/b-end-points` 接受三选一图片参数 `image_file`、`image_url`、`image_base64`。复用接口 52 的扶正裁切与虹膜/眼宽厘米估算,返回坐标以处理后底图为准。使用[客户端 Bearer 鉴权](接口42-52-72网关鉴权.md)。
| 点号 | 位置 | 标注 |
|---|---|---|
| 1 | MediaPipe 4 | 白点 |
| 2 | HL center | 白点 |
| 3 | HL center 向画面右侧水平偏移 2 cm | 白点 |
| 4 | HL left | 白点 |
| 5 | HL left left | 白点 |
| 6 | MediaPipe 446 | 白点,兼作射线锚点 |
六个白点的直径相同。标注层包含三条从起点向目标方向延伸并逐渐变淡的白色虚线(2→1、4→6、5→6),以及 2→3 白色虚线段。2→1 射线比默认可见长度短 15%,5→6 射线的绘制长度比默认长度多 30%,可见范围以图像边界为限。四个距离文字分别放在 2–1 中点左侧、2–3 中点上方、4–6 中点左侧和 5–6 中点右侧,距离保留一位小数,2–3 固定显示 `2.0 CM`。
| `data` 字段 | 内容 |
|---|---|
| `input_image_url` | 扶正裁切后的底图 |
| `annotated_image_url` | 与底图同尺寸的透明 PNG 标注层 |
| `points` | 按字符串点号 `"1"` 到 `"6"` 索引的像素坐标 `{x, y}` |
| `measurements_cm` | 四个距离,以 `2-1`、`2-3`、`4-6`、`5-6` 索引 |
| `image_size` | 处理后尺寸 `{width, height}` |
| `coordinate_space` | `processed_image` |
| `px_per_cm` | 估算的每厘米像素数 |
Worker 直连时图片字段使用 `_base64` 后缀。检测不到人脸返回 `1001`,非正面或标注所需点位无法取得时返回 `1003`,图片无法解码返回 `1008`。
---
## 42号接口-加两个特征点
**请求** `POST /api/v1/face/features-42`,使用 `multipart/form-data`。
| 请求字段 | 类型 | 说明 |
|---|---|---|
| `image_file` | 文件 | 上传 JPG 或 PNG |
| `image_url` | 字符串 | 网关可访问的图片 URL |
| `image_base64` | 字符串 | 图片 Base64,支持带 `data:image/…;base64,` 前缀 |
以上三个字段必须且只能传一个。图片按 EXIF 方向解码后,先检查原图的人脸完整性和俯拍角度。俯拍使用头部姿态估算的正向 pitch,超过服务端环境变量 `FRONTAL_PITCH_THR`(默认 30°)时拦截。通过质检后复用接口 52 的扶正裁切规则,再用处理后的图片做面部特征分析。
客户端通过 Bearer 令牌鉴权,详见[客户端鉴权](接口42-52-72网关鉴权.md)。
### 质检失败与异常响应
以下业务响应的 HTTP 状态均为 `200`。
| `code` | 情况 | `message` |
|---|---|---|
| `1001` | 原图未检测到人脸 | 无法识别人像,请上传包含完整人脸的照片 |
| `1003` | 人脸关键点触及或超出图片边界 | 人脸不完整,请保留完整的脸部轮廓 |
| `1003` | 估算的俯拍角度超过阈值 | 俯拍角度过大,请将相机调整至与脸部平视 |
| `1003` | 扶正裁切失败,无法保留所需人脸关键点 | 人脸过于贴近边缘,无法扶正裁切 |
| `1008` | URL 图片下载失败 | 图片下载失败(URL 不可达) |
| `1008` | Base64 解码失败 | 图片格式不支持(base64 解码失败) |
| `1008` | 图片无法读取 | 图片格式不支持(仅 JPG / PNG) |
| `1007` | 图片字段未按三选一传入 | 图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个 |
| `1007` | 图片预处理内部异常 | 图片预处理失败 |
| `1001` | 质检通过,但视觉模型未识别到人脸 | 无法识别人像 |
| `1007` | 视觉模型调用异常 | 分析服务异常:… |
响应包含 `code`(整数业务码)、`message`(字符串提示)、`request_id`(字符串请求标识)和 `data`(失败时为 `null`)。三个 `1003` 情况可通过 `message` 区分,响应中没有独立的质检原因或角度字段。
```json
{
"code": 1003,
"message": "俯拍角度过大,请将相机调整至与脸部平视",
"request_id": "gw-xxxxxxxx",
"data": null
}
```
### 成功响应
`data.features` 仍为 JSON 字符串,保留 main 网关接口 4 的七项特征(含 `contour_type`),在其中新增:
| 字段 | 类型 | 说明 |
|------|------|------|
| `zygoma_expansion` | boolean 或 null | 颧弓外扩 |
| `temple_hollowing` | boolean 或 null | 太阳穴凹陷 |
Ft 两端保持横坐标并取平均高度,Zy 为 234 到 454 的距离。只有 Ft ÷ Zy 严格小于 0.8 时两项同时为 `true`,否则同时为 `false`。所需点位缺失或无法测量时两项均为 `null`。网关测量复用接口 99 的 MediaPipe 与 BiSeNet 依赖,裁切还需要 Pillow。
---
## 接口 5:发际线 PNG 生成接口
**说明**:入参同接口2(先选性别、再多选发型)。对每个选中发型返回 `middle` / `high` / `low` **三档**发际线叠图与**生发图**,并返回「最合适发际线」的面部中间点坐标。
@@ -574,7 +661,7 @@
**请求**:`POST /api/v1/hairline/generate-52`(网关透传至同路径GPU worker)
**客户端鉴权**:请求头必填`X-API-Token: yonghe1999`,缺少、为空或不匹配返回HTTP401/code1009,不进入worker队列。默认值可通过网关`gateway/config.json`的`interface52_token`覆盖,修改后重启网关;内部`X-Internal-Token`仍由网关注入,客户端无需提供。该校验仅用于网关52号。
**客户端鉴权**:使用 `Authorization: Bearer <access_token>`。登录、刷新和错误码见[客户端鉴权](接口42-52-72网关鉴权.md)。
**职责**:GPU worker返回base64并完成算法处理,网关负责透传参数、附加内部Token、保存非空图片及返回公网URL。网关不增加IP限制;网关出口IP须已在worker白名单内。部署与完整字段约定见[接口52网关接入](接口52网关接入.md)。
+1 -1
View File
@@ -7,7 +7,7 @@
## 接口52(2026-11-04 已接入main)
客户端新增请求头`X-API-Token`校验,默认`yonghe1999`,可在网关配置`interface52_token`覆盖;缺少或不匹配返回HTTP401/code1009,不进入队列。其他接口不增加该校验。
42、52、72 统一采用[客户端登录与刷新令牌](接口42-52-72网关鉴权.md)。校验失败返回 HTTP 401、`code=1009`。
新增`POST /api/v1/hairline/generate-52`,由网关转发到GPU worker同路径。5号保持原样,52号的`normalize_image`等参数原样透传;非空图片沿用公共base64→URL改写,空图片仍保留`*_base64: null`。不在网关增加IP限制,不合入worker算法。
+101
View File
@@ -0,0 +1,101 @@
"""Framed Ft and Zy measurements shared by interface 42 and the ratio preview."""
import math
FT_POINTS = ("hair_line_right_right", "hair_line_left_left")
ZY_POINTS = ("face_edge_234", "face_edge_454")
class PortraitQualityError(ValueError):
"""An input that must be rejected before feature analysis."""
def __init__(self, code, message):
super().__init__(message)
self.code = code
def prepare_feature_portrait(image_bytes):
"""Check the source image before applying interface 52 framing."""
import cv2
import numpy as np
from face_analysis.pose import PITCH_THRESHOLD, estimate_head_pose
from hairline.face_landmarks import get_landmarker, to_landmark_list
from hairline.framing import decode_portrait, normalize_portrait
image = decode_portrait(image_bytes)
if image is None:
raise PortraitQualityError(1008, "图片格式不支持(仅 JPG / PNG)")
landmarks = get_landmarker().detect(
cv2.cvtColor(image, cv2.COLOR_BGR2RGB), include_iris=True
)
if landmarks is None:
raise PortraitQualityError(1001, "无法识别人像,请上传包含完整人脸的照片")
h, w = image.shape[:2]
face_xy = landmarks[:468, :2] * [w, h]
if not np.isfinite(face_xy).all() or (
(face_xy <= 0).any() or (face_xy >= [w - 1, h - 1]).any()
):
raise PortraitQualityError(1003, "人脸不完整,请保留完整的脸部轮廓")
pose = estimate_head_pose(to_landmark_list(landmarks), w, h)
# In the camera coordinate system, positive pitch points the face down.
if pose is not None and pose[1] > PITCH_THRESHOLD:
raise PortraitQualityError(1003, "俯拍角度过大,请将相机调整至与脸部平视")
try:
image, landmarks, _ = normalize_portrait(image, landmarks)
except ValueError as exc:
raise PortraitQualityError(1003, "人脸过于贴近边缘,无法扶正裁切") from exc
return image, landmarks
def detect_framed_ratio_points(image):
"""Use the interface 52 crop and return the endpoints in its coordinate space."""
from hairline.framing import prepare_portrait
image, landmarks, framing = prepare_portrait(image)
data = _detect_ratio_points(image, landmarks)
data.update(framing=framing, coordinate_space="processed_image")
return image, data
def _detect_ratio_points(image, landmarks):
from hairline.face_landmarks import to_landmark_list
from .special_points import detect_special_points
data = detect_special_points(image, landmarks_override=to_landmark_list(landmarks))
points = data["points"]
data["ft_original_points"] = {key: points[key] for key in FT_POINTS}
a, b = (points[key] for key in FT_POINTS)
if a is not None and b is not None:
middle_y = (a["y"] + b["y"]) / 2
points[FT_POINTS[0]] = {**a, "y": middle_y}
points[FT_POINTS[1]] = {**b, "y": middle_y}
data["ft_alignment"] = "horizontal_at_mean_y"
return data
def analyze_ratio_features(image, landmarks) -> dict[str, bool | None]:
"""Return both labels from the same measurement of the processed image."""
from .special_points import NoFaceError, NonFrontalError
matched = None
try:
data = _detect_ratio_points(image, landmarks)
except (NoFaceError, NonFrontalError):
pass
else:
ft_left, ft_right, zy_left, zy_right = (
data["points"].get(key) for key in (*FT_POINTS, *ZY_POINTS)
)
if all(point is not None for point in (ft_left, ft_right, zy_left, zy_right)):
ft = abs(ft_left["x"] - ft_right["x"])
zy = math.hypot(zy_left["x"] - zy_right["x"], zy_left["y"] - zy_right["y"])
if ft > 0 and zy > 0:
matched = ft / zy < 0.8
return {"zygoma_expansion": matched, "temple_hollowing": matched}
+3 -3
View File
@@ -30,7 +30,7 @@ GROUPS = {
"mouth": [0, 37, 40, 67, 91, 84, 17, 405, 375, 409, 269],
"nose_midline": [9, 168, 197, 5, 4, 194, 278, 48, 437, 217],
"cheek": [101, 117, 123, 187, 205, 50, 330, 346, 352, 280, 425],
"face_edge": [162, 234, 58, 136, 149, 152, 378, 365, 288, 401, 366, 389, 251],
"face_edge": [162, 234, 58, 136, 149, 152, 378, 365, 288, 401, 366, 389, 251, 454],
}
# 发际线点定义:字段名 → 参与计算的 (起点ID, 方向点ID) 射线组;单射线直接取交点,
@@ -87,7 +87,7 @@ def ray_hairline_intersection(p1, p2, hair_mask):
return None
def detect_special_points(image_bgr):
def detect_special_points(image_bgr, landmarks_override=None):
"""主入口:图片 → {字段名: {"x","y"} 或 None} + 元信息。
Returns: {"points": {...}, "image_size": {"width","height"},
@@ -99,7 +99,7 @@ def detect_special_points(image_bgr):
from face_analysis.pose import check_frontal_face
h, w = image_bgr.shape[:2]
landmarks = detector.detect(image_bgr)
landmarks = landmarks_override if landmarks_override is not None else detector.detect(image_bgr)
if landmarks is None:
raise NoFaceError("未检出人脸")
if not check_frontal_face(landmarks, w, h):
+2
View File
@@ -177,6 +177,8 @@ def analyze_features(image_bytes: bytes = None, image_url: str = None):
return None
# 豆包 5 项 + 本地脸型覆盖
feats = {en: raw[zh] for zh, en in _KEY_MAP.items() if zh in raw}
if "facial_age" not in feats and "面部年龄区间" in raw:
feats["facial_age"] = raw["面部年龄区间"]
try:
feats["face_shape"] = _local_face_shape(image_bytes, image_url)
except ValueError as e:
+88 -24
View File
@@ -6,17 +6,17 @@
import asyncio
import base64
import hmac
import json
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, File, Form, Header, Request, UploadFile
from fastapi import Depends, FastAPI, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from gateway.client_auth import client_auth_error, router as client_auth_router
from gateway.config import load_config
from gateway.logging_middleware import (
get_available_dates,
@@ -91,6 +91,8 @@ app = FastAPI(
lifespan=lifespan,
)
app.include_router(client_auth_router)
# 静态文件托管(阶段四完善)
static_root = Path(__file__).resolve().parent.parent / "static"
static_root.mkdir(parents=True, exist_ok=True)
@@ -250,6 +252,8 @@ async def index():
"if2_hair_grow": "/static/test_interface2.html",
"if3_hair_grow_b": "/static/test_interface3.html",
"if4_features": "/static/test_interface4.html",
"if42_features": "/static/test_interface42.html",
"if72_b_end_points": "/static/test_interface72.html",
"if5_hairline": "/static/test_interface5.html",
"if52_hairline": "/static/test_interface52.html",
"if6_measure_v2": "/static/test_interface6.html",
@@ -596,6 +600,24 @@ async def face_features(
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"),
):
"""接口4:用户特征分析 — 豆包 6 项 + 本机 MediaPipe 脸型(face/),不经过 worker。"""
return await _face_features_response(request, image_file, image_url, image_base64)
@app.post("/api/v1/face/features-42", summary="42号接口:用户特征与两项判定", tags=["人脸分析"])
async def face_features_42(
request: Request,
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 Base64"),
auth_error=Depends(client_auth_error),
):
"""质检、扶正裁切后执行接口4分析,再加入颧弓外扩与太阳穴凹陷。"""
if auth_error is not None:
return auth_error
return await _face_features_response(request, image_file, image_url, image_base64, include_ratios=True)
async def _face_features_response(request, image_file, image_url, image_base64, *, include_ratios=False):
import uuid as _uuid
# 三选一校验
@@ -644,6 +666,25 @@ async def face_features(
from fastapi.concurrency import run_in_threadpool
from face_features import analyze_features
ratio_features = {}
if include_ratios:
from face_analysis.face_ratios import PortraitQualityError
try:
img_bytes, ratio_features = await _prepare_features_42(img_bytes, image_url)
except PortraitQualityError as ex:
return JSONResponse(status_code=200, content={
"code": ex.code, "message": str(ex),
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
except Exception:
logger.exception("接口42 图片预处理失败")
return JSONResponse(status_code=200, content={
"code": 1007, "message": "图片预处理失败",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
image_url = None
try:
feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
except Exception as ex:
@@ -663,10 +704,42 @@ async def face_features(
"code": 0,
"message": "success",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}",
"data": {"features": json.dumps(feats, ensure_ascii=False)},
"data": {"features": json.dumps({**feats, **ratio_features}, ensure_ascii=False)},
})
async def _prepare_features_42(image_bytes, image_url):
from fastapi.concurrency import run_in_threadpool
if image_url:
import httpx
try:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
response = await client.get(image_url)
response.raise_for_status()
image_bytes = response.content
except httpx.HTTPError as exc:
from face_analysis.face_ratios import PortraitQualityError
raise PortraitQualityError(1008, "图片下载失败(URL 不可达)") from exc
async with _if99_lock:
return await run_in_threadpool(_preprocess_and_measure_42, image_bytes)
def _preprocess_and_measure_42(image_bytes):
import cv2
from face_analysis.face_ratios import analyze_ratio_features, prepare_feature_portrait
image, landmarks = prepare_feature_portrait(image_bytes)
success, encoded = cv2.imencode(".png", image)
if not success:
raise RuntimeError("处理后图片编码失败")
ratios = analyze_ratio_features(image, landmarks)
return encoded.tobytes(), ratios
@app.post("/api/v1/hairline/generate", tags=["人脸分析"])
async def hairline_generate(request: Request):
"""接口5:发际线PNG生成"""
@@ -675,30 +748,21 @@ async def hairline_generate(request: Request):
@app.post("/api/v1/hairline/generate-52", tags=["人脸分析"],
summary="接口52:发际线新规则与人像裁切")
async def hairline_generate_52(
request: Request,
api_token: Optional[str] = Header(default=None, alias="X-API-Token",
description="52号接口客户端Token,默认yonghe1999"),
):
"""透传图片三选一、gender、hair_style及normalize_image等参数至GPU worker。
worker负责中下庭均值定位、扶正裁切和生发;网关沿用原有派发、鉴权与图片URL改写。
空图片保持原有 *_base64: null 字段。接口5保持原样。
客户端须传X-API-Token;缺少或不匹配时返回HTTP401/code1009,不进入派发队列。
"""
from uuid import uuid4
from gateway.config import DEFAULTS, get_config
expected = get_config().get("interface52_token", DEFAULTS["interface52_token"])
if (not isinstance(expected, str) or not expected or not api_token
or not hmac.compare_digest(api_token.encode("utf-8"), expected.encode("utf-8"))):
return JSONResponse(status_code=401, content={
"code": 1009, "message": "未授权:缺少或错误的 X-API-Token",
"request_id": f"gw-{uuid4().hex[:8]}", "data": None,
})
async def hairline_generate_52(request: Request, auth_error=Depends(client_auth_error)):
"""鉴权后透传图片、gender、hair_style 与 normalize_image 至 GPU worker。"""
if auth_error is not None:
return auth_error
return await _proxy(request, "/api/v1/hairline/generate-52")
@app.post("/api/v1/face/b-end-points", summary="72号接口:B 端五个关键点", tags=["人脸分析"])
async def face_b_end_points(request: Request, auth_error=Depends(client_auth_error)):
"""鉴权后转发到 worker,图片按网关规则返回 URL。"""
if auth_error is not None:
return auth_error
return await _proxy(request, "/api/v1/face/b-end-points")
@app.post("/api/v1/hair/grow-v2", tags=["生发"])
async def hair_grow_v2(request: Request):
"""接口7:C端生发 v2(add_hair2 工作流)"""
+147
View File
@@ -0,0 +1,147 @@
"""Expiring client credentials for the protected gateway interfaces."""
import base64
import binascii
import hashlib
import hmac
import json
import os
import secrets
import time
from uuid import uuid4
from fastapi import APIRouter, Security
from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field
from gateway.config import get_config
ACCESS_TTL = 15 * 60
REFRESH_TTL = 7 * 24 * 60 * 60
_USERNAME = "yonghe"
_PASSWORD = "yonghe1999"
router = APIRouter(prefix="/api/v1/auth", tags=["客户端鉴权"])
_bearer = HTTPBearer(auto_error=False, scheme_name="ClientBearer")
class LoginRequest(BaseModel):
username: str = Field(max_length=128)
password: str = Field(max_length=256)
class RefreshRequest(BaseModel):
refresh_token: str = Field(max_length=1024)
def _response(code, message, *, status=200, data=None, challenge=False):
headers = {"Cache-Control": "no-store"}
if challenge:
headers["WWW-Authenticate"] = "Bearer"
return JSONResponse(status_code=status, headers=headers, content={
"code": code, "message": message, "request_id": f"gw-{uuid4().hex[:8]}", "data": data,
})
def _secret():
value = os.getenv("HAIR_API_TOKEN_SECRET")
if value is None:
try:
value = get_config().get("api_token_secret")
except FileNotFoundError:
return None
if not isinstance(value, str) or len(value.encode("utf-8")) < 32:
return None
return value.encode("utf-8")
def _encode(raw):
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _decode(value):
return base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
def _issue(secret, kind, ttl):
payload = _encode(json.dumps({
"sub": _USERNAME,
"kind": kind,
"exp": int(time.time()) + ttl,
"nonce": secrets.token_urlsafe(16),
}, separators=(",", ":")).encode())
signature = hmac.digest(secret, payload.encode("ascii"), hashlib.sha256)
return payload + "." + _encode(signature)
def _valid(token, secret, kind):
if not token or len(token) > 1024:
return False
try:
payload, signature = token.split(".")
expected = _encode(hmac.digest(secret, payload.encode("ascii"), hashlib.sha256))
if not hmac.compare_digest(signature.encode("ascii"), expected.encode("ascii")):
return False
claims = json.loads(_decode(payload))
return (
isinstance(claims, dict)
and claims.get("sub") == _USERNAME
and claims.get("kind") == kind
and type(claims.get("exp")) is int
and time.time() < claims["exp"]
)
except (ValueError, UnicodeError, binascii.Error):
return False
def _unconfigured():
return _response(1007, "客户端令牌服务未配置", status=503)
def _unauthorized(message):
return _response(1009, message, status=401, challenge=True)
def _access_data(secret):
return {
"access_token": _issue(secret, "access", ACCESS_TTL),
"token_type": "Bearer",
"expires_in": ACCESS_TTL,
}
@router.post("/login", summary="用户名和密码换取访问及刷新令牌")
async def login(body: LoginRequest):
username_ok = hmac.compare_digest(body.username.encode("utf-8"), _USERNAME.encode("utf-8"))
password_ok = hmac.compare_digest(body.password.encode("utf-8"), _PASSWORD.encode("utf-8"))
if not (username_ok and password_ok):
return _unauthorized("用户名或密码错误")
secret = _secret()
if secret is None:
return _unconfigured()
data = _access_data(secret)
data.update(refresh_token=_issue(secret, "refresh", REFRESH_TTL), refresh_expires_in=REFRESH_TTL)
return _response(0, "success", data=data)
@router.post("/refresh", summary="刷新访问令牌")
async def refresh(body: RefreshRequest):
secret = _secret()
if secret is None:
return _unconfigured()
if not _valid(body.refresh_token, secret, "refresh"):
return _unauthorized("刷新令牌无效或已过期,请重新登录")
return _response(0, "success", data=_access_data(secret))
async def client_auth_error(credentials: HTTPAuthorizationCredentials | None = Security(_bearer)):
"""Return an error response for protected handlers before local work or dispatch."""
if credentials is None:
return _unauthorized("请提供 Authorization: Bearer 访问令牌")
secret = _secret()
if secret is None:
return _unconfigured()
if not _valid(credentials.credentials, secret, "access"):
return _unauthorized("访问令牌无效或已过期")
return None
+1 -1
View File
@@ -1,5 +1,5 @@
{
"interface52_token": "yonghe1999",
"api_token_secret": "",
"workers": [
"http://127.0.0.1:28187",
"http://127.0.0.1:28188"
+1 -5
View File
@@ -13,7 +13,7 @@ logger = logging.getLogger("gateway.config")
CONFIG_PATH = Path(__file__).resolve().parent / "config.json"
DEFAULTS = {
"interface52_token": "yonghe1999",
"api_token_secret": "",
"public_base_url": "https://hair.xiangsilian.com",
"static_dir": "static/annotations",
"primary_workers": [], # 主 worker 地址列表(须为 workers 的子集);空 = 全部视为主 worker
@@ -77,10 +77,6 @@ def load_config() -> dict:
cfg = _deep_merge(DEFAULTS, raw)
# --- 校验 ---
client_token = cfg["interface52_token"]
if not isinstance(client_token, str) or not client_token:
raise ValueError("配置错误: interface52_token 必须是非空字符串")
workers: List[str] = cfg.get("workers", [])
if not workers:
raise ValueError("配置错误: workers 列表不能为空,至少需要一个 worker 地址")
+1 -1
View File
@@ -223,7 +223,7 @@ def is_initialized() -> bool:
def _should_log(path: str) -> bool:
"""只记录 API 请求(/api/ 路径),跳过静态文件、健康检查等。"""
return path.startswith("/api/")
return path.startswith("/api/") and not path.startswith("/api/v1/auth/")
async def request_logging_middleware(request, call_next):
+25 -6
View File
@@ -44,18 +44,18 @@ class FaceLandmarker:
self._detector = vision.FaceLandmarker.create_from_options(options)
self._mp = mp
def detect(self, image_rgb: np.ndarray) -> np.ndarray | None:
"""Returns (468, 3) float32 of normalized x, y and relative z, or None.
def detect(self, image_rgb: np.ndarray, include_iris: bool = False) -> np.ndarray | None:
"""Returns (468, 3), or (478, 3) with iris, normalized float32 points.
The task model produces 478 landmarks (468 face + 10 iris); we return
only the first 468 to match the canonical FaceMesh topology used by
the SDK's OBJ file.
The task model produces 478 landmarks (468 face + 10 iris). By default
only the first 468 match the SDK's canonical FaceMesh topology;
include_iris=True retains all 478 for face-measurement calibration.
"""
mp_image = self._mp.Image(image_format=self._mp.ImageFormat.SRGB, data=image_rgb)
result = self._detector.detect(mp_image)
if not result.face_landmarks:
return None
lm = result.face_landmarks[0][:468]
lm = result.face_landmarks[0] if include_iris else result.face_landmarks[0][:468]
arr = np.array([[p.x, p.y, p.z] for p in lm], dtype=np.float32)
return arr
@@ -66,6 +66,25 @@ class FaceLandmarker:
pass
def to_landmark_list(landmarks):
from mediapipe.framework.formats import landmark_pb2
result = landmark_pb2.NormalizedLandmarkList()
for x, y, z in landmarks:
result.landmark.add(x=float(x), y=float(y), z=float(z))
return result
_landmarker = None
def get_landmarker() -> FaceLandmarker:
global _landmarker
if _landmarker is None:
_landmarker = FaceLandmarker(static_image_mode=True)
return _landmarker
class SolutionsFaceLandmarker:
"""CPU-oriented fallback using mediapipe.solutions.face_mesh."""
+231
View File
@@ -0,0 +1,231 @@
"""Interface 52: upright face axis, then native-resolution reference framing."""
from __future__ import annotations
from functools import lru_cache
from io import BytesIO
import json
from pathlib import Path
import cv2
import numpy as np
from PIL import Image, ImageOps
PROFILE_PATH = Path(__file__).parent / "reference/framing_profile.json"
def decode_portrait(raw):
"""Respect phone EXIF orientation before detecting or transforming points."""
try:
with Image.open(BytesIO(raw)) as image:
return cv2.cvtColor(np.array(ImageOps.exif_transpose(image).convert("RGB")), cv2.COLOR_RGB2BGR)
except Exception:
return None
@lru_cache(maxsize=1)
def load_profile():
return json.loads(PROFILE_PATH.read_text(encoding="utf-8"))
def transform_points(points, matrix):
return np.asarray(points, np.float64) @ matrix[:, :2].T + matrix[:, 2]
def _rotation(landmarks, width, height):
xy = landmarks[:, :2].astype(np.float64) * [width, height]
delta = xy[152] - xy[9]
length = float(np.linalg.norm(delta))
if not np.isfinite(xy).all() or length < 2 or delta[1] <= 0:
raise ValueError("9号点与152号点无效,无法扶正人脸")
down = delta / length
rotation = np.array([[down[1], -down[0]], [down[0], down[1]]], np.float64)
return xy, rotation, length
def _matrix(rotation, source_anchor, target_anchor):
return np.column_stack((rotation, np.asarray(target_anchor) - rotation @ source_anchor))
def crop_fits(matrix, source_size, output_size):
w, h = output_size
corners = transform_points([[0, 0], [w-1, 0], [w-1, h-1], [0, h-1]], cv2.invertAffineTransform(matrix))
sw, sh = source_size
return bool((corners[:, 0] >= -1e-7).all() and (corners[:, 0] <= sw-1+1e-7).all()
and (corners[:, 1] >= -1e-7).all() and (corners[:, 1] <= sh-1+1e-7).all())
def _inscribed_frame(rotation, source_size, aspect=None):
"""Largest centred valid rectangle: no generated background or upscaling."""
sw, sh = source_size
ratio = sw / sh if aspect is None else aspect
source_center = np.array([(sw-1)/2, (sh-1)/2])
low, high = 2.0, min(float(sh), sw/ratio)
for _ in range(48):
h = (low+high)/2
w = h*ratio
matrix = _matrix(rotation, source_center, [(w-1)/2, (h-1)/2])
if crop_fits(matrix, source_size, (w, h)):
low = h
else:
high = h
if aspect == .75:
h = max(4, int(low+1e-7)//4*4)
w = h//4*3
else:
h, w = max(2, int(low+1e-7)), max(2, int(low*ratio+1e-7))
matrix = _matrix(rotation, source_center, [(w-1)/2, (h-1)/2])
if not crop_fits(matrix, source_size, (w, h)):
raise ValueError("图片边缘空间不足,无法得到有效扶正画面")
return matrix, (w, h)
def _tall_portrait_frame(xy, rotation, source_size, profile):
"""Largest native 3:4 frame, positioned to retain the entire face."""
_, size = _inscribed_frame(rotation, source_size, aspect=.75)
half = (np.array(size, np.float64)-1)/2
extent = np.abs(rotation.T) @ half
low, high = extent, np.array(source_size)-1-extent
polygon = [np.array(p) for p in ((low[0], low[1]), (high[0], low[1]),
(high[0], high[1]), (low[0], high[1]))]
face = xy[:468] @ rotation.T
# The crop centre must satisfy both the source boundaries and the face
# bounds. Clip the feasible polygon in double precision to avoid padding.
face_low, face_high = face.max(axis=0)-half, face.min(axis=0)+half
for axis in range(2):
for normal, bound in ((rotation[axis], face_high[axis]),
(-rotation[axis], -face_low[axis])):
clipped = []
for start, end in zip(polygon, polygon[1:]+polygon[:1]):
a, b = float(normal @ start-bound-1e-8), float(normal @ end-bound-1e-8)
if (a <= 0) != (b <= 0):
clipped.append(start+(end-start)*(a/(a-b)))
if b <= 0:
clipped.append(end)
polygon = clipped
if not polygon:
raise ValueError("3:4裁切窗口无法保留完整人脸")
target = np.array([profile["brow_x_ratio"]*size[0], profile["brow_y_ratio"]*size[1]])
preferred = xy[9]-rotation.T @ (target-half)
# Euclidean projection keeps the brow as close as possible to its target.
candidates = []
if (preferred >= low-1e-8).all() and (preferred <= high+1e-8).all():
upright = rotation @ preferred
if (upright >= face_low-1e-8).all() and (upright <= face_high+1e-8).all():
candidates.append(preferred)
for start, end in zip(polygon, polygon[1:]+polygon[:1]):
edge = end-start
length2 = float(edge @ edge)
t = np.clip(float((preferred-start) @ edge)/length2, 0, 1) if length2 > 1e-16 else 0
candidates.append(start+t*edge)
centre = min(candidates, key=lambda p: float(np.sum((p-preferred)**2)))
matrix = _matrix(rotation, centre, half)
if not crop_fits(matrix, source_size, size):
raise ValueError("3:4裁切窗口超出原图有效范围")
return matrix, size
def _apply(photo, landmarks, matrix, size):
h, w = photo.shape[:2]
ow, oh = size
moved = np.asarray(landmarks, np.float64).copy()
moved[:, :2] = transform_points(landmarks[:, :2]*[w, h], matrix) / [ow, oh]
# MediaPipe Z is measured in normalized image-width units.
moved[:, 2] *= w/ow
result = cv2.warpAffine(photo, matrix, size, flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0))
return result, moved
def calibrate_reference(photo, landmarks):
"""First straighten the actual reference; derive ratios from its final frame."""
h, w = photo.shape[:2]
_xy, rotation, length = _rotation(landmarks, w, h)
matrix, size = _inscribed_frame(rotation, (w, h), aspect=.75)
image, moved = _apply(photo, landmarks, matrix, size)
ow, oh = size
b, c = moved[[9, 152], :2]*[ow, oh]
profile = {
"version": "2026-11-04-upright-9-152-subject-90pct-tall-3x4",
"source_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/misc/20261103_user.jpg",
"reference_image_url": "/static/framing_reference.png",
"reference_size": {"width": ow, "height": oh},
"source_size": {"width": w, "height": h},
"brow_9": {"x": float(b[0]), "y": float(b[1])},
"chin_152": {"x": float(c[0]), "y": float(c[1])},
"face_axis_length_px": length,
"brow_x_ratio": float(b[0]/ow), "brow_y_ratio": float(b[1]/oh),
"reference_width_per_face_length": ow/length,
"reference_height_per_face_length": oh/length,
"width_per_face_length": 2.5, "height_per_face_length": 3.0/.9,
"rotation_degrees": float(np.degrees(np.arctan2(rotation[0, 1], rotation[0, 0]))),
"axis_x_error_px": float(abs(b[0]-c[0])),
"source_to_reference": matrix.tolist(),
"landmark_source": "source_landmarks_transformed_without_redetection",
}
return image, moved, profile
def normalize_portrait(photo, landmarks, profile=None):
profile = load_profile() if profile is None else profile
h, w = photo.shape[:2]
xy, rotation, length = _rotation(landmarks, w, h)
# Round to an exact 3:4 frame without changing pixel scale.
target_h = max(4, round(profile["height_per_face_length"]*length/4)*4)
target_w = target_h//4*3
matrix = _matrix(rotation, xy[9], [profile["brow_x_ratio"]*target_w,
profile["brow_y_ratio"]*target_h])
cropped = crop_fits(matrix, (w, h), (target_w, target_h))
aspect_cropped = False
aspect_skip_reason = None
if cropped:
size = (target_w, target_h)
elif h*3 > w*4:
try:
matrix, size = _tall_portrait_frame(xy, rotation, (w, h), profile)
aspect_cropped = True
except ValueError as ex:
aspect_skip_reason = str(ex)
matrix, size = _inscribed_frame(rotation, (w, h))
else:
matrix, size = _inscribed_frame(rotation, (w, h))
image, moved = _apply(photo, landmarks, matrix, size)
ow, oh = size
axis = moved[[9, 152], :2]*[ow, oh]
if not ((axis[:, 0] >= 0).all() and (axis[:, 0] < ow).all()
and (axis[:, 1] >= 0).all() and (axis[:, 1] < oh).all()):
raise ValueError("人脸过于贴近边缘,扶正后无法保留9号点与152号点")
metadata = {
"enabled": True, "standard_crop": cropped, "aspect_crop": aspect_cropped,
"aspect_crop_skip_reason": aspect_skip_reason,
"status": "reference_crop" if cropped else ("aspect_crop" if aspect_cropped else "rotation_only"),
"skip_reason": None if cropped else "insufficient_space",
"message": ("已扶正并按参考图裁切" if cropped else
"原图偏长,已扶正并裁成3:4,保留完整人脸" if aspect_cropped else
"3:4窗口无法完整保留人脸,已仅扶正" if aspect_skip_reason else
"空间不足,仅扶正,未按参考比例裁切"),
"rotation_degrees": float(np.degrees(np.arctan2(rotation[0, 1], rotation[0, 0]))),
"source_size": {"width": w, "height": h}, "output_size": {"width": ow, "height": oh},
"requested_crop_size": {"width": target_w, "height": target_h},
"face_axis_length_px": length,
"height_per_face_length": profile["height_per_face_length"],
"width_per_face_length": profile["height_per_face_length"]*.75,
"source_to_processed": matrix.tolist(),
"processed_to_source": cv2.invertAffineTransform(matrix).tolist(),
"axis_x_error_px": float(abs(axis[0, 0]-axis[1, 0])),
"profile_version": profile["version"],
"reference_image_url": profile["reference_image_url"],
}
return image, moved, metadata
class NoPortraitFaceError(ValueError):
"""The portrait landmarker could not find a face."""
def prepare_portrait(photo):
"""Shared interface-52 preprocessing, including the same landmark model/profile."""
from .face_landmarks import get_landmarker
landmarks = get_landmarker().detect(cv2.cvtColor(photo, cv2.COLOR_BGR2RGB), include_iris=True)
if landmarks is None:
raise NoPortraitFaceError("未检出人脸")
return normalize_portrait(photo, landmarks)
+43
View File
@@ -0,0 +1,43 @@
{
"version": "2026-11-04-upright-9-152-subject-90pct-tall-3x4",
"source_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/misc/20261103_user.jpg",
"reference_image_url": "/static/framing_reference.png",
"reference_size": {
"width": 786,
"height": 1048
},
"source_size": {
"width": 792,
"height": 1056
},
"brow_9": {
"x": 385.1878369059212,
"y": 360.65437126595765
},
"chin_152": {
"x": 385.18783690592113,
"y": 725.8251047982238
},
"face_axis_length_px": 365.1707335322661,
"brow_x_ratio": 0.4900608612034621,
"brow_y_ratio": 0.3441358504446161,
"reference_width_per_face_length": 2.152417835890317,
"reference_height_per_face_length": 2.8698904478537566,
"width_per_face_length": 2.5,
"height_per_face_length": 3.333333333333333,
"rotation_degrees": 0.2596248408989324,
"axis_x_error_px": 5.684341886080802e-14,
"source_to_reference": [
[
0.9999897336401403,
0.004531292786961665,
-5.386196599797756
],
[
-0.004531292786961665,
0.9999897336401403,
-2.202458197930582
]
],
"landmark_source": "source_landmarks_transformed_without_redetection"
}
+64
View File
@@ -0,0 +1,64 @@
(() => {
const form = document.getElementById("client-auth");
const message = document.getElementById("client-auth-status");
const button = form.querySelector("button");
let accessToken;
let refreshToken;
let accessExpiry = 0;
async function requestToken(operation, data) {
const response = await fetch("/api/v1/auth/" + operation, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const result = await response.json();
if (!response.ok || result.code !== 0) throw new Error(result.message || "鉴权失败");
accessToken = result.data.access_token;
accessExpiry = Date.now() + result.data.expires_in * 1000;
return result.data;
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
button.disabled = true;
const data = new FormData(form);
try {
const result = await requestToken("login", {
username: data.get("username"),
password: data.get("password"),
});
refreshToken = result.refresh_token;
message.textContent = "已登录。访问令牌过期时会自动刷新。";
} catch (error) {
message.textContent = error.message;
} finally {
form.elements.namedItem("password").value = "";
button.disabled = false;
}
});
async function refresh() {
if (!refreshToken) throw new Error("请先登录,再提交图片。");
try {
await requestToken("refresh", { refresh_token: refreshToken });
} catch (error) {
accessToken = refreshToken = undefined;
message.textContent = "请重新登录。";
throw error;
}
}
window.hairClientFetch = async (path, options) => {
if (Date.now() >= accessExpiry || !accessToken) await refresh();
const send = () => {
const headers = new Headers(options.headers);
headers.set("Authorization", "Bearer " + accessToken);
return fetch(path, { ...options, headers });
};
const response = await send();
if (response.status !== 401) return response;
await refresh();
return send();
};
})();
+2 -1
View File
@@ -270,7 +270,8 @@ console.log(features.gene_style); // "自然型"</pre>
<div class="card" id="if52">
<h2>52. 发际线新规则与裁切 &nbsp;<span class="badge post">POST</span> &nbsp;<code>/api/v1/hairline/generate-52</code></h2>
<div class="card-body">
<p>客户端请求头必填 <code>X-API-Token: yonghe1999</code>。缺少或不匹配返回HTTP401、code=1009。可在网关配置<code>interface52_token</code>覆盖默认值;内部Token由网关添加。</p>
<p>42、52、72 统一使用 <code>Authorization: Bearer &lt;access_token&gt;</code>。通过 <code>POST /api/v1/auth/login</code> 登录,访问令牌有效 15 分钟,可通过 <code>POST /api/v1/auth/refresh</code> 刷新。缺少或无效时返回 HTTP 401、code 1009。</p>
<p><a href="/static/test_interface42.html" class="link">42 号特征测试</a> · <a href="/static/test_interface72.html" class="link">72 号关键点测试</a></p>
<p class="desc">网关透传至GPU worker执行。中下庭均值定位 middle,支持9→152扶正、人像裁切和矩形发际线预览。接口5保留原有三档实现;新功能使用此独立接口。</p>
<p>基础图片参数、<code>gender</code>、<code>hair_style</code>、<code>use_mask</code>、<code>prompt</code>、<code>generate_grow_image</code>沿用接口5。新增可选<code>normalize_image</code>,默认false,传true开启扶正与裁切;测试页显式开启。</p>
<p style="margin-top:12px">非空图片返回<code>*_url</code>,空图片沿用<code>*_base64: null</code>。另有<code>height_rule</code>、<code>framing</code>、<code>guide_landmarks</code>、原图及处理后底图。middle和坐标使用当前底图,high/low字段为null。标准裁切高约3.3333L、宽2.5L;长图在保留人脸的前提下裁为3:4。</p>
+218
View File
@@ -0,0 +1,218 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>接口42 — 用户特征分析与两项判定 测试页</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 6px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 24px; }
.card { background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
.card-header { font-weight: 700; font-size: 14px; padding: 14px 18px; border-bottom: 1px solid #f0f0f0; background: #fafafa; display: flex; justify-content: space-between; align-items: center; }
.card-body { padding: 18px; }
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.file-input { flex: 1; min-width: 200px; }
.file-input input[type=file] { width: 100%; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
.btn { padding: 10px 28px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; transition: .2s; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
.btn-outline:hover { background: #f9fafb; }
.hint { font-size: 12px; color: #9ca3af; margin-top: 8px; }
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; }
.status.info { background: #dbeafe; color: #1e40af; display: block; }
.status.error { background: #fee2e2; color: #991b1b; display: block; }
.status.success { background: #d1fae5; color: #065f46; display: block; }
.results-layout { display: flex; gap: 24px; }
.col-main { flex: 1.5; min-width: 0; }
.col-side { flex: 1; min-width: 0; }
/* 特征表格 */
.feat-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.feat-table th, .feat-table td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #f1f5f9; }
.feat-table th { background: #f8fafc; font-weight: 700; color: #475569; font-size: 11px; text-transform: uppercase; letter-spacing: .3px; }
.feat-table td:first-child { font-weight: 600; color: #1e293b; width: 200px; }
.feat-table tr:hover td { background: #f8fafc; }
.feat-table .highlight td { background: #eff6ff; }
/* 图片预览 */
.img-preview { text-align: center; }
.img-preview .thumb { background: #222; border-radius: 8px; overflow: hidden; display: inline-block; line-height: 0; }
.img-preview .thumb img { max-width: 100%; max-height: 280px; object-fit: contain; }
.json-panel { max-height: 550px; overflow: auto; }
.json-content { padding: 14px 16px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
.hidden { display: none !important; }
.count-badge { background: #dbeafe; color: #1e40af; padding: 2px 10px; border-radius: 10px; font-size: 12px; font-weight: 700; }
@media (max-width: 800px) { .results-layout { flex-direction: column; } }
</style>
</head>
<body>
<div class="container">
<h1>🔬 接口42 — 用户特征分析与两项判定 测试</h1>
<p class="subtitle">POST /api/v1/face/features-42 &nbsp;|&nbsp; 质检 → 扶正裁切 → 面部特征与两项判定 &nbsp;|&nbsp; data.features 为 JSON 字符串</p>
<form id="client-auth" style="display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:12px 0">
<label>用户名 <input name="username" autocomplete="username" required></label>
<label>密码 <input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">登录</button>
<span id="client-auth-status" role="status">登录后可调用接口。</span>
</form>
<script src="/static/client_auth.js" defer></script>
<div class="card">
<div class="card-body">
<div class="upload-row">
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div>
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 分析特征</button>
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除</button>
</div>
<div class="hint">JPG/PNG 正面照 &nbsp;|&nbsp; 检测人脸完整性和俯拍角度,通过后扶正裁切</div>
<div id="statusBar" class="status hidden"></div>
</div>
</div>
<div class="results-layout hidden" id="resultsArea">
<div class="col-main">
<!-- 图片预览 -->
<div class="card">
<div class="card-header"><span>📸 上传的照片</span></div>
<div class="card-body img-preview">
<div class="thumb" id="imgPreview"><span style="color:#9ca3af;padding:40px">—</span></div>
</div>
</div>
<!-- 英文优先字段 -->
<div class="card">
<div class="card-header"><span>⭐ 英文优先字段</span><span id="countBadge" class="count-badge hidden"></span></div>
<div class="card-body" style="padding:0">
<table class="feat-table" id="enTable"></table>
</div>
</div>
<!-- 全部字段 -->
<div class="card">
<div class="card-header"><span>📋 全部特征字段</span></div>
<div class="card-body" style="padding:0;max-height:500px;overflow:auto">
<table class="feat-table" id="allTable"></table>
</div>
</div>
</div>
<div class="col-side">
<div class="card">
<div class="card-header"><span>📄 原始 JSON 响应</span><button class="btn btn-outline btn-sm" onclick="copyJson()">📋 复制</button></div>
<div class="json-panel"><pre class="json-content" id="jsonContent"></pre></div>
</div>
</div>
</div>
</div>
<script>
const API_BASE = window.location.origin;
const EN_KEYS = ['face_shape','eyebrow_shape','facial_age','dynamic_static_type','gender','gene_style','zygoma_expansion','temple_hollowing'];
const EN_LABELS = {
face_shape:'脸型', eyebrow_shape:'眉形', facial_age:'面部年龄',
dynamic_static_type:'动静类型', gender:'性别', gene_style:'基因风格',
zygoma_expansion:'颧弓外扩', temple_hollowing:'太阳穴凹陷'
};
function $(id) { return document.getElementById(id); }
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
async function submitTest() {
const f = $('imageFile').files[0];
if (!f) { setStatus('请选择图片', 'error'); return; }
$('imgPreview').innerHTML = '<img src="'+URL.createObjectURL(f)+'" alt="preview">';
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ 分析中...';
setStatus('正在质检、扶正裁切并分析特征…', 'info'); $('resultsArea').classList.add('hidden');
$('enTable').textContent = '';
$('allTable').textContent = '';
$('countBadge').classList.add('hidden');
const fd = new FormData(); fd.append('image_file', f);
const _reqStart = performance.now();
try {
const r = await window.hairClientFetch(API_BASE + '/api/v1/face/features-42', { method:'POST', body:fd });
const json = await r.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
$('resultsArea').classList.remove('hidden');
if (json.code === 0) {
const feats = JSON.parse(json.data.features);
setStatus('✅ 分析完成 (' + _elapsed + 's) — ' + Object.keys(feats).length + ' 项特征', 'success');
renderFeatures(feats);
} else {
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
}
} catch(e) {
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
setStatus('❌ (' + _elapsed + 's) ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
finally { $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 分析特征'; }
}
function displayValue(value, key) {
const label = value === true ? (EN_LABELS[key] ?? String(value)) : value === false ? ''
: value === null ? '无法测量' : String(value);
const element = document.createElement('span');
element.textContent = label;
return element.innerHTML;
}
function renderFeatures(feats) {
// 英文优先字段
let enHtml = '';
EN_KEYS.forEach(k => {
if (feats[k] !== undefined) {
enHtml += '<tr class="highlight"><td>'+displayValue(k)+'</td><td>'+EN_LABELS[k]+'</td><td>'+displayValue(feats[k], k)+'</td></tr>';
}
});
$('enTable').innerHTML = enHtml;
// 全部字段
const allKeys = Object.keys(feats);
let allHtml = '';
allKeys.forEach(k => {
const isEn = EN_KEYS.includes(k);
allHtml += '<tr'+(isEn?' class="highlight"':'')+'><td>'+displayValue(k)+'</td><td>'+(isEn?EN_LABELS[k]||'':'')+'</td><td>'+displayValue(feats[k], k)+'</td></tr>';
});
$('allTable').innerHTML = allHtml;
$('countBadge').textContent = allKeys.length + ' 项';
$('countBadge').classList.remove('hidden');
}
function clearResults() {
$('resultsArea').classList.add('hidden'); $('statusBar').className='status hidden';
$('imageFile').value=''; $('jsonContent').textContent='';
$('imgPreview').innerHTML='<span style="color:#9ca3af;padding:40px">—</span>';
$('enTable').innerHTML=''; $('allTable').innerHTML='';
$('countBadge').classList.add('hidden');
}
function copyJson() {
navigator.clipboard.writeText($('jsonContent').textContent).then(() => {
const b = event.target, o = b.textContent; b.textContent = '✅ 已复制'; setTimeout(() => b.textContent = o, 1500);
});
}
document.addEventListener('DOMContentLoaded', () => {
const dz = $('imageFile');
dz.addEventListener('dragover', e => { e.preventDefault(); dz.style.borderColor='#2563eb'; });
dz.addEventListener('dragleave', () => dz.style.borderColor='#ddd');
dz.addEventListener('drop', e => { e.preventDefault(); dz.style.borderColor='#ddd'; if(e.dataTransfer.files.length){ dz.files=e.dataTransfer.files; setStatus('已选择: '+e.dataTransfer.files[0].name, 'info'); } });
dz.addEventListener('change', () => { if(dz.files.length) setStatus('已选择: '+dz.files[0].name, 'info'); });
});
</script>
</body>
</html>
+8 -2
View File
@@ -103,10 +103,16 @@
<p class="subtitle">上传正面照,选择性别与发型,查看按中下庭均值定位的设计曲线和生发效果。</p>
<div class="rule-banner"><strong>目标上庭高度 =(中庭高度 + 下庭高度)÷ 2</strong><br>上传图先扶正9→152,再裁切为高约 3.3333×L、宽 2.5×L,人物比原3L规则缩小约10%;L 是9号点到152号点的像素距离,眉心位置沿用参考图比例。长图会在保留人脸的前提下裁为3:4,其余空间不足时仅扶正。从眉心向上定位,各发型中点对齐同一目标。high、low 已停用,旧字段返回 null。</div>
<form id="client-auth" style="display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:12px 0">
<label>用户名 <input name="username" autocomplete="username" required></label>
<label>密码 <input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">登录</button>
<span id="client-auth-status" role="status">登录后可调用接口。</span>
</form>
<script src="/static/client_auth.js" defer></script>
<div class="card">
<div class="card-body">
<div class="upload-row">
<div class="form-group"><label for="apiToken">客户端 Token</label><input id="apiToken" type="password" value="yonghe1999" autocomplete="off"></div>
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div>
<div class="form-group">
<label>性别</label>
@@ -231,7 +237,7 @@ async function submitTest() {
const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value); fd.append('hair_style', checked.join(',')); fd.append('generate_grow_image', $('genGrowImg').checked ? 'true' : 'false'); fd.append('normalize_image', 'true');
const _reqStart = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/hairline/generate-52', { method:'POST', headers: { 'X-API-Token': $('apiToken').value }, body:fd });
const r = await window.hairClientFetch(API_BASE + '/api/v1/hairline/generate-52', { method:'POST', body:fd });
const json = await r.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
+135
View File
@@ -0,0 +1,135 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>72 号接口 · B 端五个关键点</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; background: #eef2f5; color: #192d3d; font: 16px/1.55 system-ui, sans-serif; }
main { width: min(1040px, 100%); margin: auto; padding: 26px 18px; }
h1 { margin: 0; font-size: clamp(24px, 4vw, 35px); }
h2 { margin: 0 0 10px; font-size: 19px; }
p { margin: 8px 0; }
.note { color: #4f6577; }
.controls, .panel { background: white; border-radius: 12px; padding: 18px; margin-top: 16px; }
form { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
button { background: #156a72; color: white; border: 0; border-radius: 7px; padding: 9px 15px; font: inherit; cursor: pointer; }
button:disabled { opacity: .55; cursor: wait; }
input { max-width: 100%; }
#status[data-error="true"] { color: #a22629; }
.results { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 16px; }
[hidden] { display: none; }
.photo { display: grid; border-radius: 8px; overflow: hidden; background: #1b2c37; }
.photo img { grid-area: 1 / 1; width: 100%; display: block; }
.panel a { color: #156a72; }
table { width: 100%; border-collapse: collapse; margin: 8px 0 20px; font-variant-numeric: tabular-nums; }
th, td { text-align: left; border-bottom: 1px solid #e6edf2; padding: 5px 7px; }
@media(max-width: 680px) { .results { grid-template-columns: 1fr; gap: 0; } }
</style>
</head>
<body>
<main>
<h1>72 号接口 · B 端五个关键点</h1>
<p class="note">上传正面照,查看扶正裁切后的六个白点、四条虚线和厘米标注。</p>
<form id="client-auth" style="display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:12px 0">
<label>用户名 <input name="username" autocomplete="username" required></label>
<label>密码 <input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">登录</button>
<span id="client-auth-status" role="status">登录后可调用接口。</span>
</form>
<script src="/static/client_auth.js" defer></script>
<section class="controls">
<form id="form">
<label for="file">人脸照片</label>
<input id="file" type="file" accept="image/jpeg,image/png" required>
<button id="submit" type="submit">生成标注</button>
<button id="sample" type="button">测试样例</button>
</form>
<p id="status" role="status">请选择 JPG 或 PNG 图片。</p>
</section>
<div id="results" class="results" hidden>
<section class="panel">
<h2>标注预览</h2>
<div class="photo">
<img id="base" alt="扶正裁切后的底图">
<img id="overlay" alt="白点、虚线及距离标注">
</div>
<p><a id="download" download="interface72-overlay.png">下载透明标注层</a></p>
</section>
<section class="panel">
<h2>测量结果</h2>
<table><thead><tr><th>点位</th><th>距离</th></tr></thead><tbody id="distances"></tbody></table>
<h2>坐标(像素)</h2>
<table><thead><tr><th>点号</th><th>X</th><th>Y</th></tr></thead><tbody id="points"></tbody></table>
<p class="note">点 6(MediaPipe 446)是射线锚点。六个白点的直径相同。</p>
</section>
</div>
</main>
<script>
const byId = (id) => document.getElementById(id);
const status = byId("status");
const results = byId("results");
const submit = byId("submit");
const sample = byId("sample");
function setRows(id, rows) {
const body = byId(id);
body.replaceChildren();
for (const values of rows) {
const row = body.insertRow();
for (const value of values) row.insertCell().textContent = value;
}
}
function imageSource(data, field, mime) {
return data[field + "_url"] || ("data:" + mime + ";base64," + data[field + "_base64"]);
}
async function runAnnotation(loadFile) {
submit.disabled = sample.disabled = true;
results.hidden = true;
status.dataset.error = "false";
status.textContent = "正在扶正裁切并生成标注…";
try {
const file = await loadFile();
const form = new FormData();
form.append("image_file", file, file.name || "sample.jpg");
const response = await window.hairClientFetch("/api/v1/face/b-end-points", { method: "POST", body: form });
const { code, message, data } = await response.json();
if (!response.ok || code !== 0) throw new Error(message || "请求失败");
byId("base").src = imageSource(data, "input_image", "image/jpeg");
const overlay = imageSource(data, "annotated_image", "image/png");
byId("overlay").src = overlay;
byId("download").href = overlay;
setRows("distances", Object.entries(data.measurements_cm).map(([key, value]) => [
key, value.toFixed(1) + " cm"
]));
setRows("points", Object.entries(data.points).map(([key, point]) => [
key, point.x.toFixed(1), point.y.toFixed(1)
]));
results.hidden = false;
status.textContent = "标注完成。";
} catch (error) {
status.dataset.error = "true";
status.textContent = error.message;
} finally {
submit.disabled = sample.disabled = false;
}
}
byId("form").addEventListener("submit", (event) => {
event.preventDefault();
const file = byId("file").files[0];
if (file) runAnnotation(() => file);
});
sample.addEventListener("click", () => runAnnotation(async () => {
const response = await fetch("/static/sample.jpg");
if (!response.ok) throw new Error("无法读取样例图片");
return response.blob();
}));
</script>
</body>
</html>
+95
View File
@@ -0,0 +1,95 @@
import importlib
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from fastapi.testclient import TestClient
from gateway import client_auth
from gateway.logging_middleware import _should_log
gateway = importlib.import_module("gateway.app")
LOGIN = "/api/v1/auth/login"
REFRESH = "/api/v1/auth/refresh"
FEATURES_42 = "/api/v1/face/features-42"
PROTECTED = [FEATURES_42, "/api/v1/hairline/generate-52", "/api/v1/face/b-end-points"]
@pytest.fixture
def client(monkeypatch):
monkeypatch.setenv("HAIR_API_TOKEN_SECRET", "gateway-test-signing-secret-at-least-32")
session = TestClient(gateway.app)
yield session
session.close()
def login(client):
response = client.post(LOGIN, json={"username": "yonghe", "password": "yonghe1999"})
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
return response.json()["data"]
@pytest.mark.parametrize("path", PROTECTED)
def test_all_three_routes_reject_missing_static_and_refresh_tokens(client, monkeypatch, path):
proxy = AsyncMock()
features = AsyncMock()
monkeypatch.setattr(gateway, "_proxy", proxy)
monkeypatch.setattr(gateway, "_face_features_response", features)
refresh_token = login(client)["refresh_token"]
for headers in ({}, {"X-API-Token": "yonghe1999"}, {"Authorization": "Bearer " + refresh_token}):
result = client.post(path, headers=headers)
assert result.status_code == 401
assert result.json()["code"] == 1009
proxy.assert_not_awaited()
features.assert_not_awaited()
def test_login_expiry_tampering_and_refresh(client, monkeypatch):
clock = SimpleNamespace(now=1_000_000)
monkeypatch.setattr(client_auth, "time", SimpleNamespace(time=lambda: clock.now))
proxy = AsyncMock(return_value={"code": 0})
monkeypatch.setattr(gateway, "_proxy", proxy)
path = PROTECTED[-1]
assert client.post(LOGIN, json={"username": "yonghe", "password": "wrong"}).status_code == 401
data = login(client)
assert data["expires_in"] == 900 and data["refresh_expires_in"] == 604800
access, refresh = data["access_token"], data["refresh_token"]
headers = {"Authorization": f"Bearer {access}"}
assert client.post(path, headers=headers).status_code == 200
assert client.post(path, headers={"Authorization": f"Bearer {access}x"}).status_code == 401
assert client.post(REFRESH, json={"refresh_token": access}).status_code == 401
clock.now += data["expires_in"]
assert client.post(path, headers=headers).status_code == 401
response = client.post(REFRESH, json={"refresh_token": refresh})
assert response.status_code == 200 and response.headers["cache-control"] == "no-store"
refreshed = response.json()["data"]
assert "refresh_token" not in refreshed and refreshed["access_token"] != access
assert client.post(path, headers={"Authorization": "Bearer " + refreshed["access_token"]}).status_code == 200
clock.now += data["refresh_expires_in"] - data["expires_in"]
assert client.post(REFRESH, json={"refresh_token": refresh}).status_code == 401
assert proxy.await_count == 2
def test_unconfigured_secret_fails_closed_and_token_responses_are_not_logged(client, monkeypatch):
monkeypatch.setenv("HAIR_API_TOKEN_SECRET", "")
result = client.post(LOGIN, json={"username": "yonghe", "password": "yonghe1999"})
assert result.status_code == 503 and result.json()["code"] == 1007
assert not _should_log(LOGIN) and not _should_log(REFRESH)
def test_features42_combines_processed_photo_with_main_features(client, monkeypatch):
analyzer = Mock(return_value={"face_shape": "鹅蛋脸", "contour_type": "轮廓适中"})
monkeypatch.setattr("face_features.analyze_features", analyzer)
prepared = AsyncMock(return_value=(b"processed", {"zygoma_expansion": True, "temple_hollowing": True}))
monkeypatch.setattr(gateway, "_prepare_features_42", prepared)
headers = {"Authorization": "Bearer " + login(client)["access_token"]}
response = client.post(FEATURES_42, headers=headers, files={"image_file": ("face.jpg", b"raw")})
assert response.status_code == 200 and response.json()["code"] == 0
assert json.loads(response.json()["data"]["features"]) == {
"face_shape": "鹅蛋脸", "contour_type": "轮廓适中",
"zygoma_expansion": True, "temple_hollowing": True,
}
prepared.assert_awaited_once_with(b"raw", None)
analyzer.assert_called_once_with(b"processed", None)
+15 -74
View File
@@ -15,75 +15,15 @@ from gateway import config, forward
gateway = importlib.import_module("gateway.app")
PATH = "/api/v1/hairline/generate-52"
CLIENT_HEADERS = {"X-API-Token": "yonghe1999"}
@pytest.mark.parametrize("headers", [
{}, {"X-API-Token": ""}, {"X-API-Token": "wrong"},
{"X-API-Token": "yonghe1999 "}, {"X-Internal-Token": "yonghe1999"},
])
def test_invalid_client_token_is_rejected_before_dispatch(monkeypatch, headers):
monkeypatch.setattr(config, "get_config", lambda: {})
proxy = AsyncMock()
monkeypatch.setattr(gateway, "_proxy", proxy)
@pytest.fixture
def client_headers(monkeypatch):
monkeypatch.setenv("HAIR_API_TOKEN_SECRET", "gateway-test-signing-secret-at-least-32")
client = TestClient(gateway.app)
try:
response = client.post(PATH, headers=headers)
assert response.status_code == 401
body = response.json()
assert body["code"] == 1009 and body["data"] is None
assert body["request_id"].startswith("gw-")
response = client.post("/api/v1/auth/login", json={"username": "yonghe", "password": "yonghe1999"})
finally:
client.close()
proxy.assert_not_awaited()
def test_custom_token_replaces_default_only_on_interface52(monkeypatch):
monkeypatch.setattr(config, "get_config", lambda: {"interface52_token": "custom-client-token"})
proxy = AsyncMock(return_value={"code": 0, "data": None})
monkeypatch.setattr(gateway, "_proxy", proxy)
client = TestClient(gateway.app)
try:
assert client.post(PATH, headers=CLIENT_HEADERS).status_code == 401
assert client.post(PATH, headers={"X-API-Token": "custom-client-token"}).status_code == 200
assert client.post("/api/v1/hairline/generate").status_code == 200
finally:
client.close()
assert proxy.await_count == 2
@pytest.mark.parametrize("token", ["", None, 123, []])
def test_invalid_configured_token_cannot_disable_auth(monkeypatch, token):
monkeypatch.setattr(config, "get_config", lambda: {"interface52_token": token})
proxy = AsyncMock()
monkeypatch.setattr(gateway, "_proxy", proxy)
client = TestClient(gateway.app)
try:
assert client.post(PATH, headers=CLIENT_HEADERS).status_code == 401
finally:
client.close()
proxy.assert_not_awaited()
@pytest.mark.parametrize("token", ["", None, 123, []])
def test_startup_rejects_invalid_client_token_configuration(tmp_path, monkeypatch, token):
import json
path = tmp_path / "config.json"
path.write_text(json.dumps({"interface52_token": token}))
monkeypatch.setattr(config, "CONFIG_PATH", path)
monkeypatch.setattr(config, "_config_cache", None)
with pytest.raises(ValueError, match="interface52_token"):
config.load_config()
def test_existing_config_gets_default_client_token(tmp_path, monkeypatch):
import json
path = tmp_path / "config.json"
path.write_text(json.dumps({"workers": ["http://worker"], "shared_password": "test-internal-token",
"static_dir": str(tmp_path)}))
monkeypatch.setattr(config, "CONFIG_PATH", path)
monkeypatch.setattr(config, "_config_cache", None)
assert config.load_config()["interface52_token"] == "yonghe1999"
return {"Authorization": "Bearer " + response.json()["data"]["access_token"]}
def image_bytes(fmt):
@@ -113,14 +53,14 @@ def proxy_setup(tmp_path, monkeypatch):
return cfg, acquire, release, release_worker, unhealthy
@pytest.mark.parametrize("path", ["/api/v1/hairline/generate", PATH])
def test_hairline_routes_forward_to_their_own_worker_path(monkeypatch, path):
@pytest.mark.parametrize("path", ["/api/v1/hairline/generate", PATH, "/api/v1/face/b-end-points"])
def test_routes_forward_to_their_own_worker_path(monkeypatch, path, client_headers):
monkeypatch.setattr(config, "get_config", lambda: {})
proxy = AsyncMock(return_value={"code": 0, "data": None})
monkeypatch.setattr(gateway, "_proxy", proxy)
client = TestClient(gateway.app, client=("203.0.113.9", 50000))
try:
assert client.post(path, headers=CLIENT_HEADERS if path == PATH else {}).json() == {"code": 0, "data": None}
assert client.post(path, headers=client_headers if path != "/api/v1/hairline/generate" else {}).json() == {"code": 0, "data": None}
finally:
client.close()
proxy.assert_awaited_once()
@@ -128,7 +68,7 @@ def test_hairline_routes_forward_to_their_own_worker_path(monkeypatch, path):
@pytest.mark.parametrize("generate_grow_image", [False, True])
def test_interface52_raw_forwarding_and_served_image_urls(proxy_setup, monkeypatch, generate_grow_image):
def test_interface52_raw_forwarding_and_served_image_urls(proxy_setup, monkeypatch, generate_grow_image, client_headers):
cfg, acquire, release, release_worker, unhealthy = proxy_setup
png, jpg = image_bytes("PNG"), image_bytes("JPEG")
metadata = {"coordinate_space": "processed_image", "width": 6, "height": 8,
@@ -163,7 +103,7 @@ def test_interface52_raw_forwarding_and_served_image_urls(proxy_setup, monkeypat
client = TestClient(gateway.app, client=("203.0.113.9", 50000))
try:
result = client.post(PATH, content=raw, headers={
**CLIENT_HEADERS,
**client_headers,
"Content-Type": source.headers["content-type"], "X-Internal-Token": "untrusted-client-token"})
assert result.status_code == 200
data = result.json()["data"]
@@ -190,21 +130,21 @@ def test_interface52_raw_forwarding_and_served_image_urls(proxy_setup, monkeypat
assert calls[0].headers["content-type"] == source.headers["content-type"]
assert calls[0].headers["x-internal-token"] == cfg["shared_password"]
assert calls[0].headers.get("x-forwarded-for") is None
assert calls[0].headers.get("x-api-token") is None
assert calls[0].headers.get("authorization") is None
acquire.assert_awaited_once_with(cfg, 0)
release.assert_called_once()
release_worker.assert_awaited_once()
unhealthy.assert_not_awaited()
def test_worker_ip_rejection_is_preserved(proxy_setup, monkeypatch):
def test_worker_ip_rejection_is_preserved(proxy_setup, monkeypatch, client_headers):
_, _, release, release_worker, unhealthy = proxy_setup
body = {"code": 1010, "message": "来源IP不在接口白名单", "data": None}
upstream = httpx.AsyncClient(transport=httpx.MockTransport(lambda request: httpx.Response(403, json=body)))
monkeypatch.setattr(forward, "get_client", lambda: upstream)
client = TestClient(gateway.app)
try:
response = client.post(PATH, headers=CLIENT_HEADERS, data={"gender": "male", "hair_style": "1"})
response = client.post(PATH, headers=client_headers, data={"gender": "male", "hair_style": "1"})
assert response.status_code == 403 and response.json() == body
finally:
client.close()
@@ -220,7 +160,8 @@ def test_public_interface52_page_has_no_internal_password():
page = client.get("/static/test_interface52.html")
assert page.status_code == 200
assert PATH in page.text and "X-Internal-Token" not in page.text
assert 'id="apiToken"' in page.text and "X-API-Token" in page.text
assert 'id="client-auth"' in page.text and "X-API-Token" not in page.text
assert "yonghe1999" not in page.text
assert "localhost:8187" not in page.text
assert "test_interface52_compare.html" not in page.text
assert client.get("/static/hairline_reference_box.js").status_code == 200