# 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))