Files
hair/gateway/reqlog.py
T
2026-08-02 01:27:04 +08:00

217 lines
8.0 KiB
Python
Raw 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.
"""请求日志辅助:multipart 入参解析(标量保留 + 图片存盘转 URL)、出参摘要。
设计原则(与用户约定):
- 入参/出参日志里**绝不内嵌图片 base64**。图片统一存盘后用 URL 引用,保持日志短小。
- 入参图片(image_file / *_base64)补存到 static_dirin_ 前缀),永久保留;
url 输入图本身就在远端,不重新下载,直接记 URL。
- 出参响应在 forward.py 已把 base64 改写成 *_url(无大图),记录完整 data,
但用递归摘要器截断超长结构(landmarks、长字符串),单条上限 ~8KB。
所有解析/存盘均包 try/except,**绝不影响请求转发**。
"""
import base64
import io
import json
import logging
import uuid
from pathlib import Path
from typing import Any, Optional
from python_multipart.multipart import parse_form, parse_options_header
logger = logging.getLogger("gateway.reqlog")
_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
def _sniff_ext(data: bytes) -> str:
"""按 magic byte 嗅探图片扩展名(PNG / 否则 JPEG)。与 forward.py 一致。"""
return "png" if data[:8] == _PNG_MAGIC else "jpg"
def save_image_bytes(
data: bytes,
static_dir: str,
public_base_url: str,
prefix: str = "in_",
) -> Optional[str]:
"""把图片字节存盘并返回公网 URL。失败返回 None(不抛异常)。
存到 static_dir/{prefix}{uuid}.{ext}URL = {public_base_url}/static/annotations/{file}
与 forward.py 的 rewrite_base64_to_url 落盘路径/URL 规则一致;文件永久保留,不自动清理。
"""
if not data:
return None
try:
ext = _sniff_ext(data)
filename = f"{prefix}{uuid.uuid4().hex}.{ext}"
target = Path(static_dir)
target.mkdir(parents=True, exist_ok=True)
(target / filename).write_bytes(data)
return f"{public_base_url}/static/annotations/{filename}"
except Exception as e: # noqa: BLE001
logger.warning("保存输入图片失败: %s", e)
return None
def _decode_b64_text(text: str) -> Optional[bytes]:
"""解码 base64 文本(兼容 data: URI 前缀和裸 base64)。失败返回 None。"""
s = text.strip()
if s.startswith("data:") and "," in s:
s = s.split(",", 1)[1]
try:
return base64.b64decode(s)
except Exception:
return None
def _name(b) -> str:
"""把 field_name(可能是 bytes)安全解码为 str。"""
if isinstance(b, (bytes, bytearray)):
return b.decode("utf-8", "replace")
return str(b)
def extract_form_params(
content_type: str,
body: bytes,
static_dir: str,
public_base_url: str,
) -> dict:
"""解析 multipart/form-data 请求体,返回可安全写入日志的入参 dict。
- 标量字段(gender/hair_style/use_mask/prompt/erode_cm/...):utf-8 解码,超 500 字符截断。
- *_base64 字段:视为图片 → 解码存盘 → 记 {_image, source:"base64", size, url},绝不保留 base64 文本。
- file 字段:读字节存盘 → 记 {_image, source:"file", filename?, ctype?, size, url}。
- 非 multipart:记 {_content_type, _body_bytes}。
任何异常都吞掉并在结果里记 _parse_error,不抛。
"""
result: dict = {}
if not content_type or "multipart/form-data" not in content_type:
result["_content_type"] = content_type or ""
result["_body_bytes"] = len(body)
return result
try:
_mime, options = parse_options_header(content_type)
except Exception as e: # noqa: BLE001
result["_parse_error"] = f"options_header: {e}"
return result
if not options.get(b"boundary"):
result["_parse_error"] = "no boundary in content-type"
return result
def on_field(field) -> None:
try:
name = _name(field.field_name)
val = field.value
val_b = val if isinstance(val, (bytes, bytearray)) else (
val.encode("utf-8") if isinstance(val, str) else b""
)
if name.endswith("_base64"):
text = val_b.decode("utf-8", "replace")
data = _decode_b64_text(text)
if data:
url = save_image_bytes(data, static_dir, public_base_url)
result[name] = {"_image": True, "source": "base64",
"size": len(data), "url": url}
else:
result[name] = {"_image": True, "source": "base64",
"size": len(val_b), "url": None,
"_save_error": "decode failed"}
else:
text = val_b.decode("utf-8", "replace")
if len(text) > 500:
text = text[:500] + f"…(+{len(text) - 500} chars)"
result[name] = text
except Exception as e: # noqa: BLE001
logger.warning("on_field 解析失败 (%s): %s", getattr(field, "field_name", "?"), e)
def on_file(file) -> None:
try:
name = _name(file.field_name)
data = b""
fo = getattr(file, "file_object", None)
if fo is not None:
try:
fo.seek(0)
data = fo.read()
except Exception: # noqa: BLE001
data = b""
size = getattr(file, "size", None)
if size is None:
size = len(data)
entry: dict = {"_image": True, "source": "file", "size": size}
fn = getattr(file, "file_name", None)
if fn:
entry["filename"] = _name(fn)
ct = getattr(file, "content_type", None)
if ct:
entry["ctype"] = _name(ct)
entry["url"] = save_image_bytes(data, static_dir, public_base_url) if data else None
result[name] = entry
except Exception as e: # noqa: BLE001
logger.warning("on_file 解析失败 (%s): %s", getattr(file, "field_name", "?"), e)
try:
# headers: dict[str, bytes],仅需 Content-Type(含 boundary)。
# 不传 Content-Lengthparse_form 会从 BytesIO 读到 EOF。
parse_form(
{"Content-Type": content_type.encode("utf-8")},
io.BytesIO(body),
on_field,
on_file,
)
except Exception as e: # noqa: BLE001
result["_parse_error"] = f"parse_form: {e}"
return result
def summarize_for_log(
obj: Any,
max_str: int = 200,
max_list: int = 5,
max_depth: int = 6,
max_bytes: int = 8192,
) -> Any:
"""递归摘要:截断超长字符串 / 大数组 / 深层嵌套,保证日志短小。
- str 超 max_str → 截断并标注溢出字符数。
- list/tuple 超 max_list → 保留前 max_list 项 + "…(+N items)"。
- 嵌套深度超 max_depth → "<…>"。
- 摘要后 JSON 序列化若超 max_bytes → 退化为 {_truncated, size_bytes, keys} 桩。
"""
def _trunc(o: Any, depth: int) -> Any:
if depth > max_depth:
return "<…>"
if isinstance(o, bool) or o is None:
return o
if isinstance(o, (int, float)):
return o
if isinstance(o, str):
return o[:max_str] + f"…(+{len(o) - max_str} chars)" if len(o) > max_str else o
if isinstance(o, dict):
return {str(k): _trunc(v, depth + 1) for k, v in o.items()}
if isinstance(o, (list, tuple)):
n = len(o)
if n <= max_list:
return [_trunc(v, depth + 1) for v in o]
return [_trunc(v, depth + 1) for v in o[:max_list]] + [f"…(+{n - max_list} items)"]
return str(o)[:max_str]
summarized = _trunc(obj, 0)
try:
raw = json.dumps(summarized, ensure_ascii=False).encode("utf-8")
if len(raw) > max_bytes:
keys = list(summarized.keys()) if isinstance(summarized, dict) else None
return {"_truncated": True, "size_bytes": len(raw), "keys": keys}
except Exception: # noqa: BLE001
return {"_truncated": True}
return summarized