e728dcd226
① D11 HTML 报告 RustFS 单源化(TECH_DEBT P2 清偿):可视化产物写任务临时目录后
裸传报告键 html/reports/{filename}(文件名寻址),/html StaticFiles 挂载删除,
新增 html_report_router 根路径代理(报告键→遗留 JSON 包装→本地卷兜底→404,
防穿越);URL 形状 /html/{filename} 不变,持久化引用零迁移;celery 摘除
html_data 卷,镜像不再烤入陈旧报告;顺带删除 get_stp_file_with_data 死数据块
② OCC 方案 B(D10 清偿):run_occ(op_name, payload) 契约 + 常驻工作进程池
(occ_process_pool + occ_worker 操作注册表),超时/崩溃 terminate 换新补位、
任务级超时 recover 整体重建,残留线程泄漏根治;TopoDS 不跨进程(generate_cavity
分模 + 方案 STEP 持久化全在子进程内,返回 export_manifest);删除内存形状缓存链、
CADExporter.export_mold_results、shape_loader(→ stp_materializer)
③ OCC 方案 A 部署参数:CELERY_CONCURRENCY / CELERY_MAX_TASKS_PER_CHILD 进
Dockerfile.celery + compose + .env.example
④ D2 诚实标注:铝价响应带 source: "simulated",前端按来源渲染标注(原硬编码
"上海期货交易所"属虚假声明),死代码 getAluminumPrice 删除
⑤ CI 门禁:.gitea/workflows/ci.yml 三 job(pytest / 前端构建含 vue-tsc /
openapi 漂移检测)
接口变更三件套随批完成(openapi 76→77 paths + gen:api + 前端构建通过;方案 B
接口面零变化)。测试基线 143 passed, 0 skipped(新增 16 项)。文档六处同步。
Co-Authored-By: Claude Code <noreply@anthropic.com>
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))
|