140 lines
5.3 KiB
Python
140 lines
5.3 KiB
Python
|
|
# 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))
|