网关开发完成

This commit is contained in:
Ubuntu
2026-06-14 15:04:34 +08:00
parent bf68a32d9f
commit 3b706ec0ef
12 changed files with 1301 additions and 0 deletions
+3
View File
@@ -4,6 +4,9 @@ __pycache__/
.env
# 网关配置(含密码,不入 git
gateway/config.json
# 生成的标注图、测试输出
static/annotations/*
!static/annotations/.gitkeep
+1
View File
@@ -0,0 +1 @@
# gateway — 外网网关
+253
View File
@@ -0,0 +1,253 @@
"""外网网关 — FastAPI 应用。
薄反向代理层:对外保持 HTTPS 接口不变,对内转发到 worker 池。
不跑任何算法(无 torch/mediapipe/opencv 依赖)。
"""
import asyncio
import logging
import time
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from gateway.config import load_config
# ---------------------------------------------------------------------------
# 日志
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("gateway")
# ---------------------------------------------------------------------------
# 应用生命周期
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
"""启动:加载配置、初始化健康池;关闭:清理资源。"""
# 启动
cfg = load_config()
logger.info("网关启动中... workers=%s", cfg["workers"])
# 初始化健康池(阶段二实现)
try:
from gateway.pool import init_pool, shutdown_pool as _pool_shutdown
_has_pool = True
except ImportError:
logger.warning("pool 模块未就绪,跳过健康池初始化")
_has_pool = False
if _has_pool:
await init_pool(cfg)
app.state._pool_shutdown = _pool_shutdown
else:
app.state._pool_shutdown = None
# 确保标注图目录存在
static_dir = Path(cfg["static_dir"])
static_dir.mkdir(parents=True, exist_ok=True)
logger.info("标注图目录: %s", static_dir)
# 启动定期清理任务(阶段四)
cleanup_shutdown = asyncio.Event()
cleanup_task = asyncio.create_task(
_cleanup_loop(static_dir, cfg, cleanup_shutdown)
)
app.state._cleanup_shutdown = cleanup_shutdown
app.state._cleanup_task = cleanup_task
yield
# 关闭
logger.info("网关关闭中...")
# 停止清理任务
if app.state._cleanup_shutdown:
app.state._cleanup_shutdown.set()
if app.state._cleanup_task:
app.state._cleanup_task.cancel()
try:
await app.state._cleanup_task
except asyncio.CancelledError:
pass
if app.state._pool_shutdown:
await app.state._pool_shutdown()
logger.info("网关已关闭")
# ---------------------------------------------------------------------------
# 创建应用
# ---------------------------------------------------------------------------
app = FastAPI(
title="旷视五接口 — 网关",
version="0.1.0",
description="外网网关:反向代理 5 个接口到高性能 worker 池。",
lifespan=lifespan,
)
# 静态文件托管(阶段四完善)
static_root = Path(__file__).resolve().parent.parent / "static"
static_root.mkdir(parents=True, exist_ok=True)
(static_root / "annotations").mkdir(parents=True, exist_ok=True)
app.mount("/static", StaticFiles(directory=str(static_root)), name="static")
# ---------------------------------------------------------------------------
# 健康检查(网关自身)
# ---------------------------------------------------------------------------
def _get_pool_status_safe():
"""安全获取池状态(pool 未就绪时返回占位值)。"""
try:
from gateway.pool import get_pool_status
return get_pool_status()
except ImportError:
return {"total": 0, "healthy": 0, "busy": 0}
async def _cleanup_loop(annotations_dir: Path, cfg: dict, shutdown: asyncio.Event):
"""定期清理 static/annotations/ 中过期的标注图文件。
配置项(可选,在 config.json 中设定):
- cleanup.interval_minutes: 清理间隔,默认 60
- cleanup.max_age_hours: 文件保留时长(小时),默认 24
"""
cleanup_cfg = cfg.get("cleanup", {})
interval_s = cleanup_cfg.get("interval_minutes", 60) * 60
max_age_s = cleanup_cfg.get("max_age_hours", 24) * 3600
logger.info(
"清理任务启动 | 间隔=%dmin | 保留=%dh | 目录=%s",
interval_s // 60, max_age_s // 3600, annotations_dir,
)
while not shutdown.is_set():
try:
await asyncio.wait_for(shutdown.wait(), timeout=interval_s)
break # shutdown
except asyncio.TimeoutError:
pass # 正常到时,执行清理
now = time.time()
deleted = 0
for f in annotations_dir.iterdir():
if f.name == ".gitkeep":
continue
if not f.is_file():
continue
try:
age_s = now - f.stat().st_mtime
if age_s > max_age_s:
f.unlink()
deleted += 1
logger.debug("清理过期文件: %s (age=%.1fh)", f.name, age_s / 3600)
except Exception:
logger.warning("清理文件失败: %s", f.name, exc_info=True)
if deleted:
logger.info("清理完成: 删除 %d 个过期文件", deleted)
logger.info("清理任务已停止")
@app.get("/gateway-health", include_in_schema=False)
async def gateway_health():
"""网关自身健康检查(区别于 worker 的 /health)。"""
status = _get_pool_status_safe()
return {
"status": "ok",
"service": "gateway",
"workers_total": status["total"],
"workers_healthy": status["healthy"],
"workers_busy": status["busy"],
}
@app.get("/health", include_in_schema=False)
async def health():
"""兼容旧 /health 路径,返回网关状态。"""
return await gateway_health()
@app.get("/", include_in_schema=False)
async def index():
cfg = load_config()
return {
"service": "旷视五接口 — 网关",
"version": "0.1.0",
"docs": f"{cfg['public_base_url']}/docs",
}
# ---------------------------------------------------------------------------
# 代理路由
# ---------------------------------------------------------------------------
# 所有接口统一走「选 worker → 转发 → 改写 base64 → 返回」链路。
# 使用 Request 对象直接读取并转发,不做业务入参解析(解析在 worker 侧完成)。
# Form/File 声明保留在 OpenAPI extra 中以便文档生成。
def _proxy(request: Request, path: str):
"""延迟导入 proxy_request。"""
from gateway.forward import proxy_request
return proxy_request(request, path)
# 声明各接口的 form 参数用于 OpenAPI schema(实际转发直接读 Request
_MEASURE_FORMS = {
"image_file": {"type": "file", "description": "上传图片文件(JPG/PNG,≤ 1 MB"},
"image_url": {"type": "string", "description": "图片 URL"},
"image_base64": {"type": "string", "description": "图片 base64(需带前缀)"},
}
_GROW_FORMS = {
**_MEASURE_FORMS,
"beauty_enabled": {"type": "boolean", "description": "是否开启美颜效果"},
}
_GROW_B_FORMS = {
"marked_image_file": {"type": "file", "description": "划线图片文件"},
"marked_image_url": {"type": "string", "description": "划线图片 URL"},
"marked_image_base64": {"type": "string", "description": "划线图片 base64"},
"original_image_file": {"type": "file", "description": "原始用户照片文件"},
"original_image_url": {"type": "string", "description": "原始用户照片 URL"},
"original_image_base64": {"type": "string", "description": "原始用户照片 base64"},
}
@app.post("/api/v1/face/measure", tags=["人脸分析"])
async def face_measure(request: Request):
"""接口1:四庭七眼测量标注"""
return await _proxy(request, "/api/v1/face/measure")
@app.post("/api/v1/hair/grow", tags=["生发"])
async def hair_grow(request: Request):
"""接口2C端生发"""
return await _proxy(request, "/api/v1/hair/grow")
@app.post("/api/v1/hair/grow-b", tags=["生发"])
async def hair_grow_b(request: Request):
"""接口3B端生发"""
return await _proxy(request, "/api/v1/hair/grow-b")
@app.post("/api/v1/face/features", tags=["人脸分析"])
async def face_features(request: Request):
"""接口4:用户特征分析"""
return await _proxy(request, "/api/v1/face/features")
@app.post("/api/v1/hairline/generate", tags=["人脸分析"])
async def hairline_generate(request: Request):
"""接口5:发际线PNG生成"""
return await _proxy(request, "/api/v1/hairline/generate")
+27
View File
@@ -0,0 +1,27 @@
{
"workers": [
"http://127.0.0.1:28187",
"http://127.0.0.1:28188"
],
"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
},
"cleanup": {
"interval_minutes": 60,
"max_age_hours": 24
}
}
+124
View File
@@ -0,0 +1,124 @@
"""网关配置加载与校验。
从 gateway/config.json 读取配置,缺字段给默认值,启动时校验必填项。
"""
import json
import logging
from pathlib import Path
from typing import List
logger = logging.getLogger("gateway.config")
CONFIG_PATH = Path(__file__).resolve().parent / "config.json"
DEFAULTS = {
"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,
},
"cleanup": {
"interval_minutes": 60,
"max_age_hours": 24,
},
}
_config_cache = None
def _deep_merge(defaults: dict, overrides: dict) -> dict:
"""递归合并:overrides 中的值覆盖 defaults,嵌套 dict 递归处理。"""
result = defaults.copy()
for key, value in overrides.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = _deep_merge(result[key], value)
else:
result[key] = value
return result
def load_config() -> dict:
"""加载并校验配置文件,结果缓存仅加载一次。"""
global _config_cache
if _config_cache is not None:
return _config_cache
if not CONFIG_PATH.exists():
raise FileNotFoundError(
f"配置文件不存在: {CONFIG_PATH}\n"
f"请从 gateway/config.example.json 复制并修改: "
f"cp gateway/config.example.json gateway/config.json"
)
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
raw = json.load(f)
# 合并默认值
cfg = _deep_merge(DEFAULTS, raw)
# --- 校验 ---
workers: List[str] = cfg.get("workers", [])
if not workers:
raise ValueError("配置错误: workers 列表不能为空,至少需要一个 worker 地址")
for i, w in enumerate(workers):
if not isinstance(w, str) or not w:
raise ValueError(f"配置错误: workers[{i}] 必须是非空字符串")
password: str = cfg.get("shared_password", "")
if not password or password == "REPLACE_ME_ROTATE_PERIODICALLY":
logger.warning(
"⚠ 安全警告: shared_password 未设置或仍为占位值 "
"'REPLACE_ME_ROTATE_PERIODICALLY',请立即更换!"
)
if len(password) < 8:
logger.warning("⚠ 安全警告: shared_password 长度不足 8 位,建议使用更长的密码")
public_base_url: str = cfg.get("public_base_url", "")
if public_base_url.endswith("/"):
cfg["public_base_url"] = public_base_url.rstrip("/")
logger.warning("public_base_url 末尾含 '/',已自动去除")
# 确保 static_dir 是绝对路径
static_dir = Path(cfg["static_dir"])
if not static_dir.is_absolute():
cfg["static_dir"] = str(Path(__file__).resolve().parents[1] / static_dir)
logger.info(
"配置加载完成 | workers=%s | public_base_url=%s | "
"hc_interval=%ds | dispatch_timeout=%ds | queue_wait=%ds",
cfg["workers"],
cfg["public_base_url"],
cfg["health_check"]["interval_seconds"],
cfg["dispatch"]["request_timeout_seconds"],
cfg["dispatch"]["queue_wait_seconds"],
)
_config_cache = cfg
return cfg
def get_config() -> dict:
"""获取已加载的配置(必须先调用 load_config)。"""
if _config_cache is None:
raise RuntimeError("配置尚未加载,请先调用 load_config()")
return _config_cache
def reload_config() -> dict:
"""强制重新加载配置(用于热更新)。"""
global _config_cache
_config_cache = None
return load_config()
+307
View File
@@ -0,0 +1,307 @@
"""请求转发 + base64→URL 改写。
- 用 httpx.AsyncClient 把客户端请求原样转发到选中的 worker
- 附加 X-Internal-Token 头
- 失败重试(换 worker
- 响应中含 *_base64 图片字段 → 解码落盘 → 改写为 *_url
"""
import base64
import logging
import re
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional
import httpx
from fastapi import Request, UploadFile
from fastapi.responses import JSONResponse
from gateway.pool import (
NoWorkerAvailable,
acquire_worker,
mark_worker_unhealthy,
release_worker,
)
logger = logging.getLogger("gateway.forward")
# ---------------------------------------------------------------------------
# 全局 httpx client(连接复用)
# ---------------------------------------------------------------------------
_client: Optional[httpx.AsyncClient] = None
def get_client() -> httpx.AsyncClient:
global _client
if _client is None:
_client = httpx.AsyncClient()
return _client
async def close_client():
global _client
if _client:
await _client.aclose()
_client = None
# ---------------------------------------------------------------------------
# base64 → URL 改写
# ---------------------------------------------------------------------------
# data URI 正则:data:image/png;base64,xxxx
_DATA_URI_RE = re.compile(r"^data:(image/\w+);base64,(.+)$", re.IGNORECASE)
def _decode_data_uri(value: str) -> Optional[bytes]:
"""解析 data URI,返回解码后的字节;不匹配则返回 None。"""
m = _DATA_URI_RE.match(value)
if not m:
return None
try:
return base64.b64decode(m.group(2))
except Exception:
logger.warning("base64 解码失败,保留原文")
return None
def rewrite_base64_to_url(
obj: Any,
public_base_url: str,
static_dir: str,
) -> Any:
"""递归遍历响应 JSON,将所有 *_base64 字段改写为 *_url。
- 识别 key 以 _base64 结尾、值为 data: URI 的字段
- 解码 base64 → 保存到 static_dir/{uuid}.png
- 删除 *_base64 字段,新增 *_url 字段指向公网 URL
"""
if isinstance(obj, dict):
new_dict: Dict[str, Any] = {}
for key, value in obj.items():
if key.endswith("_base64") and isinstance(value, str):
img_bytes = _decode_data_uri(value)
if img_bytes is not None:
# 生成文件名并落盘
filename = f"{uuid.uuid4().hex}.png"
filepath = Path(static_dir) / filename
filepath.write_bytes(img_bytes)
# 构造对外 URL
url_key = key[:-7] + "_url" # "xxx_base64" → "xxx_url"
new_dict[url_key] = f"{public_base_url}/static/annotations/{filename}"
logger.info("base64→URL: %s%s (%d bytes)", key, new_dict[url_key], len(img_bytes))
continue # 跳过原 _base64 key
else:
logger.warning("字段 %s 的值不是有效的 data URI,保留", key)
new_dict[key] = value
else:
new_dict[key] = rewrite_base64_to_url(value, public_base_url, static_dir)
return new_dict
elif isinstance(obj, list):
return [rewrite_base64_to_url(item, public_base_url, static_dir) for item in obj]
else:
return obj
# ---------------------------------------------------------------------------
# 请求转发
# ---------------------------------------------------------------------------
async def proxy_request(request: Request, path: str) -> JSONResponse:
"""代理一次请求到 worker,处理重试与 base64 改写。
流程:
1. acquire worker(排队等待空闲)
2. 重构 multipart/form 请求,加 X-Internal-Token
3. 转发到 worker
4. 成功 → release worker → 改写 base64 → 返回 JSONResponse
5. 失败(连接/超时/5xx/401 → mark unhealthy → retry
6. retry 耗尽 / acquire 失败 → 返回 1007
"""
from gateway.config import get_config
cfg = get_config()
dispatch_cfg = cfg["dispatch"]
token = cfg["shared_password"]
request_timeout = dispatch_cfg["request_timeout_seconds"]
max_retries = dispatch_cfg.get("max_retries", 1)
retry_on_failure = dispatch_cfg.get("retry_on_failure", True)
public_base_url = cfg["public_base_url"]
static_dir = cfg["static_dir"]
# --- 1. 读取客户端请求体 ---
# 获取 form 数据(multipart/form-data
try:
form = await request.form()
except Exception:
# 非 form 请求:尝试读 JSON
body = await request.body()
form = None
# --- 2. 获取 worker(最多重试 max_retries+1 次) ---
attempts = max_retries + 1
last_error_response = None
for attempt in range(attempts):
worker = None
try:
worker = await acquire_worker(cfg)
except NoWorkerAvailable:
logger.warning("无可用 worker,返回 1007")
return JSONResponse(
status_code=503,
content={
"code": 1007,
"message": "后端服务暂不可用,请稍后重试",
"request_id": f"gw-{uuid.uuid4().hex[:8]}",
"data": None,
},
)
try:
client = get_client()
# 构建转发请求
if form is not None:
# multipart/form-data:重构文件和表单字段
req_files: List = []
req_data: Dict[str, str] = {}
for field_name, field_value in form.items():
if isinstance(field_value, UploadFile):
content = await field_value.read()
req_files.append(
(field_name, (field_value.filename or "file", content, field_value.content_type or "application/octet-stream"))
)
else:
req_data[field_name] = str(field_value)
resp = await client.post(
f"{worker.url}{path}",
data=req_data or None,
files=req_files or None,
headers={"X-Internal-Token": token},
timeout=request_timeout,
)
else:
# 回退:直接转发 body + content-type
headers = {"X-Internal-Token": token}
ct = request.headers.get("content-type", "")
if ct:
headers["Content-Type"] = ct
resp = await client.request(
method=request.method,
url=f"{worker.url}{path}",
content=body,
headers=headers,
timeout=request_timeout,
)
# --- 判断响应 ---
if resp.status_code == 401:
# worker 鉴权失败 → 视为 worker 异常
logger.warning("Worker %s 返回 401(鉴权失败),标记不健康", worker.url)
await mark_worker_unhealthy(worker)
await release_worker(worker)
if attempt < attempts - 1:
continue # retry
last_error_response = JSONResponse(
status_code=503,
content={
"code": 1007,
"message": "后端服务暂不可用,请稍后重试",
"request_id": f"gw-{uuid.uuid4().hex[:8]}",
"data": None,
},
)
break
if resp.status_code >= 500:
# worker 内部错误 → 视为该 worker 异常
logger.warning("Worker %s 返回 %d,标记不健康", worker.url, resp.status_code)
await mark_worker_unhealthy(worker)
await release_worker(worker)
if attempt < attempts - 1:
continue
last_error_response = JSONResponse(
status_code=503,
content={
"code": 1007,
"message": "后端服务暂不可用,请稍后重试",
"request_id": f"gw-{uuid.uuid4().hex[:8]}",
"data": None,
},
)
break
# --- 成功:改写 base64 → URL ---
await release_worker(worker)
try:
worker_json = resp.json()
except Exception:
logger.warning("Worker %s 返回非 JSON 响应", worker.url)
return JSONResponse(
status_code=502,
content={
"code": 1007,
"message": "后端服务响应异常",
"request_id": f"gw-{uuid.uuid4().hex[:8]}",
"data": None,
},
)
# 递归改写 base64 图片字段
rewritten = rewrite_base64_to_url(worker_json, public_base_url, static_dir)
return JSONResponse(
status_code=resp.status_code,
content=rewritten,
)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.RemoteProtocolError) as exc:
logger.warning("Worker %s 连接失败: %s", worker.url, exc)
await mark_worker_unhealthy(worker)
await release_worker(worker)
if attempt < attempts - 1:
continue
last_error_response = JSONResponse(
status_code=503,
content={
"code": 1007,
"message": "后端服务暂不可用,请稍后重试",
"request_id": f"gw-{uuid.uuid4().hex[:8]}",
"data": None,
},
)
break
except Exception as exc:
logger.error("转发异常: %s", exc)
await release_worker(worker)
if attempt < attempts - 1:
continue
last_error_response = JSONResponse(
status_code=503,
content={
"code": 1007,
"message": "后端服务暂不可用,请稍后重试",
"request_id": f"gw-{uuid.uuid4().hex[:8]}",
"data": None,
},
)
break
# --- 所有尝试耗尽 ---
return last_error_response or JSONResponse(
status_code=503,
content={
"code": 1007,
"message": "后端服务暂不可用,请稍后重试",
"request_id": f"gw-{uuid.uuid4().hex[:8]}",
"data": None,
},
)
+278
View File
@@ -0,0 +1,278 @@
"""Worker 健康池 + 空闲派发。
- 后台 asyncio 任务周期性探测每个 worker 的 /health
- 连续失败 N 次 → 下线;连续成功 N 次 → 上线
- 每个 worker 一个 busy 标志(per_worker_concurrency=1
- acquire_worker() 从在线池挑空闲 worker;全忙排队;池空→NoWorkerAvailable
- release_worker() 释放 worker
"""
import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Dict, List, Optional
import httpx
logger = logging.getLogger("gateway.pool")
# ---------------------------------------------------------------------------
# 异常
# ---------------------------------------------------------------------------
class NoWorkerAvailable(Exception):
"""无可用 worker(池空或全忙排队超时)。"""
pass
# ---------------------------------------------------------------------------
# 数据结构
# ---------------------------------------------------------------------------
@dataclass
class WorkerState:
"""单个 worker 的运行时状态。"""
url: str
online: bool = False # 初始 offline,等健康检查通过后上线
busy: bool = False
consecutive_failures: int = 0
consecutive_successes: int = 0
# ---------------------------------------------------------------------------
# 全局状态
# ---------------------------------------------------------------------------
_workers: Dict[str, WorkerState] = {}
_pool_condition: Optional[asyncio.Condition] = None
_health_task: Optional[asyncio.Task] = None
_shutdown_event: Optional[asyncio.Event] = None
# ---------------------------------------------------------------------------
# 健康检查后台任务
# ---------------------------------------------------------------------------
async def _check_worker_health(
client: httpx.AsyncClient,
w: WorkerState,
cfg: dict,
) -> None:
"""探测单个 worker 的 /health,更新上下线状态。"""
hc_cfg = cfg["health_check"]
url = f"{w.url}{hc_cfg['path']}"
token = cfg["shared_password"]
try:
resp = await client.get(
url,
headers={"X-Internal-Token": token},
timeout=hc_cfg["timeout_seconds"],
)
if resp.status_code == 200:
w.consecutive_failures = 0
w.consecutive_successes += 1
if w.consecutive_successes >= hc_cfg["healthy_threshold"]:
if not w.online:
w.online = True
logger.info("Worker 上线: %s(连续成功 %d 次)", w.url, w.consecutive_successes)
else:
_mark_failure(w, f"HTTP {resp.status_code}")
except Exception as exc:
_mark_failure(w, str(exc))
def _mark_failure(w: WorkerState, reason: str) -> None:
"""记录一次失败,达到阈值后下线。"""
w.consecutive_successes = 0
w.consecutive_failures += 1
threshold = w.consecutive_failures # used in log
hc_threshold = 2 # default, will be overridden
if w.consecutive_failures >= hc_threshold:
# 实际 threshold 从配置读取,这里先做基本判断
pass
logger.debug("Worker %s 健康检查失败 (%d/%d): %s", w.url, w.consecutive_failures, 99, reason)
async def _health_check_loop(cfg: dict) -> None:
"""后台循环:周期性探测所有 worker 健康状态。"""
hc_cfg = cfg["health_check"]
interval = hc_cfg["interval_seconds"]
unhealthy_threshold = hc_cfg["unhealthy_threshold"]
healthy_threshold = hc_cfg["healthy_threshold"]
token = cfg["shared_password"]
logger.info(
"健康检查循环启动 | 间隔=%ds | 下线阈值=%d | 上线阈值=%d | workers=%d",
interval, unhealthy_threshold, healthy_threshold, len(_workers),
)
async with httpx.AsyncClient() as client:
while not _shutdown_event.is_set():
for w in _workers.values():
url = f"{w.url}{hc_cfg['path']}"
try:
resp = await client.get(
url,
headers={"X-Internal-Token": token},
timeout=hc_cfg["timeout_seconds"],
)
if resp.status_code == 200:
w.consecutive_failures = 0
w.consecutive_successes += 1
if w.consecutive_successes >= healthy_threshold and not w.online:
w.online = True
logger.info("✅ Worker 上线: %s", w.url)
else:
w.consecutive_successes = 0
w.consecutive_failures += 1
if w.consecutive_failures >= unhealthy_threshold and w.online:
w.online = False
logger.warning("⚠ Worker 下线: %sHTTP %d,连续失败 %d 次)",
w.url, resp.status_code, w.consecutive_failures)
except Exception as exc:
w.consecutive_successes = 0
w.consecutive_failures += 1
if w.consecutive_failures >= unhealthy_threshold and w.online:
w.online = False
logger.warning("⚠ Worker 下线: %s%s,连续失败 %d 次)",
w.url, exc, w.consecutive_failures)
# 等待下一次探测(支持快速关闭)
try:
await asyncio.wait_for(_shutdown_event.wait(), timeout=interval)
break # shutdown signaled
except asyncio.TimeoutError:
pass # 正常的 interval 到期
logger.info("健康检查循环已停止")
# ---------------------------------------------------------------------------
# 初始化 / 关闭
# ---------------------------------------------------------------------------
async def init_pool(cfg: dict) -> None:
"""初始化 worker 池并启动健康检查后台任务。"""
global _workers, _pool_condition, _health_task, _shutdown_event
_workers = {url: WorkerState(url=url) for url in cfg["workers"]}
_pool_condition = asyncio.Condition()
_shutdown_event = asyncio.Event()
# 立即做一轮健康检查以快速上线
hc_cfg = cfg["health_check"]
token = cfg["shared_password"]
async with httpx.AsyncClient() as client:
for w in _workers.values():
try:
resp = await client.get(
f"{w.url}{hc_cfg['path']}",
headers={"X-Internal-Token": token},
timeout=hc_cfg["timeout_seconds"],
)
if resp.status_code == 200:
w.consecutive_successes = 1
if hc_cfg["healthy_threshold"] <= 1:
w.online = True
else:
w.consecutive_failures = 1
except Exception:
w.consecutive_failures = 1
# 对于 healthy_threshold == 1 的情况,已在上面的检查中上线
# 标记初始在线状态
online_count = sum(1 for w in _workers.values() if w.online)
logger.info("Worker 池初始化完成 | 总数=%d | 在线=%d", len(_workers), online_count)
# 启动后台健康检查
_health_task = asyncio.create_task(_health_check_loop(cfg))
async def shutdown_pool() -> None:
"""关闭健康检查任务,释放资源。"""
global _shutdown_event, _health_task
if _shutdown_event:
_shutdown_event.set()
if _health_task:
_health_task.cancel()
try:
await _health_task
except asyncio.CancelledError:
pass
_health_task = None
logger.info("Worker 池已关闭")
# ---------------------------------------------------------------------------
# 派发
# ---------------------------------------------------------------------------
async def mark_worker_unhealthy(w: WorkerState) -> None:
"""被动标记:转发失败时立即将该 worker 下线。"""
async with _pool_condition:
if w.online:
w.online = False
w.consecutive_failures += 1
logger.warning("⚠ Worker 被动下线: %s(转发失败)", w.url)
async def acquire_worker(cfg: dict) -> WorkerState:
"""从在线池中获取一个空闲 worker。
全忙则排队等待,最多 queue_wait_seconds;池空立即抛异常。
"""
queue_wait = cfg["dispatch"]["queue_wait_seconds"]
deadline = time.monotonic() + queue_wait
async with _pool_condition:
while True:
# 在线 + 空闲
idle = [w for w in _workers.values() if w.online and not w.busy]
if not idle:
online = [w for w in _workers.values() if w.online]
if not online:
raise NoWorkerAvailable("后端服务暂不可用,请稍后重试")
# 全忙,等待
remaining = deadline - time.monotonic()
if remaining <= 0:
raise NoWorkerAvailable("后端服务繁忙,请稍后重试")
logger.info("所有 worker 全忙(%d 在线),等待 %.1fs...", len(online), remaining)
try:
await asyncio.wait_for(_pool_condition.wait(), timeout=remaining)
except asyncio.TimeoutError:
raise NoWorkerAvailable("后端服务繁忙,请稍后重试")
continue # 重新检查
# 取第一个空闲 worker
w = idle[0]
w.busy = True
logger.debug("Worker 分配: %s", w.url)
return w
async def release_worker(w: WorkerState) -> None:
"""释放 worker,标记为空闲并通知等待者。"""
async with _pool_condition:
w.busy = False
logger.debug("Worker 释放: %s", w.url)
_pool_condition.notify(1)
# ---------------------------------------------------------------------------
# 状态查询
# ---------------------------------------------------------------------------
def get_pool_status() -> dict:
"""返回当前池状态(供 /gateway-health 使用)。"""
if not _workers:
return {"total": 0, "healthy": 0, "busy": 0}
total = len(_workers)
healthy = sum(1 for w in _workers.values() if w.online)
busy = sum(1 for w in _workers.values() if w.busy)
return {"total": total, "healthy": healthy, "busy": busy}
+256
View File
@@ -0,0 +1,256 @@
"""Stub Worker — 模拟高性能后端,用于网关开发与测试。
每个 stub 监听一个端口,提供:
- GET /health → 200(可用 ?fail=1 模拟故障)
- POST /api/v1/face/measure → mock 响应(含 annotated_image_base64
- POST /api/v1/hair/grow → mock 响应
- POST /api/v1/hair/grow-b → mock 响应
- POST /api/v1/face/features → mock 响应
- POST /api/v1/hairline/generate → mock 响应
鉴权:校验 X-Internal-Token 头,不匹配返回 401。
并发模拟:可通过 ?delay=N 让接口 sleep N 秒(默认 1)。
故障模拟:GET /health?fail=1 返回 503。
用法:
python gateway/stub_worker.py --port 28187
python gateway/stub_worker.py --port 28188
"""
import argparse
import base64
import json
import os
import sys
import time
import uuid
from typing import Optional
# 确保项目根在 sys.path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import uvicorn
from fastapi import FastAPI, File, Form, Header, Request, UploadFile
from fastapi.responses import JSONResponse
# ---------------------------------------------------------------------------
# 一小张 PNG 用于 mock base64 返回
# 1x1 白色像素 PNG(最小合法 PNG)
# ---------------------------------------------------------------------------
TINY_PNG_BASE64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
)
TINY_PNG_BYTES = base64.b64decode(TINY_PNG_BASE64)
# ---------------------------------------------------------------------------
# 应用工厂
# ---------------------------------------------------------------------------
def create_app(port: int, password: str) -> FastAPI:
app = FastAPI(
title=f"Stub Worker :{port}",
version="0.1.0",
docs_url=None,
redoc_url=None,
)
# --- 鉴权依赖 ---
def check_token(x_internal_token: Optional[str] = Header(default=None)):
if x_internal_token != password:
return False
return True
# --- 通用 mock data 生成 ---
def mock_ok(data: dict, delay: float = 1.0):
"""模拟处理延迟后返回标准响应。"""
if delay > 0:
time.sleep(delay)
return {
"code": 0,
"message": "success",
"request_id": f"stub-{port}-{uuid.uuid4().hex[:8]}",
"data": data,
}
def mock_err(code: int, message: str):
return {
"code": code,
"message": message,
"request_id": f"stub-{port}-{uuid.uuid4().hex[:8]}",
"data": None,
}
# --- /health ---
@app.get("/health", include_in_schema=False)
async def health(request: Request, x_internal_token: Optional[str] = Header(default=None)):
# /health 也校验 token(生产环境 worker 行为)
if x_internal_token != password:
return JSONResponse(status_code=401, content={"detail": "unauthorized"})
fail = request.query_params.get("fail")
if fail == "1":
return JSONResponse(status_code=503, content={"status": "error"})
return {"status": "ok", "worker": f"stub-{port}"}
# --- 接口1 ---
@app.post("/api/v1/face/measure")
async def face_measure(
request: Request,
x_internal_token: Optional[str] = Header(default=None),
image_file: Optional[UploadFile] = File(default=None),
image_url: Optional[str] = Form(default=None),
image_base64: Optional[str] = Form(default=None),
):
if x_internal_token != password:
return JSONResponse(status_code=401, content={"detail": "unauthorized"})
delay = float(request.query_params.get("delay", "1"))
data = {
"annotated_image_base64": f"data:image/png;base64,{TINY_PNG_BASE64}",
"face_total_height_cm": 13.76,
"four_courts": {
"top_court_cm": 3.44,
"upper_court_cm": 3.44,
"middle_court_cm": 3.44,
"lower_court_cm": 3.44,
"ratios": {
"top_court": 0.25,
"upper_court": 0.25,
"middle_court": 0.25,
"lower_court": 0.25,
},
},
"seven_eyes": {
"eye_width_cm": 3.44,
"face_width_cm": 24.08,
"inter_eye_distance_cm": 3.44,
"ratios": {"eye_width": 0.143, "inter_eye_distance": 0.143},
},
"landmarks": {
"hair_top": {"x": 540, "y": 120},
"hairline": {"x": 540, "y": 430},
"brow_center": {"x": 540, "y": 740},
"nose_bottom": {"x": 540, "y": 1050},
"chin_tip": {"x": 540, "y": 1360},
},
}
return mock_ok(data, delay=delay)
# --- 接口2 ---
@app.post("/api/v1/hair/grow")
async def hair_grow(
request: Request,
x_internal_token: Optional[str] = Header(default=None),
image_file: Optional[UploadFile] = File(default=None),
image_url: Optional[str] = Form(default=None),
image_base64: Optional[str] = Form(default=None),
beauty_enabled: bool = Form(default=False),
):
if x_internal_token != password:
return JSONResponse(status_code=401, content={"detail": "unauthorized"})
delay = float(request.query_params.get("delay", "1"))
data = {
"results": [
{
"image_base64": f"data:image/png;base64,{TINY_PNG_BASE64}",
"hairline_type": "花瓣形",
"order": 1,
},
{
"image_base64": f"data:image/png;base64,{TINY_PNG_BASE64}",
"hairline_type": "波浪形",
"order": 2,
},
]
}
return mock_ok(data, delay=delay)
# --- 接口3 ---
@app.post("/api/v1/hair/grow-b")
async def hair_grow_b(
request: Request,
x_internal_token: Optional[str] = Header(default=None),
marked_image_file: Optional[UploadFile] = File(default=None),
marked_image_url: Optional[str] = Form(default=None),
marked_image_base64: Optional[str] = Form(default=None),
original_image_file: Optional[UploadFile] = File(default=None),
original_image_url: Optional[str] = Form(default=None),
original_image_base64: Optional[str] = Form(default=None),
):
if x_internal_token != password:
return JSONResponse(status_code=401, content={"detail": "unauthorized"})
delay = float(request.query_params.get("delay", "1"))
data = {
"best_hairline_image_base64": f"data:image/png;base64,{TINY_PNG_BASE64}",
"hair_growth_image_base64": f"data:image/png;base64,{TINY_PNG_BASE64}",
"hairline_type": "花瓣形",
}
return mock_ok(data, delay=delay)
# --- 接口4 ---
@app.post("/api/v1/face/features")
async def face_features(
request: Request,
x_internal_token: Optional[str] = Header(default=None),
image_file: Optional[UploadFile] = File(default=None),
image_url: Optional[str] = Form(default=None),
image_base64: Optional[str] = Form(default=None),
):
if x_internal_token != password:
return JSONResponse(status_code=401, content={"detail": "unauthorized"})
delay = float(request.query_params.get("delay", "1"))
features = json.dumps(
{
"face_shape": "鹅蛋脸",
"eyebrow_shape": "柳叶眉",
"facial_age": 26,
"dynamic_static_type": "静态",
"gender": "",
"gene_style": {"label": "面部特征标签", "style": "基因风格示例"},
},
ensure_ascii=False,
)
return mock_ok({"features": features}, delay=delay)
# --- 接口5 ---
@app.post("/api/v1/hairline/generate")
async def hairline_generate(
request: Request,
x_internal_token: Optional[str] = Header(default=None),
image_file: Optional[UploadFile] = File(default=None),
image_url: Optional[str] = Form(default=None),
image_base64: Optional[str] = Form(default=None),
):
if x_internal_token != password:
return JSONResponse(status_code=401, content={"detail": "unauthorized"})
delay = float(request.query_params.get("delay", "1"))
data = {
"hairline_images": [
{
"image_base64": f"data:image/png;base64,{TINY_PNG_BASE64}",
"order": 1,
},
{
"image_base64": f"data:image/png;base64,{TINY_PNG_BASE64}",
"order": 2,
},
],
"best_hairline_center_point": {"x": 540, "y": 430},
}
return mock_ok(data, delay=delay)
return app
# ---------------------------------------------------------------------------
# 入口
# ---------------------------------------------------------------------------
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Stub Worker for gateway testing")
parser.add_argument("--port", type=int, default=28187, help="监听端口(默认 28187")
parser.add_argument("--password", type=str, default="dev-shared-secret-2026", help="共享密码")
args = parser.parse_args()
app = create_app(port=args.port, password=args.password)
print(f"[stub_worker] 启动在 :{args.port},密码={args.password[:4]}...")
uvicorn.run(app, host="127.0.0.1", port=args.port, log_level="warning")
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=Hair Gateway - 外网网关(反向代理到 worker 池)
After=network.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/hair
ExecStart=/home/ubuntu/hair/venv/bin/uvicorn gateway.app:app --host 127.0.0.1 --port 8080
Restart=always
RestartSec=3
# 日志
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
+33
View File
@@ -0,0 +1,33 @@
# 外网网关 nginx 配置
# 切换到网关时:把此文件链接到 /etc/nginx/sites-enabled/ 并重载 nginx
#
# 当前 hair.conf 代理 → 127.0.0.1:8000mock app
# 此文件代理 → 127.0.0.1:8080(网关)
# 切换时只需换 enabled 下的 symlink 即可。
server {
listen 80;
server_name hair.xiangsilian.com;
# 请求体大小限制(网关层兜底)
client_max_body_size 2m;
# 代理到网关
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 网关内部转发可能耗时较长(worker 处理 + 排队)
proxy_read_timeout 90s;
proxy_send_timeout 90s;
}
# 标注图直接由网关托管,nginx 不缓存小图
location /static/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
}
}
+1
View File
@@ -1,3 +1,4 @@
fastapi==0.115.12
uvicorn[standard]==0.34.2
python-multipart==0.0.31
httpx==0.28.1
View File