This commit is contained in:
xsl
2026-06-14 18:23:25 +08:00
3 changed files with 324 additions and 54 deletions
+43 -53
View File
@@ -11,10 +11,10 @@ import logging
import re import re
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Optional
import httpx import httpx
from fastapi import Request, UploadFile from fastapi import Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from gateway.pool import ( from gateway.pool import (
@@ -56,16 +56,32 @@ async def close_client():
_DATA_URI_RE = re.compile(r"^data:(image/\w+);base64,(.+)$", re.IGNORECASE) _DATA_URI_RE = re.compile(r"^data:(image/\w+);base64,(.+)$", re.IGNORECASE)
def _decode_data_uri(value: str) -> Optional[bytes]: def _decode_base64_value(value: str) -> Optional[bytes]:
"""析 data URI,返回解码后的字节;不匹配则返回 None。""" """码 base64 值(支持 data URI 和原始 base64 两种格式)。
格式1data URI: data:image/png;base64,xxxx
格式2(原始base64): xxxx(无前缀,自动尝试解码)
"""
# 优先匹配 data URI
m = _DATA_URI_RE.match(value) m = _DATA_URI_RE.match(value)
if not m: if m:
return None try:
return base64.b64decode(m.group(2))
except Exception:
logger.warning("data URI base64 解码失败")
return None
# 尝试当作原始 base64 解码(排除明显不是 base64 的短字符串)
stripped = value.strip()
if len(stripped) < 20:
return None # 太短,不可能是图片
try: try:
return base64.b64decode(m.group(2)) decoded = base64.b64decode(stripped)
if len(decoded) >= 50: # 最小合法图片大小
return decoded
except Exception: except Exception:
logger.warning("base64 解码失败,保留原文") pass
return None return None
def rewrite_base64_to_url( def rewrite_base64_to_url(
@@ -75,7 +91,8 @@ def rewrite_base64_to_url(
) -> Any: ) -> Any:
"""递归遍历响应 JSON,将所有 *_base64 字段改写为 *_url。 """递归遍历响应 JSON,将所有 *_base64 字段改写为 *_url。
- 识别 key 以 _base64 结尾、值为 data: URI 的字段 - 识别 key 以 _base64 结尾的字段
- 支持 data: URI 和原始 base64 两种格式
- 解码 base64 → 保存到 static_dir/{uuid}.png - 解码 base64 → 保存到 static_dir/{uuid}.png
- 删除 *_base64 字段,新增 *_url 字段指向公网 URL - 删除 *_base64 字段,新增 *_url 字段指向公网 URL
""" """
@@ -83,7 +100,7 @@ def rewrite_base64_to_url(
new_dict: Dict[str, Any] = {} new_dict: Dict[str, Any] = {}
for key, value in obj.items(): for key, value in obj.items():
if key.endswith("_base64") and isinstance(value, str): if key.endswith("_base64") and isinstance(value, str):
img_bytes = _decode_data_uri(value) img_bytes = _decode_base64_value(value)
if img_bytes is not None: if img_bytes is not None:
# 生成文件名并落盘 # 生成文件名并落盘
filename = f"{uuid.uuid4().hex}.png" filename = f"{uuid.uuid4().hex}.png"
@@ -97,7 +114,7 @@ def rewrite_base64_to_url(
logger.info("base64→URL: %s%s (%d bytes)", key, new_dict[url_key], len(img_bytes)) logger.info("base64→URL: %s%s (%d bytes)", key, new_dict[url_key], len(img_bytes))
continue # 跳过原 _base64 key continue # 跳过原 _base64 key
else: else:
logger.warning("字段 %s 的值不是有效的 data URI,保留", key) logger.warning("字段 %s 的值无法解码为图片,保留", key)
new_dict[key] = value new_dict[key] = value
else: else:
new_dict[key] = rewrite_base64_to_url(value, public_base_url, static_dir) new_dict[key] = rewrite_base64_to_url(value, public_base_url, static_dir)
@@ -133,14 +150,8 @@ async def proxy_request(request: Request, path: str) -> JSONResponse:
public_base_url = cfg["public_base_url"] public_base_url = cfg["public_base_url"]
static_dir = cfg["static_dir"] static_dir = cfg["static_dir"]
# --- 1. 读取客户端请求体 --- # --- 1. 读取客户端请求体(原始字节,不做解析) ---
# 获取 form 数据(multipart/form-data body = await request.body()
try:
form = await request.form()
except Exception:
# 非 form 请求:尝试读 JSON
body = await request.body()
form = None
# --- 2. 获取 worker(最多重试 max_retries+1 次) --- # --- 2. 获取 worker(最多重试 max_retries+1 次) ---
attempts = max_retries + 1 attempts = max_retries + 1
@@ -165,40 +176,19 @@ async def proxy_request(request: Request, path: str) -> JSONResponse:
try: try:
client = get_client() client = get_client()
# 构建转发请求 # 直接转发原始请求体(不解析、不重建,保证 multipart 原样透传)
if form is not None: headers = {"X-Internal-Token": token}
# multipart/form-data:重构文件和表单字段 ct = request.headers.get("content-type", "")
req_files: List = [] if ct:
req_data: Dict[str, str] = {} headers["Content-Type"] = ct
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( resp = await client.request(
f"{worker.url}{path}", method="POST",
data=req_data or None, url=f"{worker.url}{path}",
files=req_files or None, content=body,
headers={"X-Internal-Token": token}, headers=headers,
timeout=request_timeout, 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: if resp.status_code == 401:
+6 -1
View File
@@ -2,11 +2,16 @@ server {
listen 80; listen 80;
server_name hair.xiangsilian.com; server_name hair.xiangsilian.com;
client_max_body_size 2m;
location / { location / {
proxy_pass http://127.0.0.1:8000; proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 90s;
proxy_send_timeout 90s;
} }
} }
+275
View File
@@ -0,0 +1,275 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>接口1 — 四庭七眼测量 测试页</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: 1100px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 8px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 24px; }
/* 上传区 */
.upload-card { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 24px; }
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.file-input { flex: 1; min-width: 200px; }
.file-input input[type=file] { width: 100%; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
.btn { padding: 10px 28px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; transition: .2s; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
.btn-outline:hover { background: #f9fafb; }
.upload-hint { font-size: 12px; color: #9ca3af; margin-top: 8px; }
/* 状态提示 */
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; }
.status.info { background: #dbeafe; color: #1e40af; display: block; }
.status.error { background: #fee2e2; color: #991b1b; display: block; }
.status.success { background: #d1fae5; color: #065f46; display: block; }
/* 结果双栏 */
.results { display: flex; gap: 24px; }
.results > div { flex: 1; min-width: 0; background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06); overflow: hidden; }
.results .panel-header { font-weight: 700; font-size: 14px; padding: 14px 18px; border-bottom: 1px solid #f0f0f0; background: #fafafa; display: flex; justify-content: space-between; align-items: center; }
/* JSON 面板 */
.json-panel { max-height: 600px; overflow: auto; }
.json-content { padding: 16px 18px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12.5px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
/* 图片面板 — 叠加显示 */
.img-panel { display: flex; align-items: center; justify-content: center; min-height: 300px; background: #222; position: relative; }
.img-stack { position: relative; display: inline-block; line-height: 0; }
.img-stack img { display: block; max-width: 520px; max-height: 620px; object-fit: contain; }
.img-stack .layer-anno { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: contain; }
.img-panel .placeholder { color: #9ca3af; font-size: 14px; }
/* 叠加控制 */
.anno-controls { display: flex; gap: 12px; align-items: center; font-size: 12px; color: #9ca3af; }
.anno-controls label { display: flex; align-items: center; gap: 4px; cursor: pointer; }
.anno-controls input[type=range] { width: 60px; }
/* 指标卡片 */
.metrics { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; margin-top: 16px; }
.metric { background: #f9fafb; border-radius: 8px; padding: 12px 14px; }
.metric .label { font-size: 11px; color: #9ca3af; text-transform: uppercase; letter-spacing: .5px; }
.metric .value { font-size: 18px; font-weight: 700; color: #111827; margin-top: 2px; }
.metric .unit { font-size: 12px; color: #6b7280; font-weight: 400; }
@media (max-width: 768px) { .results { flex-direction: column; } }
.hidden { display: none !important; }
</style>
</head>
<body>
<div class="container">
<h1>📐 四庭七眼测量 — 接口测试</h1>
<p class="subtitle">POST /api/v1/face/measure &nbsp;|&nbsp; 上传正面照 → 原图+标注叠加显示</p>
<!-- 上传区 -->
<div class="upload-card">
<div class="upload-row">
<div class="file-input">
<input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png">
</div>
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交测试</button>
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除结果</button>
</div>
<div class="upload-hint">
支持 JPG / PNG 正面照 &nbsp;|&nbsp; 单文件 ≤ 1 MB &nbsp;|&nbsp; 也可拖拽图片到文件选择框
</div>
<div id="statusBar" class="status hidden"></div>
</div>
<!-- 指标速览 -->
<div id="metricsBar" class="metrics hidden"></div>
<!-- 结果双栏 -->
<div class="results hidden" id="resultsArea">
<!-- 左:图片叠加 -->
<div>
<div class="panel-header">
<span>🖼️ 原图 + 标注叠加</span>
<div class="anno-controls">
<label><input type="checkbox" id="showBase" checked onchange="toggleBase()"> 底图</label>
<label><input type="checkbox" id="showAnno" checked onchange="toggleAnno()"> 标注层</label>
<label>透明度 <input type="range" id="annoOpacity" min="0" max="100" value="100" oninput="setOpacity()"></label>
</div>
</div>
<div class="img-panel" id="imgPanel">
<span class="placeholder">提交后显示叠加效果</span>
</div>
</div>
<!-- 右:JSON -->
<div>
<div class="panel-header">
<span>📋 JSON 响应</span>
<button class="btn btn-outline btn-sm" onclick="copyJson()">📋 复制</button>
</div>
<div class="json-panel"><pre class="json-content" id="jsonContent"></pre></div>
</div>
</div>
</div>
<script>
const API_BASE = window.location.origin;
let _origDataUrl = null;
function $(id) { return document.getElementById(id); }
function setStatus(text, type) {
const bar = $('statusBar');
bar.textContent = text;
bar.className = 'status ' + type;
}
async function submitTest() {
const fileInput = $('imageFile');
const file = fileInput.files[0];
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
if (file.size > 1_000_000) { setStatus('文件超过 1 MB 限制', 'error'); return; }
const btn = $('submitBtn');
btn.disabled = true;
btn.textContent = '⏳ 请求中...';
setStatus('正在请求 ' + API_BASE + '/api/v1/face/measure ...', 'info');
$('resultsArea').classList.add('hidden');
$('metricsBar').classList.add('hidden');
// 缓存原图 data URL(用于叠加显示)
const reader = new FileReader();
reader.onload = () => { _origDataUrl = reader.result; };
reader.readAsDataURL(file);
const form = new FormData();
form.append('image_file', file);
try {
const resp = await fetch(API_BASE + '/api/v1/face/measure', { method: 'POST', body: form });
const json = await resp.json();
$('jsonContent').textContent = JSON.stringify(json, null, 2);
$('resultsArea').classList.remove('hidden');
if (json.code === 0) {
setStatus('✅ 请求成功 — request_id: ' + json.request_id, 'success');
showOverlay(json.data.annotated_image_url);
renderMetrics(json.data);
$('metricsBar').classList.remove('hidden');
} else {
setStatus('❌ 业务错误 — code: ' + json.code + ' message: ' + json.message, 'error');
$('imgPanel').innerHTML = '<span class="placeholder">请求失败,无标注图</span>';
}
} catch (err) {
setStatus('❌ 网络错误: ' + err.message, 'error');
$('jsonContent').textContent = 'Error: ' + err.message;
$('resultsArea').classList.remove('hidden');
$('imgPanel').innerHTML = '<span class="placeholder">请求失败</span>';
} finally {
btn.disabled = false;
btn.textContent = '🚀 提交测试';
}
}
function showOverlay(annoUrl) {
if (!_origDataUrl) {
// 原图还没读完,等 200ms 再试
setTimeout(() => showOverlay(annoUrl), 200);
return;
}
const checked = $('showAnno').checked ? '' : 'display:none';
const opacity = ($('annoOpacity').value / 100).toFixed(2);
$('imgPanel').innerHTML =
'<div class="img-stack">' +
'<img class="layer-base" src="' + _origDataUrl + '" alt="原图">' +
'<img class="layer-anno" src="' + annoUrl + '" alt="标注层" ' +
'style="opacity:' + opacity + ';' + checked + '" ' +
'onerror="this.style.display=\'none\'">' +
'</div>';
}
function toggleBase() {
const base = document.querySelector('.layer-base');
const anno = document.querySelector('.layer-anno');
const show = $('showBase').checked;
if (base) base.style.display = show ? '' : 'none';
// 底图隐藏时,标注层改为普通图片撑开容器
if (anno) anno.style.position = show ? 'absolute' : 'static';
}
function toggleAnno() {
const el = document.querySelector('.layer-anno');
if (el) el.style.display = $('showAnno').checked ? '' : 'none';
}
function setOpacity() {
const el = document.querySelector('.layer-anno');
if (el) el.style.opacity = ($('annoOpacity').value / 100).toFixed(2);
}
function renderMetrics(data) {
const fc = data.four_courts || {};
const se = data.seven_eyes || {};
const items = [
{ label: '全脸总高', value: data.face_total_height_cm, unit: 'cm' },
{ label: '顶庭', value: fc.top_court_cm, unit: 'cm' },
{ label: '上庭', value: fc.upper_court_cm, unit: 'cm' },
{ label: '中庭', value: fc.middle_court_cm, unit: 'cm' },
{ label: '下庭', value: fc.lower_court_cm, unit: 'cm' },
{ label: '单眼宽度', value: se.eye_width_cm, unit: 'cm' },
{ label: '脸宽', value: se.face_width_cm, unit: 'cm' },
{ label: '两眼间距', value: se.inter_eye_distance_cm, unit: 'cm' },
];
let html = '';
items.forEach(m => {
html += '<div class="metric"><div class="label">' + m.label + '</div><div class="value">' + (m.value ?? '—') + ' <span class="unit">' + m.unit + '</span></div></div>';
});
$('metricsBar').innerHTML = html;
}
function clearResults() {
_origDataUrl = null;
$('resultsArea').classList.add('hidden');
$('metricsBar').classList.add('hidden');
$('statusBar').className = 'status hidden';
$('imageFile').value = '';
$('jsonContent').textContent = '';
$('imgPanel').innerHTML = '<span class="placeholder">提交后显示叠加效果</span>';
}
function copyJson() {
const text = $('jsonContent').textContent;
navigator.clipboard.writeText(text).then(() => {
const btn = event.target;
const orig = btn.textContent;
btn.textContent = '✅ 已复制';
setTimeout(() => btn.textContent = orig, 1500);
});
}
// 拖拽上传
document.addEventListener('DOMContentLoaded', () => {
const dropZone = $('imageFile');
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.style.borderColor = '#2563eb'; });
dropZone.addEventListener('dragleave', () => { dropZone.style.borderColor = '#ddd'; });
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.style.borderColor = '#ddd';
if (e.dataTransfer.files.length) {
dropZone.files = e.dataTransfer.files;
setStatus('已选择: ' + e.dataTransfer.files[0].name, 'info');
}
});
dropZone.addEventListener('change', () => {
if (dropZone.files.length) setStatus('已选择: ' + dropZone.files[0].name, 'info');
});
});
</script>
</body>
</html>