批次4后续专项完成:D11清偿 + OCC方案B实施 + 部署参数 + D2诚实标注 + CI门禁
① 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>
This commit is contained in:
@@ -4,7 +4,8 @@
|
||||
- 装载失败返回 None 且不缓存失败(与原实现一致,端点统一 503「服务不可用」)
|
||||
- 实例缓存:设计/加工模块为纯 Python 计算(构造后无 self 突变,方法仅读入参),
|
||||
可安全地被 asyncio.to_thread 并发调用;OCC 相关的 side_action_designer
|
||||
必须经 processing_service.run_occ 的单线程 executor 使用
|
||||
经 processing_service.run_occ 的常驻 OCC 进程池使用(方案 B,见
|
||||
docs/topics/performance/OCC_THROUGHPUT.md)
|
||||
"""
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
- 请求体一律 Pydantic 模型(原 request.json() 手动解析退役,校验失败统一 422)
|
||||
- 纯 Python 设计计算统一经 asyncio.to_thread 投放线程池,不阻塞事件循环;
|
||||
OCC 相关的倒扣检测仍走 processing_service.run_occ 的单线程 executor
|
||||
(PythonOCC 非线程安全)
|
||||
OCC 相关的倒扣检测经 processing_service.run_occ 的常驻 OCC 进程池
|
||||
(PythonOCC 非线程安全,进程内串行;见 OCC_THROUGHPUT.md 方案 B)
|
||||
"""
|
||||
import asyncio
|
||||
from typing import List, Optional
|
||||
@@ -165,17 +165,21 @@ async def detect_undercuts(
|
||||
if not sd:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
|
||||
# 从持久化 STP 原件重建几何(此前传 shape=None 会被兜底吞掉,永远返回"无倒扣")
|
||||
from moldinsight.services.shape_loader import get_shape_loader
|
||||
shape = await get_shape_loader().load_shape_for_task(db_session, body.task_id)
|
||||
if shape is None:
|
||||
# 方案 B:从持久化 STP 原件落盘,倒扣分析在常驻 OCC 子进程内完成(形状不跨进程)
|
||||
from moldinsight.services.stp_materializer import get_stp_materializer
|
||||
stp_path = await get_stp_materializer().materialize_stp_for_task(db_session, body.task_id)
|
||||
if stp_path is None:
|
||||
raise HTTPException(410, "任务几何不可用:无法从存储重建 STP 形状,请重新上传分析")
|
||||
|
||||
# OCC 操作必须走单线程 executor(PythonOCC 非线程安全)
|
||||
result = await processing_service.run_occ(
|
||||
sd.analyze_and_design,
|
||||
shape,
|
||||
body.parting_direction,
|
||||
body.mold_size.model_dump(),
|
||||
)
|
||||
try:
|
||||
result = await processing_service.run_occ(
|
||||
"detect_undercuts",
|
||||
{
|
||||
"stp_path": str(stp_path),
|
||||
"parting_direction": body.parting_direction,
|
||||
"mold_size": body.mold_size.model_dump(),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
stp_path.unlink(missing_ok=True)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
@@ -178,76 +178,45 @@ async def export_mold_results(
|
||||
},
|
||||
}
|
||||
|
||||
cavity_shapes = processing_service.get_export_shapes(
|
||||
task_id,
|
||||
resolved_scheme_id,
|
||||
)
|
||||
# 方案 B 后主进程不再持有内存形状缓存(get_export_shapes 已删除):
|
||||
# 未命中持久化清单时,从持久化单组件 STEP 现场转换缺失格式,用户无需重新分析
|
||||
filename = task_data.get("filename", f"mold_{task_id}")
|
||||
|
||||
if not cavity_shapes:
|
||||
# 内存 shape 缓存失效(如服务重启):从持久化的单组件 STEP
|
||||
# 现场转换缺失格式,用户无需重新分析
|
||||
artifacts = _get_export_artifacts(task_data)
|
||||
scheme_data = (artifacts.get("schemes") or {}).get(resolved_scheme_id)
|
||||
if scheme_data:
|
||||
base_filename = scheme_data.get("base_filename") or Path(filename).stem
|
||||
regenerated = await processing_service.regenerate_export_from_persisted(
|
||||
task_id=task_id,
|
||||
scheme_id=resolved_scheme_id,
|
||||
formats=formats,
|
||||
components=_expand_components(components),
|
||||
base_filename=base_filename,
|
||||
scheme_files=scheme_data.get("files", []),
|
||||
)
|
||||
if regenerated:
|
||||
regenerated["files"] = _augment_export_files(
|
||||
task_id, regenerated.get("files", [])
|
||||
)
|
||||
# 合并进持久化 manifest,后续请求直接命中持久化路径
|
||||
merged_artifacts = _merge_export_artifacts(artifacts, regenerated)
|
||||
await TaskStorageService().update_task_parameters(
|
||||
db_session,
|
||||
task_id,
|
||||
{"export_artifacts": merged_artifacts},
|
||||
)
|
||||
# D9:存储方法已不再自行 commit,请求侧显式提交
|
||||
await db_session.commit()
|
||||
await redis_task_manager.update_task(
|
||||
task_id, {"export_artifacts": merged_artifacts}
|
||||
)
|
||||
TaskQueryService.invalidate_task_view(task_id)
|
||||
|
||||
return {"status": "success", "data": regenerated}
|
||||
|
||||
raise HTTPException(
|
||||
409,
|
||||
"导出缓存已失效或任务尚未完成,请重新分析后再导出以保证方案一致性",
|
||||
artifacts = _get_export_artifacts(task_data)
|
||||
scheme_data = (artifacts.get("schemes") or {}).get(resolved_scheme_id)
|
||||
if scheme_data:
|
||||
base_filename = scheme_data.get("base_filename") or Path(filename).stem
|
||||
regenerated = await processing_service.regenerate_export_from_persisted(
|
||||
task_id=task_id,
|
||||
scheme_id=resolved_scheme_id,
|
||||
formats=formats,
|
||||
components=_expand_components(components),
|
||||
base_filename=base_filename,
|
||||
scheme_files=scheme_data.get("files", []),
|
||||
)
|
||||
if regenerated:
|
||||
regenerated["files"] = _augment_export_files(
|
||||
task_id, regenerated.get("files", [])
|
||||
)
|
||||
# 合并进持久化 manifest,后续请求直接命中持久化路径
|
||||
merged_artifacts = _merge_export_artifacts(artifacts, regenerated)
|
||||
await TaskStorageService().update_task_parameters(
|
||||
db_session,
|
||||
task_id,
|
||||
{"export_artifacts": merged_artifacts},
|
||||
)
|
||||
# D9:存储方法已不再自行 commit,请求侧显式提交
|
||||
await db_session.commit()
|
||||
await redis_task_manager.update_task(
|
||||
task_id, {"export_artifacts": merged_artifacts}
|
||||
)
|
||||
TaskQueryService.invalidate_task_view(task_id)
|
||||
|
||||
base_filename = Path(filename).stem
|
||||
result = cad_exporter.export_mold_results(
|
||||
cavity_data=cavity_shapes,
|
||||
base_filename=base_filename,
|
||||
formats=formats,
|
||||
components=components,
|
||||
task_id=task_id,
|
||||
scheme_id=resolved_scheme_id,
|
||||
return {"status": "success", "data": regenerated}
|
||||
|
||||
raise HTTPException(
|
||||
409,
|
||||
"导出文件不可用:任务未生成持久化导出清单,请重新分析后再导出",
|
||||
)
|
||||
result["files"] = _augment_export_files(task_id, result.get("files", []))
|
||||
result["source"] = "generated"
|
||||
|
||||
merged_artifacts = _merge_export_artifacts(_get_export_artifacts(task_data), result)
|
||||
await TaskStorageService().update_task_parameters(
|
||||
db_session,
|
||||
task_id,
|
||||
{"export_artifacts": merged_artifacts},
|
||||
)
|
||||
# D9:存储方法已不再自行 commit,请求侧显式提交
|
||||
await db_session.commit()
|
||||
await redis_task_manager.update_task(task_id, {"export_artifacts": merged_artifacts})
|
||||
TaskQueryService.invalidate_task_view(task_id) # parameters 已变更,缓存视图失效
|
||||
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.get("/export-download/{filepath:path}")
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# 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))
|
||||
Reference in New Issue
Block a user