Files
2026-08-02 01:27:04 +08:00

589 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""外网网关 — FastAPI 应用。
薄反向代理层:对外保持 HTTPS 接口不变,对内转发到 worker 池。
不跑任何算法(无 torch/mediapipe/opencv 依赖)。
"""
import base64
import json
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from gateway.config import load_config
from gateway.logging_middleware import (
get_available_dates,
get_daily_stats,
get_stats,
init_logging as _init_req_logging,
request_logging_middleware,
)
# ---------------------------------------------------------------------------
# 日志
# ---------------------------------------------------------------------------
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"])
# 初始化请求日志
_init_req_logging(cfg)
# 初始化健康池(阶段二实现)
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)
yield
# 关闭
logger.info("网关关闭中...")
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")
# 请求日志中间件(在所有路由之前,静态文件之后)
app.middleware("http")(request_logging_middleware)
# ---------------------------------------------------------------------------
# 健康检查(网关自身)
# ---------------------------------------------------------------------------
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}
@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"],
"global_max": status.get("global_max", 1),
"global_busy": status.get("global_busy", 0),
"global_waiting": status.get("global_waiting", 0),
}
@app.get("/health", include_in_schema=False)
async def health():
"""兼容旧 /health 路径,返回网关状态。"""
return await gateway_health()
@app.get("/", include_in_schema=False)
async def index():
return {
"service": "旷视五接口 — 网关",
"version": "0.1.0",
"docs": "/docs",
"stats": "/admin/stats",
"timing_dashboard": "/static/api_timing_dashboard.html",
"integration_guide": "/static/integration.html",
"test_pages": {
"if1_measure": "/static/test_interface1.html",
"if2_hair_grow": "/static/test_interface2.html",
"if3_hair_grow_b": "/static/test_interface3.html",
"if4_features": "/static/test_interface4.html",
"if5_hairline": "/static/test_interface5.html",
"if6_measure_v2": "/static/test_interface6.html",
"if7_hair_grow_v2": "/static/test_interface7.html",
"if9_head_mask": "/static/test_interface9.html",
"if10_head_band": "/static/test_interface10.html",
},
}
# ---------------------------------------------------------------------------
# 请求统计仪表盘 HTML
# ---------------------------------------------------------------------------
_STATS_PAGE_HTML = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>网关请求统计</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: 1300px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 4px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 20px; }
.nav { margin-bottom: 20px; }
.nav a { color: #2563eb; text-decoration: none; font-size: 13px; }
.nav a:hover { text-decoration: underline; }
/* 汇总卡片 */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; margin-bottom: 24px; }
.stat-card { background: #fff; border-radius: 12px; padding: 18px 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.stat-card .value { font-size: 28px; font-weight: 700; color: #111827; }
.stat-card .label { font-size: 11px; color: #9ca3af; text-transform: uppercase; letter-spacing: .5px; margin-top: 4px; }
.stat-card.ok .value { color: #059669; }
.stat-card.warn .value { color: #d97706; }
/* 表格 */
.section { margin-bottom: 24px; }
.section h2 { font-size: 16px; margin-bottom: 10px; color: #374151; }
.table-wrap { background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 9px 14px; text-align: left; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
th { background: #f8fafc; font-weight: 700; color: #475569; font-size: 11px; text-transform: uppercase; letter-spacing: .3px; white-space: nowrap; }
tr:hover td { background: #fafbfc; }
td.mono { font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; }
.badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; }
.badge-ok { background: #d1fae5; color: #065f46; }
.badge-err { background: #fee2e2; color: #991b1b; }
.badge-other { background: #f3f4f6; color: #6b7280; }
.duration-fast { color: #059669; }
.duration-mid { color: #d97706; }
.duration-slow { color: #dc2626; }
.footer { text-align: right; font-size: 12px; color: #9ca3af; margin-top: 20px; }
.auto-refresh { display: flex; align-items: center; gap: 8px; }
.auto-refresh input { accent-color: #2563eb; }
.empty { text-align: center; padding: 40px; color: #9ca3af; font-size: 14px; }
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
th, td { padding: 6px 8px; font-size: 12px; }
}
</style>
</head>
<body>
<div class="container">
<h1>📊 网关请求统计</h1>
<p class="subtitle">实时请求监控 &nbsp;|&nbsp; 每 3 秒自动刷新</p>
<div class="nav">
<a href="/">← 返回首页</a> &nbsp;|&nbsp;
<a href="/docs">API 文档</a> &nbsp;|&nbsp;
<a href="/static/integration.html">接入指南</a>
</div>
<!-- 汇总卡片 -->
<div class="stats-grid">
<div class="stat-card"><div class="value" id="totalCount">—</div><div class="label">请求总数</div></div>
<div class="stat-card ok"><div class="value" id="successRate">—</div><div class="label">成功率(code=0</div></div>
<div class="stat-card"><div class="value" id="avgTime">—</div><div class="label">平均响应时间</div></div>
<div class="stat-card"><div class="value" id="minTime">—</div><div class="label">最短响应</div></div>
<div class="stat-card warn"><div class="value" id="maxTime">—</div><div class="label">最长响应</div></div>
</div>
<!-- 按接口 -->
<div class="section">
<h2>📋 按接口统计</h2>
<div class="table-wrap">
<table>
<thead><tr><th>路径</th><th>请求数</th><th>平均耗时</th><th>最大耗时</th><th>成功率</th></tr></thead>
<tbody id="endpointTable"><tr><td class="empty" colspan="5">暂无数据</td></tr></tbody>
</table>
</div>
</div>
<!-- 按 Worker -->
<div class="section">
<h2>🖥️ 按 GPU Worker 统计</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Worker</th><th>请求数</th><th>平均耗时</th><th>成功率</th></tr></thead>
<tbody id="workerTable"><tr><td class="empty" colspan="4">暂无数据</td></tr></tbody>
</table>
</div>
</div>
<!-- 最近请求 -->
<div class="section">
<h2>🕐 最近请求(最新 100 条)</h2>
<div class="table-wrap" style="max-height:600px;overflow:auto;">
<table>
<thead><tr><th>时间</th><th>方法</th><th>路径</th><th>Worker</th><th>HTTP</th><th>业务码</th><th>耗时</th><th>客户端 IP</th></tr></thead>
<tbody id="recentTable"><tr><td class="empty" colspan="8">暂无数据</td></tr></tbody>
</table>
</div>
</div>
<div class="footer">
<label class="auto-refresh">
<input type="checkbox" id="autoRefresh" checked onchange="toggleAutoRefresh()"> 自动刷新(3s
</label>
<span style="margin-left:16px" id="lastUpdated">加载中…</span>
</div>
</div>
<script>
let _timer = null;
function formatDuration(ms) {
if (ms < 1000) return ms.toFixed(1) + 'ms';
if (ms < 60000) return (ms / 1000).toFixed(2) + 's';
return (ms / 60000).toFixed(1) + 'min';
}
function durationClass(ms) {
if (ms < 500) return 'duration-fast';
if (ms < 2000) return 'duration-mid';
return 'duration-slow';
}
function badgeClass(code) {
if (code === 0) return 'badge-ok';
if (code !== null && code !== undefined && code !== 0) return 'badge-err';
return 'badge-other';
}
function badgeText(code) {
if (code === 0) return 'OK';
if (code !== null && code !== undefined) return 'ERR ' + code;
return '—';
}
async function refresh() {
try {
const r = await fetch('/admin/stats/json');
const data = await r.json();
const s = data.summary;
document.getElementById('totalCount').textContent = s.total.toLocaleString();
document.getElementById('successRate').textContent = s.success_rate + '%';
document.getElementById('avgTime').textContent = formatDuration(s.avg_duration_ms);
document.getElementById('minTime').textContent = formatDuration(s.min_duration_ms);
document.getElementById('maxTime').textContent = formatDuration(s.max_duration_ms);
// 按接口
let ehtml = '';
if (data.endpoints.length === 0) {
ehtml = '<tr><td class="empty" colspan="5">暂无数据</td></tr>';
} else {
data.endpoints.forEach(function(e) {
ehtml += '<tr>' +
'<td class="mono">' + e.path + '</td>' +
'<td>' + e.count + '</td>' +
'<td class="' + durationClass(e.avg_duration_ms) + '">' + formatDuration(e.avg_duration_ms) + '</td>' +
'<td>' + formatDuration(e.max_duration_ms) + '</td>' +
'<td><span class="badge ' + badgeClass(0) + '" style="opacity:' + (e.success_rate / 100) + '">' + e.success_rate + '%</span></td>' +
'</tr>';
});
}
document.getElementById('endpointTable').innerHTML = ehtml;
// 按 Worker
let whtml = '';
if (!data.workers || data.workers.length === 0) {
whtml = '<tr><td class="empty" colspan="4">暂无数据</td></tr>';
} else {
data.workers.forEach(function(w) {
whtml += '<tr>' +
'<td class="mono">' + w.worker + '</td>' +
'<td>' + w.count + '</td>' +
'<td class="' + durationClass(w.avg_duration_ms) + '">' + formatDuration(w.avg_duration_ms) + '</td>' +
'<td><span class="badge ' + badgeClass(0) + '" style="opacity:' + (w.success_rate / 100) + '">' + w.success_rate + '%</span></td>' +
'</tr>';
});
}
document.getElementById('workerTable').innerHTML = whtml;
// 最近请求
let rhtml = '';
if (data.recent.length === 0) {
rhtml = '<tr><td class="empty" colspan="8">暂无数据</td></tr>';
} else {
data.recent.forEach(function(entry) {
var ts = entry.timestamp.replace('T', ' ').substring(0, 23);
var workerDisplay = entry.worker || '—';
// 短 worker 显示:只取主机部分
if (workerDisplay.length > 30) {
workerDisplay = workerDisplay.replace(/^https?:\/\//, '').substring(0, 28) + '…';
}
rhtml += '<tr>' +
'<td class="mono">' + ts + '</td>' +
'<td>' + entry.method + '</td>' +
'<td class="mono">' + entry.path + '</td>' +
'<td class="mono" style="font-size:11px">' + workerDisplay + '</td>' +
'<td>' + entry.status_code + '</td>' +
'<td><span class="badge ' + badgeClass(entry.response_code) + '">' + badgeText(entry.response_code) + '</span></td>' +
'<td class="' + durationClass(entry.duration_ms) + '">' + formatDuration(entry.duration_ms) + '</td>' +
'<td class="mono">' + entry.client_ip + '</td>' +
'</tr>';
});
}
document.getElementById('recentTable').innerHTML = rhtml;
document.getElementById('lastUpdated').textContent = '最后更新: ' + new Date().toLocaleTimeString();
} catch(err) {
document.getElementById('lastUpdated').textContent = '加载失败: ' + err.message;
}
}
function toggleAutoRefresh() {
var checked = document.getElementById('autoRefresh').checked;
if (checked) {
_timer = setInterval(refresh, 3000);
} else {
clearInterval(_timer);
_timer = null;
}
}
refresh();
_timer = setInterval(refresh, 3000);
</script>
</body>
</html>"""
# ---------------------------------------------------------------------------
# 请求统计页面
# ---------------------------------------------------------------------------
@app.get("/admin/stats", include_in_schema=False)
async def admin_stats():
"""请求统计仪表盘(HTML 页面)。"""
return HTMLResponse(content=_STATS_PAGE_HTML)
@app.get("/admin/stats/json", include_in_schema=False)
async def admin_stats_json():
"""请求统计数据(JSON,供页面轮询)。"""
return get_stats()
@app.get("/admin/stats/dates", include_in_schema=False)
async def admin_stats_dates():
"""返回日志中出现过的所有本地日期(供耗时看板日期选择器使用)。"""
return {"dates": get_available_dates()}
@app.get("/admin/stats/daily", include_in_schema=False)
async def admin_stats_daily(date: str):
"""按天统计各接口调用耗时(含多发型接口的单次换发型耗时拆解)。"""
return get_daily_stats(date)
# ---------------------------------------------------------------------------
# 代理路由
# ---------------------------------------------------------------------------
# 所有接口统一走「选 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"},
"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"},
}
@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/face/measure-v2", tags=["人脸分析"])
async def face_measure_v2(request: Request):
"""接口6:四庭七眼测量标注 v2(去顶庭 + 去头部端线)"""
return await _proxy(request, "/api/v1/face/measure-v2")
@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=["生发"], deprecated=True)
async def hair_grow_b(request: Request):
"""接口3B端生发(已屏蔽)"""
# 在网关层直接拦截,不转发到 worker 池——对所有上游 worker 立即生效。
import uuid as _uuid
return JSONResponse(status_code=200, content={
"code": 1007, "message": "接口3/api/v1/hair/grow-b)已屏蔽,暂不提供服务",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
@app.post("/api/v1/face/features", tags=["人脸分析"])
async def face_features(
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(需带前缀)"),
):
"""接口4:用户特征分析 — 豆包 5 项 + 本机 MediaPipe 脸型(face/),不经过 worker。"""
import uuid as _uuid
# 三选一校验
provided = [x for x in (image_file, image_url, image_base64) if x]
if len(provided) != 1:
return JSONResponse(status_code=200, content={
"code": 1007, "message": "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
img_bytes = None
if image_file:
img_bytes = await image_file.read()
elif image_base64:
b64 = image_base64
if "," in b64:
b64 = b64.split(",", 1)[1]
try:
img_bytes = base64.b64decode(b64)
except Exception:
return JSONResponse(status_code=200, content={
"code": 1008, "message": "图片格式不支持(base64 解码失败)",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
# 入参日志(接口4 不走 forward.py,自行记录;不获取全局串行槽,因不占 GPU)
try:
from gateway.config import get_config as _get_cfg
from gateway.reqlog import save_image_bytes
_cfg = _get_cfg()
_params: dict = {}
if image_file:
_url = save_image_bytes(img_bytes, _cfg["static_dir"], _cfg["public_base_url"]) if img_bytes else None
_params["image_file"] = {"_image": True, "source": "file",
"size": len(img_bytes) if img_bytes else 0, "url": _url}
elif image_base64:
_url = save_image_bytes(img_bytes, _cfg["static_dir"], _cfg["public_base_url"]) if img_bytes else None
_params["image_base64"] = {"_image": True, "source": "base64",
"size": len(img_bytes) if img_bytes else 0, "url": _url}
elif image_url:
_params["image_url"] = image_url
request.state.log_request_params = _params
except Exception: # noqa: BLE001
request.state.log_request_params = {"_parse_error": "iface4"}
from fastapi.concurrency import run_in_threadpool
from face_features import analyze_features
try:
feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
except Exception as ex:
logger.exception("接口4 豆包调用失败")
return JSONResponse(status_code=200, content={
"code": 1007, "message": f"分析服务异常:{ex}",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
if feats is None:
return JSONResponse(status_code=200, content={
"code": 1001, "message": "无法识别人像",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
return JSONResponse(status_code=200, content={
"code": 0,
"message": "success",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}",
"data": {"features": json.dumps(feats, ensure_ascii=False)},
})
@app.post("/api/v1/hairline/generate", tags=["人脸分析"])
async def hairline_generate(request: Request):
"""接口5:发际线PNG生成"""
return await _proxy(request, "/api/v1/hairline/generate")
@app.post("/api/v1/hair/grow-v2", tags=["生发"])
async def hair_grow_v2(request: Request):
"""接口7C端生发 v2add_hair2 工作流)"""
return await _proxy(request, "/api/v1/hair/grow-v2")
@app.post("/api/v1/head/mask", tags=["人脸分析"])
async def head_mask(request: Request):
"""接口9:头发遮罩生成 + 分步可视化"""
return await _proxy(request, "/api/v1/head/mask")
@app.post("/api/v1/head/band", tags=["人脸分析"])
async def head_band(request: Request):
"""接口10:头部外缘膨胀带遮罩 + 分步可视化"""
return await _proxy(request, "/api/v1/head/band")