# 外网网关 — 开发任务书(AI Agent 执行版) > 在 **外网机(`hair.xiangsilian.com`)** 上开发。本任务书自包含,只负责**网关**这一层。 > 配套:[`系统架构-网关与高性能后端.md`](系统架构-网关与高性能后端.md)(两端共享的契约,**先读**)。 > worker(GPU 机)侧算法由另一份任务书负责:`接口1-四庭七眼测量-开发任务书.md`,本机不涉及。 > 执行者:AI coding agent。**严格按阶段顺序**执行,每阶段跑完「验证方法」通过后再进入下一阶段。 --- ## 0. 网关是什么 / 不是什么 - **是**:一个无状态的**薄反向代理**。对外保持 HTTPS 接口与接口文档**完全不变**;对内把请求转发给高性能 worker 池,做健康检查、空闲派发、鉴权、把 worker 返回的 base64 标注图落盘成对外 URL。 - **不是**:不跑任何算法(无 MediaPipe / torch / BiSeNet);不解析业务入参;不持有业务状态。所有业务逻辑和错误码(1001–1008)都来自 worker,网关原样透传。 **网关唯一自行产生的错误**:无可用 worker / 全忙排队超时 → `code: 1007`(复用系统错误,不新增错误码)。 --- ## 1. 总体约束 1. **对外契约零变化**:客户端看到的 URL、请求方式(multipart/form-data,三选一图片)、响应结构 `{code,message,request_id,data}`、字段名、错误码,全部与 `docs/接口文档.md` 一致。客户端**无感知**拆分。 2. **代理全部 5 个接口**:`/api/v1/face/measure`、`/hair/grow`、`/hair/grow-b`、`/face/features`、`/hairline/generate`。worker 跑完整 app,网关统一代理。 3. **技术栈**:Python + FastAPI + `httpx`(异步转发)+ uvicorn。**不引入** torch/mediapipe/opencv 等重依赖,网关保持轻量。 4. **无状态**:除"健康池 + worker 忙闲状态"这点运行时状态外,不持久化业务数据。 5. 密码、worker 列表等走**配置文件**,不硬编码、不入 git。 --- ## 2. 配置文件 `gateway/config.json`(运维维护,**入 `.gitignore`**;仓库只放 `gateway/config.example.json`): ```json { "workers": [ "http://hair.xiangsilian.com:28187", "http://10.0.0.12:28187" ], "shared_password": "REPLACE_ME_ROTATE_PERIODICALLY", "public_base_url": "https://hair.xiangsilian.com", "static_dir": "static/annotations", "health_check": { "path": "/health", "interval_seconds": 8, "timeout_seconds": 3, "unhealthy_threshold": 2, "healthy_threshold": 1 }, "dispatch": { "per_worker_concurrency": 1, "queue_wait_seconds": 30, "request_timeout_seconds": 60, "retry_on_failure": true, "max_retries": 1 } } ``` 字段含义见架构文档 §4。要点:`workers` 手动增删(**加一个就多一路并发**);`per_worker_concurrency` 固定 1;`shared_password` 网关调用 worker 时通过 `X-Internal-Token` 头发送。 --- ## 3. 阶段一:骨架 + 配置加载 **开发步骤** 1. 新建目录: ``` gateway/ ├── app.py # FastAPI 应用 + 5 接口代理路由 ├── config.py # 读取/校验 config.json ├── config.example.json ├── pool.py # 健康池 + worker 忙闲状态 + 派发 ├── forward.py # httpx 转发 + base64→URL 改写 └── __init__.py static/annotations/ # 标注图落盘目录(.gitkeep) ``` 2. `config.py`:加载 `config.json`,缺字段给默认值,启动时校验 `workers` 非空、密码非占位值(占位值打 WARNING)。 3. `app.py`:建 FastAPI 应用,挂载 `/static`,加 `/gateway-health`(网关自身健康,区别于 worker 的 `/health`)。 4. 更新根 `.gitignore`:忽略 `gateway/config.json`、`static/annotations/*`(保留 `.gitkeep`)。 **验证** ```bash ./venv/bin/uvicorn gateway.app:app --host 127.0.0.1 --port 8080 & curl -s http://127.0.0.1:8080/gateway-health # 200 ``` 启动日志打印解析出的 worker 列表与派发参数。 **完成标准**:网关起得来,配置正确加载,无 worker 时也不崩。 --- ## 4. 阶段二:健康检查 + 空闲派发(并发=worker 数) **开发步骤** 1. `pool.py`: - 后台 asyncio 任务,每 `interval_seconds` 给每个 worker 发 `GET {worker}/health`(带 `X-Internal-Token`,`timeout_seconds` 超时)。 - 连续失败 `unhealthy_threshold` 次 → 下线;重新连续成功 `healthy_threshold` 次 → 上线。维护在线集合。 - 每个 worker 一个 `asyncio.Lock`(或容量=1 的信号量)表示忙闲(`per_worker_concurrency=1`)。 2. 派发 `acquire_worker()`:从在线池里挑一个**空闲** worker 占用;全忙则 `await` 等待,最多 `queue_wait_seconds`,超时抛 `NoWorkerAvailable`;在线池为空也抛该异常。用完 `release`。 3. 路由层捕获 `NoWorkerAvailable` → 返回 `{code:1007, message:"后端服务暂不可用,请稍后重试", ...}`。 **验证** - 配 2 个**假 worker**(本地起两个返回 `/health` 200 + 简单 echo 的 stub):并发打 3 个请求 → 前 2 个并行、第 3 个排队后成功。 - 停掉 1 个 stub → `interval×unhealthy_threshold` 内自动下线,请求只走存活的。 - 全停 → 请求返回 `code==1007`。 ```bash # 可用 python 起两个 stub:每个监听不同端口,/health 返回200,业务接口 sleep 1s 再回 ``` **完成标准**:健康检查上下线正确;并发=在线 worker 数;全忙排队、池空→1007。 --- ## 5. 阶段三:鉴权头 + 转发 + 标注图改写 **开发步骤** 1. `forward.py`:用 `httpx.AsyncClient` 把客户端请求**原样转发**到选中的 worker 对应路径: - 透传 method、multipart/form body、查询参数;附加 `X-Internal-Token: ` 头。 - `request_timeout_seconds` 超时;连接失败/超时/5xx/401 视为该 worker 异常 → 标记不健康,按 `max_retries` 换 worker 重试;耗尽 → 1007。 - worker 返回的 1001–1008 业务响应**原样透传**给客户端(不要改 code)。 2. **base64 → URL 改写**:拿到 worker 的 JSON 响应后,对约定的图片字段: - worker 用 `*_base64` 承载 PNG(如接口1 `annotated_image_base64`)。 - 网关:解码 → 存 `static/annotations/{uuid}.png` → **删除 `*_base64`,新增 `*_url`** = `{public_base_url}/static/annotations/{uuid}.png`。 - **推荐通用实现**:递归遍历 `data`,凡 key 以 `_base64` 结尾 → 落盘 → 改成同前缀 `_url`, **包括数组元素内部的字段**(接口2/5 的图片字段在数组里)。这样新增字段自动覆盖、无需逐一硬编码。 - `*_base64` 值可能为 **null**(如接口2/3 的生发图,ComfyUI 未起/失败时)→ 该项**保留 null、不落盘、不生成 url**。 **完整字段映射表**(worker 内部 `*_base64` → 对外 `*_url`,以 `docs/接口文档.md` 为准): | 接口 | 路径 | worker 字段(内部) | 对外字段 | 位置 | |------|------|--------------------|----------|------| | 1 | `/api/v1/face/measure` | `annotated_image_base64` | `annotated_image_url` | data 顶层 | | 2 | `/api/v1/hair/grow` | `results[].image_base64` | `results[].image_url` | **数组元素** | | 2 | 〃 | `results[].grown_image_base64` | `results[].grown_image_url` | **数组元素**(可空) | | 3 | `/api/v1/hair/grow-b` | `hair_growth_image_base64` | `hair_growth_image_url` | data 顶层(可空) | | 4 | `/api/v1/face/features` | —(无图片字段,原样透传)| — | — | | 5 | `/api/v1/hairline/generate` | `hairline_images[].image_base64` | `hairline_images[].image_url` | **数组元素** | > 接口2/5 **新增了必填 `gender` 入参**、接口3 用 `marked_image_*`+`original_image_*`——这些都是 > **请求 multipart 参数**,网关原样透传即可,无需改造(网关对入参透明)。 3. 5 个接口路由统一走「选 worker → 转发 → 改写 → 返回」一条链路。 **验证(端到端,最终冒烟)** ```bash # 经网关(对外 HTTPS;客户端不需要 token——token 是网关→worker 内部的) curl -s -X POST https://hair.xiangsilian.com/api/v1/face/measure \ -F image_file=@<一张人像> | python -m json.tool # 期望:与接口文档完全一致——data 含 annotated_image_url(不是 base64) curl -sI https://hair.xiangsilian.com/static/annotations/.png # 200 ``` - 对外响应字段/URL 与 `docs/接口文档.md` **逐一一致**。 - 响应里**不应**出现 `*_base64`(已被网关消化)。 - 直接打 worker 不带 token → 401(worker 侧行为);经网关正常。 **完成标准**:5 接口代理通;base64 落盘改 URL 正确;对外契约零变化;失败重试与 1007 生效。 --- ## 6. 阶段四:静态托管 + 清理 **开发步骤** 1. 网关 `app.mount("/static", StaticFiles(directory="static"), ...)` 托管落盘的标注图。 2. 加**定期清理**:`static/annotations/` 会持续增长,按时间(如保留 N 天)或容量清理。可用进程内定时任务或外部 cron(任选,记录方案)。 **验证** - 生成的图能经 `https://hair.xiangsilian.com/static/annotations/.png` 访问(200)。 - 清理任务按规则删除过期文件,不误删近期文件。 **完成标准**:静态图可公网访问;清理任务可控。 --- ## 7. 阶段五:部署 **开发步骤** 1. nginx:443 → 网关进程(uvicorn)。沿用现有 `nginx/hair.conf` 风格。 2. systemd 管理网关进程(可继续用 `hair.service`,或新建 `hair-gateway.service`)。 3. `config.json` 就位(worker 列表 + 密码);`static/annotations/` 可写。 **验证** ```bash sudo systemctl restart hair-gateway && sudo systemctl status hair-gateway # 端到端冒烟(同阶段三);journalctl 无 ERROR ``` **完成标准**:线上经 HTTPS 走通全链路,标注图可访问,日志无异常。 --- ## 8. 交付清单(网关侧 DoD) - [ ] `gateway/`:`app.py`、`config.py`、`pool.py`、`forward.py`、`config.example.json` - [ ] 代理 5 个接口,对外契约/字段/URL 与接口文档**完全一致** - [ ] worker 池健康检查(上下线)+ 空闲派发(并发=在线 worker 数)+ 全忙排队/无后端→1007 - [ ] 共享密码鉴权(`X-Internal-Token`,配置文件,可轮换) - [ ] base64 → 落盘 `static/annotations/` → 改写 `*_url`,响应无 `*_base64` 残留 - [ ] 静态托管 + 定期清理 - [ ] 端到端 HTTPS 冒烟通过,`annotated_image_url` 公网可访问 - [ ] `config.json` 入 `.gitignore`,仓库只留 `config.example.json` --- ## 9. 风险与注意 1. **联调依赖 worker**:阶段四之前可用**本地 stub worker**(返回 `/health` 200 + 假的 base64 图)独立开发;worker 真机就绪后再换真实地址端到端联调。 2. **接口文档是字段唯一权威**:图片字段映射表、对外字段名都以 `docs/接口文档.md` 为准,冲突时以文档为准并在 PR 说明指出。 3. **安全(先跑通后处理,已知项)**:`:28187` 当前 HTTP 明文 + 公网可达,密码明文传输;后续建议加 TLS / 内网 / IP 白名单(见架构文档 §13/§14)。本阶段不阻塞。 4. **接口实现进度**:接口 **1/2/3/5 worker 已真实实现**(图片字段见上方映射表);**接口 4(用户特征)仍为 mock**(无图片字段,原样透传)。 5. **生发图耗时**:接口 2(一次 N 张 Flux,~18s)、接口 3(~6s)经 ComfyUI 同步出图,**`request_timeout_seconds` 要调大**(建议 ≥120s),否则网关会先超时换 worker 重试。 --- > **文档版本**: v1.0 | **创建日期**: 2026-06-14 | 配套:系统架构 v1.0 > **关键约束**: 接口文档不变;不跑算法;每 worker 并发=1;无后端→1007;base64→落盘→URL