docs: 接口4 网关侧实现方案(供网关开发照做)

接口4 不碰本地GPU/模型,仅调外网豆包→放网关本地实现更合理。文档含:
路由改本地处理(不转发)、httpx调方舟(OpenAI兼容,无需SDK)、prompt原文、
base64 data URI喂图、字段映射(6英文+全部中文)、无人脸→1001、配置(ark密钥入网关config)、
错误码、worker侧回收步骤、自测。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
xsl
2026-06-15 20:33:50 +08:00
co-authored by Claude Opus 4.8
parent 3008552331
commit 043a4c0603
@@ -0,0 +1,219 @@
# 接口 4:用户特征 — 网关侧实现方案
> **结论先行**:接口 4 不碰任何本地 GPU/模型,只是「调一次外网豆包视觉模型 + 解析 JSON」。
> 因此**在网关本地实现**(不转发给 worker)最合理:网关本就是对外那台、天然有公网出口;
> worker 由此保持纯内网/离线。本文档供网关开发照做。
>
> 算法来源 `/home/xsl/fuyan`FaceArk.py);worker 侧已有一版可跑通的实现
> `face_features.py`(用 volcengine SDK),可直接作为「逻辑参考」。
---
## 0. 与现状的差异
- 现网关 `gateway/app.py``/api/v1/face/features` 是**盲转发**给 worker
```python
@app.post("/api/v1/face/features", tags=["人脸分析"])
async def face_features(request: Request):
return await _proxy(request, "/api/v1/face/features")
```
- 改为**网关本地处理**(解析图片 → 调豆包 → 返回),**不再转发**。
- 其余 4 个接口(1/2/3/5)仍然盲转发给 worker,不变。
---
## 1. 对外契约(不变,`接口文档.md` 接口4
- `POST /api/v1/face/features`
- 输入:图片三选一(`image_file` / `image_url` / `image_base64`),无其它参数。
- 输出:标准信封 `{code, message, request_id, data}`,其中 `data.features` 是一个
**JSON 字符串**(不是对象),内含几十项面部特征。无人脸返回 **1001**。
---
## 2. 依赖与配置
### 2.1 依赖
**不需要 volcengine SDK**。豆包/方舟是 **OpenAI 兼容** 接口,网关用现成的 `httpx` 直接调即可,保持轻量。
(若想省事,也可 `pip install "volcengine-python-sdk[ark]"` 直接照搬 worker 的 `face_features.py`,二选一。)
### 2.2 配置(加到 `gateway/config.json`,含密钥不入 git
```json
{
"ark": {
"api_key": "14fc0280-fc65-462d-ac2d-50178c0212e3",
"base_url": "https://ark.cn-beijing.volces.com/api/v3",
"model": "doubao-seed-1-6-vision-250815",
"timeout_seconds": 60
}
}
```
- `config.example.json` 里放占位(`"api_key": "your-volcengine-ark-api-key"`)。
- 也可用环境变量覆盖(`ARK_API_KEY` 等),按网关现有风格来。
- ⚠️ 网关机需能访问 `ark.cn-beijing.volces.com`(已实测可达,401=要鉴权即连通)。
---
## 3. 豆包调用(httpx 版)
### 3.1 请求
`POST {base_url}/chat/completions`,头 `Authorization: Bearer {api_key}`,体:
```json
{
"model": "doubao-seed-1-6-vision-250815",
"messages": [{
"role": "user",
"content": [
{ "type": "image_url", "image_url": { "url": "<图片URL 或 base64 data URI>" } },
{ "type": "text", "text": "<下面 §3.3 的 PROMPT>" }
]
}]
}
```
返回里取 `resp["choices"][0]["message"]["content"]`(是个带 ```json 包裹的字符串)。
### 3.2 图片怎么喂
- 传了 `image_url` → **直接把这个 URL 塞进去**(豆包自己去拉,最省)。
- 传了 `image_file` / `image_base64` → 转成 **base64 data URI**
`data:image/jpeg;base64,XXXX`PNG 头 `\x89PNG` 用 `image/png`,否则 `image/jpeg`)。
(已实测豆包接受 data URI,无需先把图落到公网。)
### 3.3 PROMPT(移植自 fuyan,去掉身高体重前缀,**原样使用**)
```
分析一下图片告诉我以下特征,只要答案,格式为json字符串,图片是否有人脸(有人/没人) 三庭五眼特征(答案要有三庭五眼四个字,9个字以内) 面部年龄(给出区间年龄)鼻长(鼻长适中/长鼻/短鼻) 脸型(圆形脸/心形脸/菱形脸/鹅蛋脸/方形脸/长形脸/瓜子脸) 嘴型 眼袋(答案要有眼袋两个个字) 眼型 鼻型 眼皮(双眼皮/单眼皮) 法令纹(有法令纹/无法令纹) 人中(人中适中/人中长/人中短) 眉形 瞳色(答案要有瞳色两个字) 脖长(脖长适中/脖子短/脖子长) 肤色(粉一白/粉二白/粉三白/黄一白/黄二白/黄黑皮)直得分 曲得分 直曲总分(直得分-曲得分) 大量感得分 小量感得分 量感总分(大量感得分-小量感得) 面部立体度(总分十分)瞳距(毫米)对比度(对比度较强/对比度适中/对比度较弱)鼻子立体度(立体度高/立体度适中/立体度低)色相(中间表示0,最大值分别是-5和5,负数表示偏冷,正数表示偏暖)亮度(中间表示0,最大值分别是-5和5;负数表示暗沉,正数表示白皙)色度(中间表示0,最大值分别是-5和5;负数表示饱和度低,正数表示鲜艳)面部颜色对比度(10分制)四季色彩季型(净春型/暖春型/浅春型/浅夏型/冷夏型/柔夏型/柔秋型/暖秋型/深秋型/净冬型/冷冬型/深冬型)基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)量感类型(大量感/中量感/小量感)轮廓类型(轮廓偏曲/轮廓适中/轮廓偏直)动静类型(静态型/动态型)性别(男/女)
```
---
## 4. 解析 + 字段映射
1. **去包裹解析**`content` 去掉首尾 ```json ``` 后 `json.loads`,得到 doubao 的中文字段 dict。
2. **英文优先字段**(与中文并存,方便客户端直接取):
```python
_KEY_MAP = {
"脸型": "face_shape", "眉形": "eyebrow_shape", "面部年龄": "facial_age",
"动静类型": "dynamic_static_type", "性别": "gender", "基因风格": "gene_style",
}
for zh, en in _KEY_MAP.items():
if zh in data and en not in data:
data[en] = data[zh]
```
3. **无人脸判定**`data["图片是否有人脸"]` 含「没人」/「没有」→ 视为无人脸 → 返回 **1001**。
4. `data.features` = `json.dumps(data, ensure_ascii=False)`**字符串**)。
> 字段几十项:脸型/眉形/眼型/鼻型/眼袋/法令纹/人中/瞳色/脖长/肤色、三庭五眼、四季色彩季型、
> 量感/轮廓类型、各项得分、瞳距、对比度、基因风格、动静类型、性别…… 字段不固定、可增删。
---
## 5. 路由实现(替换盲转发)
`gateway/app.py`
```python
@app.post("/api/v1/face/features", tags=["人脸分析"])
async def face_features(request: Request):
from gateway.face_features import handle_features # 新增模块
return await handle_features(request) # 网关本地处理,不再 _proxy
```
`gateway/face_features.py`(新增,伪代码):
```python
import base64, json, uuid, httpx
from fastapi import Request
from fastapi.responses import JSONResponse
from gateway.config import get_config
PROMPT = "...(§3.3 原样)..."
_KEY_MAP = {...} # §4
def _env(code, msg):
return JSONResponse(status_code=200, content={
"code": code, "message": msg, "request_id": f"gw-{uuid.uuid4().hex[:8]}", "data": None})
async def handle_features(request: Request) -> JSONResponse:
form = await request.form()
f = form.get("image_file"); url = form.get("image_url"); b64 = form.get("image_base64")
provided = [x for x in (f, url, b64) if x]
if len(provided) != 1:
return _env(1007, "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个")
# 图片 → URL 或 data URI
if url:
image_ref = url
else:
raw = (await f.read()) if f is not None else _decode_b64(b64) # base64 去 data:前缀再 decode
if len(raw) > 1_000_000:
return _env(1006, "文件超出 1 MB 限制")
fmt = "png" if raw[:8] == b"\x89PNG\r\n\x1a\n" else "jpeg"
image_ref = f"data:image/{fmt};base64," + base64.b64encode(raw).decode()
cfg = get_config()["ark"]
try:
async with httpx.AsyncClient(timeout=cfg.get("timeout_seconds", 60)) as cli:
r = await cli.post(
f'{cfg["base_url"]}/chat/completions',
headers={"Authorization": f'Bearer {cfg["api_key"]}'},
json={"model": cfg["model"], "messages": [{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": image_ref}},
{"type": "text", "text": PROMPT}]}]})
r.raise_for_status()
text = r.json()["choices"][0]["message"]["content"]
data = _parse_json(text) # 去 ```json 包裹
for zh, en in _KEY_MAP.items():
if zh in data and en not in data: data[en] = data[zh]
if _no_face(data):
return _env(1001, "无法识别人像")
return JSONResponse(status_code=200, content={
"code": 0, "message": "success", "request_id": f"gw-{uuid.uuid4().hex[:8]}",
"data": {"features": json.dumps(data, ensure_ascii=False)}})
except Exception as ex:
return _env(1007, f"处理失败:{ex}")
```
> `_parse_json``s.strip()`,若以 ``` 开头去掉反引号和开头的 `json`,再 `json.loads`。
> `_no_face``"没人" in str(data.get("图片是否有人脸",""))`。
> `_decode_b64`:有 `data:` 前缀就 `split(",",1)[1]` 再 `base64.b64decode`。
> ⚠️ 网关其它接口是盲转发(不读 body);接口4 这里**读了 form**,没问题(不再转发,body 自己消费)。
---
## 6. 错误码
| 码 | 触发 |
|----|------|
| 1007 | 图片参数 0 个或多个;或调用/解析异常兜底 |
| 1006 | 文件 > 1MBfile/base64 路径) |
| 1001 | 豆包判定「没人」 |
| 0 | 正常,`data.features` 为 JSON 字符串 |
---
## 7. worker 侧回收(迁移后做)
接口4 改由网关处理、不再转发后,worker 的 `/api/v1/face/features` 就成了死代码。建议:
- worker `app.py` 把接口4 **回退为 Mock**(或保留真实实现做直连兜底,二选一);
- worker `requirements.txt` 去掉 `volcengine-python-sdk[ark]`worker 保持无外网依赖);
- `worker_config.json` 去掉 `ark_api_key`(密钥只放网关配置)。
- worker 仓库现有 `face_features.py` 可作为网关实现的逻辑参考,迁移完成后 worker 侧可删。
> 这步不阻塞网关实现;先把网关跑通,worker 回收随后做。
---
## 8. 自测
```bash
# 经网关(对外,客户端不带 token)
curl -s -X POST https://hair.xiangsilian.com/api/v1/face/features \
-F image_file=@<一张人像> | python -m json.tool
# 期望:code 0data.features 是 JSON 字符串,parse 后含 face_shape/gender/基因风格 等几十项
curl -s -X POST https://hair.xiangsilian.com/api/v1/face/features \
-F image_file=@<风景图> ; # 期望 code 1001(豆包判无人脸;注意合成图偶有波动)
```
> 实测参考(worker 直跑 frontal):返回 42 字段,`鹅蛋脸 / 平眉 / 18-25岁 / 静态型 / 女 / 少年型`。
---
> **文档版本**: v1.0 **创建日期**: 2026-06-15 模型: 火山方舟 doubao-seed-1-6-vision
> 运行位置: **网关**(本地 httpx 调用,不经 worker)| 逻辑参考: worker `face_features.py` / `/home/xsl/fuyan`