网关开发完成
This commit is contained in:
@@ -4,6 +4,9 @@ __pycache__/
|
||||
.env
|
||||
|
||||
|
||||
# 网关配置(含密码,不入 git)
|
||||
gateway/config.json
|
||||
|
||||
# 生成的标注图、测试输出
|
||||
static/annotations/*
|
||||
!static/annotations/.gitkeep
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# gateway — 外网网关
|
||||
+253
@@ -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):
|
||||
"""接口2:C端生发"""
|
||||
return await _proxy(request, "/api/v1/hair/grow")
|
||||
|
||||
|
||||
@app.post("/api/v1/hair/grow-b", tags=["生发"])
|
||||
async def hair_grow_b(request: Request):
|
||||
"""接口3:B端生发"""
|
||||
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")
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
@@ -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 下线: %s(HTTP %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}
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -0,0 +1,33 @@
|
||||
# 外网网关 nginx 配置
|
||||
# 切换到网关时:把此文件链接到 /etc/nginx/sites-enabled/ 并重载 nginx
|
||||
#
|
||||
# 当前 hair.conf 代理 → 127.0.0.1:8000(mock 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,3 +1,4 @@
|
||||
fastapi==0.115.12
|
||||
uvicorn[standard]==0.34.2
|
||||
python-multipart==0.0.31
|
||||
httpx==0.28.1
|
||||
|
||||
Reference in New Issue
Block a user