# api/html_report_router.py
"""HTML 可视化报告读取代理(D11)。
报告产物唯一持久来源是 RustFS 报告键 html/reports/{filename}(文件名寻址),
本路由以 GET /html/{filename} 提供读取,替代原节点本地 html_output 的
StaticFiles 挂载——API 与 worker 容器文件系统不互通,本地盘从来不是
可依赖的读取来源。
解析顺序(逐级兜底,每次未命中记日志):
1. RustFS 报告键(新产物,裸 HTML / 裸 JSON)
2. HTMLFile 表记录(遗留 html/{hash}.json JSON 包装 {'content'})
3. 节点本地 html_output 目录(存量兜底,compose 共享卷;新产物不再写本地)
已知约束(沿用 StaticFiles 时代的既定姿态,非本次引入):本路由不做认证。
iframe 加载报告时浏览器不会携带 Authorization 头,无法套用 API 鉴权。
"""
import json
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from sqlalchemy import select
from shared.config.settings import settings
from shared.database.database import db_manager
from shared.utils.logger import get_logger
from moldinsight.models import HTMLFile
from moldinsight.storage.rustfs_storage import rustfs_manager
logger = get_logger(__name__)
router = APIRouter()
# 存量兜底目录(模块常量,测试可替换)
LOCAL_HTML_DIR = Path("html_output")
_MEDIA_TYPES = {
".html": "text/html; charset=utf-8",
".json": "application/json",
}
def include_into(app) -> None:
"""挂载到应用根路径——不能进 /api 前缀聚合:URL 形状必须保持
/html/{filename}(持久化 cavity JSON 与前端 iframe 均引用此形状)。
失败语义与 route_registry._safe_include 一致:非 DEBUG 记入
route_load_status['failed'](/api/health 呈现 degraded),DEBUG 直接抛错。
"""
try:
app.include_router(router)
except Exception as exc:
from moldinsight.api.route_registry import route_load_status
route_load_status["failed"].append(
{"label": "HTML报告", "module": __name__, "error": str(exc)}
)
logger.error(f"HTML 报告路由加载失败: {exc}")
if settings.DEBUG:
raise
def _validate_filename(filename: str) -> None:
"""防路径穿越:只允许单段文件名(报告键固定为 html/reports/ 一级平铺)。"""
if not filename or filename.startswith(".") or Path(filename).name != filename:
raise HTTPException(status_code=404, detail=f"报告不存在: {filename}")
def _media_type_for(filename: str) -> str:
return _MEDIA_TYPES.get(Path(filename).suffix.lower(), "application/octet-stream")
async def _download_from_rustfs(filename: str) -> Optional[bytes]:
"""新产物:html/reports/{filename} 裸文件直取。"""
if not rustfs_manager.is_connected:
return None
try:
return await rustfs_manager.download_report_artifact(filename)
except Exception as exc:
logger.debug(f"报告键未命中(继续遗留解析): {filename}: {exc}")
return None
async def _download_from_legacy_record(filename: str) -> Optional[bytes]:
"""遗留 HTMLFile 记录:html/{hash}.json JSON 包装 {'content'}。"""
if not rustfs_manager.is_connected:
return None
async with db_manager.session() as session:
result = await session.execute(
select(HTMLFile)
.where(HTMLFile.filename == filename)
.order_by(HTMLFile.id.desc())
)
record = result.scalars().first()
if record is None:
return None
data = await rustfs_manager.download_file("html_files", record.object_key)
if record.object_key.startswith(rustfs_manager.report_prefix + "/"):
# 新格式记录:报告键直取瞬时失败走到这里,裸文件原样返回
return data
wrapper = json.loads(data.decode("utf-8"))
content = wrapper.get("content")
if content is None:
raise ValueError(f"遗留报告对象缺少 content 字段: {record.object_key}")
return content.encode("utf-8")
def _download_from_local(filename: str) -> Optional[bytes]:
"""存量兜底:旧 worker 写入共享卷 html_output 的历史产物。"""
path = LOCAL_HTML_DIR / filename
if path.is_file():
return path.read_bytes()
return None
@router.get("/html/{filename:path}", summary="读取 HTML 可视化报告")
async def get_html_report(filename: str) -> Response:
_validate_filename(filename)
data = await _download_from_rustfs(filename)
source = "rustfs"
if data is None:
try:
data = await _download_from_legacy_record(filename)
except Exception as exc:
logger.debug(f"遗留记录解析失败(继续本地兜底): {filename}: {exc}")
source = "rustfs-legacy"
if data is None:
data = _download_from_local(filename)
source = "local-fallback"
if data is None:
raise HTTPException(status_code=404, detail=f"报告不存在: {filename}")
if source != "rustfs":
# 存量链路命中留痕,便于评估遗留对象与本地卷的清理时机
logger.info(f"报告经 {source} 链路命中: {filename}")
return Response(content=data, media_type=_media_type_for(filename))