批次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:
@@ -59,6 +59,9 @@ def get_aluminum_current_price() -> Dict:
|
||||
"low": round(price - random.uniform(10, 60), 0),
|
||||
"prev_close": prev_price,
|
||||
"week_ago_price": round(week_price, 0),
|
||||
# D2:数据为模拟走势(见模块 docstring),必须显式声明来源,
|
||||
# 前端按此字段展示"模拟/参考"标注,防止被当作实时行情
|
||||
"source": "simulated",
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +90,7 @@ def get_aluminum_price_history(days: int = 30) -> List[Dict]:
|
||||
"high": high_price,
|
||||
"low": low_price,
|
||||
"close": close_price,
|
||||
"source": "simulated", # D2:与 current 一致,逐项显式声明模拟来源
|
||||
})
|
||||
|
||||
random.seed()
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
批次 3 自 storage_integration_rustfs.py 按职责拆分(见 task_storage_service.py 头注)。
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -270,39 +271,29 @@ class AnalysisStorageService:
|
||||
stp_file_id: int,
|
||||
filename: str,
|
||||
file_path: str,
|
||||
html_content: Optional[str] = None,
|
||||
visualization_type: str = "3d_viewer") -> HTMLFile:
|
||||
"""保存HTML文件到PostgreSQL元数据 + RustFS对象存储"""
|
||||
"""保存HTML文件:正文按报告键裸传 RustFS + PG 仅存元数据(D11)。
|
||||
|
||||
# 1. 获取文件哈希
|
||||
stp_file = await session.get(STPFile, stp_file_id)
|
||||
file_hash = stp_file.file_hash
|
||||
上传键固定 html/reports/{filename}(文件名寻址),读侧 GET /html/{filename}
|
||||
按文件名直取,不再写 JSON 包装对象(遗留 html/{hash}.json 仅由读侧兼容解析)。
|
||||
file_path 为任务内临时目录路径,任务结束即删除,仅供追溯,不作为读取来源。
|
||||
"""
|
||||
html_file_path = Path(file_path)
|
||||
if not html_file_path.exists():
|
||||
raise RuntimeError(f"HTML 可视化文件缺失: {file_path}")
|
||||
html_bytes = html_file_path.read_bytes()
|
||||
|
||||
# 2. 读取HTML内容(如果未提供)
|
||||
if html_content is None:
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
html_content = f.read()
|
||||
except Exception as e:
|
||||
logger.error(f"读取HTML文件失败: {e}")
|
||||
html_content = ""
|
||||
|
||||
# 3. 上传到RustFS
|
||||
html_json = {'content': html_content, 'filename': filename}
|
||||
upload_result = await rustfs_manager.upload_json_data(
|
||||
file_type='html_files',
|
||||
json_data=html_json,
|
||||
file_hash=file_hash
|
||||
object_key = await rustfs_manager.upload_report_artifact(
|
||||
filename, html_bytes, content_type="text/html; charset=utf-8"
|
||||
)
|
||||
|
||||
# 4. 创建PostgreSQL记录
|
||||
html_file = HTMLFile(
|
||||
stp_file_id=stp_file_id,
|
||||
object_key=upload_result['object_key'],
|
||||
storage_bucket=upload_result['bucket'],
|
||||
object_key=object_key,
|
||||
storage_bucket=rustfs_manager.bucket_name,
|
||||
filename=filename,
|
||||
file_path=file_path, # 保留本地路径
|
||||
# 停止双写完整 HTML 进 PG:读取路径走 RustFS(html_json.content),
|
||||
file_path=file_path,
|
||||
# 停止双写完整 HTML 进 PG:读取路径走 /html/{filename} 代理,
|
||||
# PG 仅存对象键与文件名,避免大文本撑爆表
|
||||
html_content=None,
|
||||
visualization_type=visualization_type
|
||||
@@ -313,7 +304,7 @@ class AnalysisStorageService:
|
||||
await session.flush()
|
||||
await session.refresh(html_file)
|
||||
|
||||
logger.info(f"HTML文件保存成功 RustFS: {html_file.id}")
|
||||
logger.info(f"HTML文件保存成功 RustFS: {html_file.id} ({object_key})")
|
||||
return html_file
|
||||
|
||||
async def save_features_and_recommendations(
|
||||
@@ -386,7 +377,6 @@ class AnalysisStorageService:
|
||||
'geometry_data': None,
|
||||
'mesh_data': None,
|
||||
'mold_cavity_data': None,
|
||||
'html_content': None,
|
||||
'features': [],
|
||||
'recommendations': [],
|
||||
'analysis_metrics': None # 新增分析指标字段
|
||||
@@ -418,14 +408,10 @@ class AnalysisStorageService:
|
||||
)
|
||||
result['mesh_data'] = json.loads(mesh_bytes.decode('utf-8'))
|
||||
|
||||
# HTML文件
|
||||
if stp_file.html_file:
|
||||
html_bytes = await rustfs_manager.download_file(
|
||||
file_type='html_files',
|
||||
object_key=stp_file.html_file.object_key
|
||||
)
|
||||
html_json = json.loads(html_bytes.decode('utf-8'))
|
||||
result['html_content'] = html_json.get('content', '')
|
||||
# HTML 正文不再随本视图下载(D11):历史实现把整个 HTML 读进
|
||||
# result['html_content'],但所有调用方只取 geometry/cavity/features,
|
||||
# HTML 的读取入口是 /html/{filename} 代理路由——每次任务查询白下载
|
||||
# 数 MB 正文纯属浪费,已删除
|
||||
except Exception as e:
|
||||
logger.error(f"从RustFS获取数据失败: {e}")
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# services/occ_process_pool.py
|
||||
"""常驻 OCC 工作进程池(方案 B,见 docs/topics/performance/OCC_THROUGHPUT.md)。
|
||||
|
||||
替代原进程内 `ThreadPoolExecutor(max_workers=1)`:
|
||||
- 每个工作进程是一个独立 OCC 通道(OCC 非线程安全,通道内串行),常驻不随任务拉起
|
||||
(spawn 下 import OCC 秒级,按任务拉起会把开销摊到每个任务上)
|
||||
- 超时/崩溃 = terminate() 换新进程补位——进程边界干净回收(线程级无法击杀 C++ 栈,
|
||||
旧方案每次超时滞留 1 个线程)
|
||||
- 输入输出走文件路径 + 普通字典,杜绝 pickle OCC 对象(见 core/occ_worker.py)
|
||||
"""
|
||||
import asyncio
|
||||
import multiprocessing
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from moldinsight.core.occ_worker import worker_main
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class _OccWorker:
|
||||
"""单个 OCC 工作进程的父进程侧封装。"""
|
||||
|
||||
def __init__(self, process, conn):
|
||||
self.process = process
|
||||
self.conn = conn
|
||||
self.lock = asyncio.Lock() # 通道串行:同一进程同时只有一个操作在途
|
||||
|
||||
async def run(self, op_name: str, payload: Dict[str, Any], timeout: float):
|
||||
# 阻塞式管道收发放 asyncio.to_thread,不卡事件循环;
|
||||
# 超时后父进程 terminate() 子进程 → 管道 EOF → 该线程 recv 立即返回,无泄漏
|
||||
result = await asyncio.wait_for(
|
||||
asyncio.to_thread(self._run_blocking, op_name, payload),
|
||||
timeout=timeout,
|
||||
)
|
||||
return result
|
||||
|
||||
def _run_blocking(self, op_name: str, payload: Dict[str, Any]):
|
||||
self.conn.send((op_name, payload))
|
||||
status, data = self.conn.recv()
|
||||
if status == "error":
|
||||
raise RuntimeError(data.get("error") or "OCC 子进程操作失败")
|
||||
return data
|
||||
|
||||
|
||||
class OccProcessPool:
|
||||
"""OCC 工作进程池(默认 1 进程 = 1 串行通道,与旧单线程语义一致)。
|
||||
|
||||
池大小与 celery 并发解耦(celery 并发走多 worker 子进程,每个持自己的池)。
|
||||
"""
|
||||
|
||||
def __init__(self, size: int = 1):
|
||||
if size < 1:
|
||||
raise ValueError("size 必须 >= 1")
|
||||
self._size = size
|
||||
self._ctx = multiprocessing.get_context("spawn")
|
||||
self._workers: list[_OccWorker] = []
|
||||
self._rr = 0
|
||||
# 保护 workers 列表与轮转指针;操作执行期不持锁
|
||||
self._pool_lock = asyncio.Lock()
|
||||
# 任务级整体超时(process_file_with_storage 外层 wait_for)时的在途 worker 追踪
|
||||
self._busy: Optional[_OccWorker] = None
|
||||
|
||||
async def run(self, op_name: str, payload: Dict[str, Any], timeout: float = 600):
|
||||
for attempt in range(3):
|
||||
async with self._pool_lock:
|
||||
await self._ensure_started()
|
||||
worker = self._workers[self._rr]
|
||||
self._rr = (self._rr + 1) % len(self._workers)
|
||||
self._busy = worker
|
||||
try:
|
||||
async with worker.lock:
|
||||
return await worker.run(op_name, payload, timeout)
|
||||
except asyncio.TimeoutError:
|
||||
await self._replace(worker)
|
||||
raise
|
||||
except Exception as exc:
|
||||
if not worker.process.is_alive():
|
||||
# OCC segfault 等进程死亡:换新补位后重试该操作
|
||||
logger.warning(f"OCC 工作进程异常退出,重试操作 {op_name}: {exc}")
|
||||
await self._replace(worker)
|
||||
continue
|
||||
raise
|
||||
finally:
|
||||
if self._busy is worker:
|
||||
self._busy = None
|
||||
raise RuntimeError(f"OCC 工作进程连续异常,操作 {op_name} 未能完成")
|
||||
|
||||
async def recover(self):
|
||||
"""任务级整体超时恢复:重建整个池,丢弃可能正卡在挂死 OCC 操作上的进程。
|
||||
|
||||
单通道池重建代价可忽略;重建后下次 run 自动按需补拉。
|
||||
"""
|
||||
async with self._pool_lock:
|
||||
for worker in self._workers:
|
||||
await self._terminate(worker)
|
||||
self._workers = []
|
||||
self._busy = None
|
||||
logger.warning("OCC 进程池已整体重建(任务级超时恢复)")
|
||||
|
||||
async def shutdown(self):
|
||||
async with self._pool_lock:
|
||||
for worker in self._workers:
|
||||
await self._terminate(worker)
|
||||
self._workers = []
|
||||
self._busy = None
|
||||
|
||||
async def _ensure_started(self):
|
||||
if not self._workers:
|
||||
for _ in range(self._size):
|
||||
self._workers.append(self._spawn_one())
|
||||
logger.info(f"OCC 进程池已启动: {self._size} 个常驻工作进程")
|
||||
|
||||
def _spawn_one(self) -> _OccWorker:
|
||||
parent_conn, child_conn = self._ctx.Pipe(duplex=True)
|
||||
proc = self._ctx.Process(target=worker_main, args=(child_conn,), daemon=True)
|
||||
proc.start()
|
||||
child_conn.close() # 父进程侧关闭子端,只留 parent_conn
|
||||
return _OccWorker(proc, parent_conn)
|
||||
|
||||
async def _replace(self, worker: _OccWorker):
|
||||
async with self._pool_lock:
|
||||
await self._terminate(worker)
|
||||
new_worker = self._spawn_one()
|
||||
try:
|
||||
idx = self._workers.index(worker)
|
||||
except ValueError:
|
||||
# 已被并发重建移除,新进程追加补位
|
||||
self._workers.append(new_worker)
|
||||
else:
|
||||
self._workers[idx] = new_worker
|
||||
logger.warning("OCC 工作进程已重建补位")
|
||||
|
||||
@staticmethod
|
||||
async def _terminate(worker: _OccWorker):
|
||||
try:
|
||||
worker.process.terminate()
|
||||
worker.process.join(timeout=5)
|
||||
except Exception as exc:
|
||||
logger.warning(f"终止 OCC 工作进程异常: {exc}")
|
||||
try:
|
||||
worker.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -6,8 +6,6 @@ import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
@@ -15,11 +13,8 @@ from typing import Optional, Dict, Any, List, Tuple
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from moldinsight.core.stp_parser import STPParser
|
||||
from moldinsight.core.geometry_analyzer import GeometryAnalyzer
|
||||
from moldinsight.core.mesh_generator import MeshGenerator
|
||||
from moldinsight.core.multi_scheme_planner import MultiSchemeMoldPlanner
|
||||
from moldinsight.core.cad_exporter import CADExporter
|
||||
from moldinsight.services.occ_process_pool import OccProcessPool
|
||||
from moldinsight.services.task_storage_service import TaskStorageService
|
||||
from moldinsight.services.analysis_storage_service import AnalysisStorageService
|
||||
from moldinsight.storage.rustfs_storage import rustfs_manager
|
||||
@@ -40,46 +35,29 @@ class ProcessingService:
|
||||
"""核心处理流程编排 — 协调 STP 解析、网格、型腔、计算、保存、验证"""
|
||||
|
||||
def __init__(self):
|
||||
self.stp_parser = STPParser()
|
||||
self.geometry_analyzer = GeometryAnalyzer()
|
||||
self.mesh_generator = MeshGenerator(quality="medium")
|
||||
self.html_generator = HTMLGenerator()
|
||||
# 批次 3 按职责拆分:任务/文件生命周期 与 分析结果数据(原 StorageIntegrationService)
|
||||
self.task_storage = TaskStorageService()
|
||||
self.analysis_storage = AnalysisStorageService()
|
||||
self.multi_scheme_planner = MultiSchemeMoldPlanner()
|
||||
# cad_exporter 仅用于导出文件条目构建/输出目录(export_artifacts 路径语义);
|
||||
# 真正的 OCC 形状导出在子进程内完成(见 core/occ_worker.py)
|
||||
self.cad_exporter = CADExporter()
|
||||
# TopoDS_Shape 为 C++ 原生内存对象,LRU 上限防止长期运行内存只涨不降
|
||||
self._export_shapes_cache: "OrderedDict[str, Dict[str, Dict[str, Any]]]" = OrderedDict()
|
||||
self._export_shapes_cache_max = 32
|
||||
# OCC 非线程安全,max_workers=1 保证所有 OCC 操作序列化执行,避免偶发崩溃
|
||||
self._occ_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="occ")
|
||||
# D11:HTMLGenerator 不再持有常驻实例,可视化产物统一写任务内临时目录后
|
||||
# 上传 RustFS 报告键(见 process_file_core)
|
||||
# OCC 方案 B:所有几何操作(解析/布尔/三角化/倒扣/转换)经常驻 OCC 进程池,
|
||||
# 进程边界干净回收超时/崩溃——替代原线程级单通道 executor(见 OCC_THROUGHPUT.md)
|
||||
self._occ_pool = OccProcessPool()
|
||||
|
||||
# ─── 对外入口 ───
|
||||
|
||||
def _reset_occ_executor(self):
|
||||
"""超时后重建 OCC executor。
|
||||
async def run_occ(self, op_name: str, payload: Dict[str, Any],
|
||||
timeout: float = 600) -> Any:
|
||||
"""在常驻 OCC 工作进程中执行同步几何操作(方案 B)。
|
||||
|
||||
asyncio.wait_for 只能取消协程,正在执行 OCC 布尔运算的线程无法中断;
|
||||
单 worker executor 中一个挂死线程会让后续任务永久排队直至重启。
|
||||
cancel_futures=True 丢弃旧 executor 中尚未开跑的排队任务(否则旧线程
|
||||
恢复后仍会继续消化旧队列,与新 executor 并发操作 OCC 必然崩溃)。
|
||||
已在运行中的 C++ 线程在 Python 层不可杀,仍会滞留——这是已知残留
|
||||
泄漏(每次超时 1 线程),根治需进程级 OCC 隔离,见 docs/OCC_THROUGHPUT.md。
|
||||
OCC 非线程安全,所有几何计算统一经本入口在独立进程中串行执行;
|
||||
超时/进程崩溃由进程池 terminate + 换新补位,干净回收(见 OCC_THROUGHPUT.md)。
|
||||
op_name 须已在 occ_worker._OPS 注册;payload 只含文件路径 + 普通字典。
|
||||
"""
|
||||
old = self._occ_executor
|
||||
self._occ_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="occ")
|
||||
old.shutdown(wait=False, cancel_futures=True)
|
||||
logger.warning("OCC executor 已因处理超时重建(排队任务已丢弃,运行中线程可能滞留 1 个)")
|
||||
|
||||
async def run_occ(self, fn, *args):
|
||||
"""在 OCC 单线程 executor 中执行同步几何操作。
|
||||
|
||||
OCC 非线程安全,所有几何计算(解析/布尔/三角化)统一经由本入口串行执行,
|
||||
避免各调用方自行创建线程池造成并发崩溃。
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(self._occ_executor, fn, *args)
|
||||
return await self._occ_pool.run(op_name, payload, timeout=timeout)
|
||||
|
||||
async def _materialize_source_file(self, stp_file: STPFile) -> Tuple[Path, Optional[Path]]:
|
||||
"""把待处理文件落到本地磁盘,返回 (本地路径, 临时目录或 None)。
|
||||
@@ -164,8 +142,8 @@ class ProcessingService:
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"处理超时: {task_id}")
|
||||
# OCC 线程无法取消:抛弃整个 executor,避免挂死线程堵死后续所有任务
|
||||
self._reset_occ_executor()
|
||||
# OCC 进程无法取消:整体重建进程池,干净杀掉可能卡死的 OCC 操作
|
||||
await self._occ_pool.recover()
|
||||
raise Exception(f"处理超时,超过{timeout_seconds}秒未完成")
|
||||
|
||||
except Exception as e:
|
||||
@@ -214,12 +192,9 @@ class ProcessingService:
|
||||
)
|
||||
|
||||
stage_started = time.perf_counter()
|
||||
loop = asyncio.get_running_loop()
|
||||
shape = await loop.run_in_executor(
|
||||
self._occ_executor, self.stp_parser.load_step_file, Path(file_path)
|
||||
)
|
||||
geometry_data = await loop.run_in_executor(
|
||||
self._occ_executor, self.stp_parser.analyze_geometry, shape
|
||||
# 方案 B:解析 + 几何分析在 OCC 子进程内一步完成(形状不跨进程)
|
||||
geometry_data = await self.run_occ(
|
||||
"parse_stp", {"stp_path": file_path}, timeout=timeout_seconds
|
||||
)
|
||||
stage_timings["parse_stp"] = round(time.perf_counter() - stage_started, 3)
|
||||
|
||||
@@ -230,7 +205,8 @@ class ProcessingService:
|
||||
|
||||
stage_started = time.perf_counter()
|
||||
mesh_result = await self._step_generate_mesh(
|
||||
shape, geometry_data, file_path, db_session, stp_file_id, task_id
|
||||
geometry_data, file_path, db_session, stp_file_id, task_id,
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
stage_timings["generate_mesh"] = round(time.perf_counter() - stage_started, 3)
|
||||
|
||||
@@ -246,21 +222,13 @@ class ProcessingService:
|
||||
is_foam_material = MaterialService.is_foam_material(requested_material)
|
||||
|
||||
stage_started = time.perf_counter()
|
||||
plan_result = await self._step_generate_cavity(
|
||||
shape, selected_material, is_foam_material, process_params
|
||||
plan_result, export_artifacts = await self._step_generate_cavity(
|
||||
file_path, selected_material, is_foam_material, process_params,
|
||||
task_id, timeout=timeout_seconds,
|
||||
)
|
||||
stage_timings["generate_cavity"] = round(time.perf_counter() - stage_started, 3)
|
||||
export_shapes = {}
|
||||
export_artifacts = None
|
||||
if plan_result:
|
||||
export_shapes = plan_result.pop("_export_shapes", {}) or {}
|
||||
if export_shapes:
|
||||
self._cache_export_shapes(task_id, export_shapes)
|
||||
export_artifacts = self._persist_step_exports(
|
||||
task_id=task_id,
|
||||
original_filename=Path(file_path).name,
|
||||
export_shapes=export_shapes,
|
||||
)
|
||||
# 方案 B:各方案形状的持久化 STEP 已由子进程导出并返回 manifest(export_artifacts),
|
||||
# 主进程不再持有 TopoDS 引用(无跨进程传输)
|
||||
|
||||
# 4. 生成详细JSON数据 — 委托 CalculationService
|
||||
await self.task_storage.update_task_status(
|
||||
@@ -330,49 +298,60 @@ class ProcessingService:
|
||||
lod_data = mesh_result
|
||||
logger.info(f"LOD数据复用成功: {len(lods)} 级 (面数: {[lods[k]['face_count'] for k in sorted(lods.keys())]})")
|
||||
|
||||
detailed_cavity_json = await self._attach_scheme_previews(
|
||||
detailed_cavity_json=detailed_cavity_json,
|
||||
geometry_data=geometry_data,
|
||||
stp_filename=Path(file_path).name,
|
||||
pointcloud_data=pointcloud_data,
|
||||
lod_data=lod_data,
|
||||
)
|
||||
# D11:可视化产物先写任务内临时目录,再统一上传 RustFS 报告键
|
||||
# (html/reports/{filename}),不再落节点本地 html_output——
|
||||
# API 与 worker 容器文件系统不互通,本地盘从来不是可依赖的读取来源
|
||||
html_out_dir = Path(tempfile.mkdtemp(prefix="moldinsight_html_"))
|
||||
try:
|
||||
html_generator = HTMLGenerator(output_dir=str(html_out_dir))
|
||||
|
||||
best_scheme = CalculationService.get_best_scheme(detailed_cavity_json)
|
||||
best_cavity_data = best_scheme.get("cavity_data", {}) if best_scheme else best_cavity_data
|
||||
best_key_info = best_scheme.get("key_info", {}) if best_scheme else best_key_info
|
||||
detailed_cavity_json = await self._attach_scheme_previews(
|
||||
detailed_cavity_json=detailed_cavity_json,
|
||||
geometry_data=geometry_data,
|
||||
stp_filename=Path(file_path).name,
|
||||
pointcloud_data=pointcloud_data,
|
||||
lod_data=lod_data,
|
||||
html_generator=html_generator,
|
||||
)
|
||||
|
||||
# 8. 保存模具型腔数据(包含方案级预览链接)
|
||||
await self.analysis_storage.save_mold_cavity_data(
|
||||
db_session, stp_file_id, detailed_cavity_json
|
||||
)
|
||||
best_scheme = CalculationService.get_best_scheme(detailed_cavity_json)
|
||||
best_cavity_data = best_scheme.get("cavity_data", {}) if best_scheme else best_cavity_data
|
||||
best_key_info = best_scheme.get("key_info", {}) if best_scheme else best_key_info
|
||||
|
||||
html_file_path = self.html_generator.generate_and_save_visualization(
|
||||
geometry_data,
|
||||
Path(file_path).name,
|
||||
cavity_data=best_cavity_data,
|
||||
pointcloud_data=pointcloud_data,
|
||||
lod_data=lod_data,
|
||||
)
|
||||
# 8. 保存模具型腔数据(包含方案级预览链接)
|
||||
await self.analysis_storage.save_mold_cavity_data(
|
||||
db_session, stp_file_id, detailed_cavity_json
|
||||
)
|
||||
|
||||
await self.analysis_storage.save_html_file(
|
||||
db_session,
|
||||
stp_file_id,
|
||||
Path(html_file_path).name,
|
||||
html_file_path,
|
||||
)
|
||||
html_file_path = html_generator.generate_and_save_visualization(
|
||||
geometry_data,
|
||||
Path(file_path).name,
|
||||
cavity_data=best_cavity_data,
|
||||
pointcloud_data=pointcloud_data,
|
||||
lod_data=lod_data,
|
||||
)
|
||||
await self._upload_report_artifacts(Path(html_file_path))
|
||||
|
||||
await self.analysis_storage.save_html_file(
|
||||
db_session,
|
||||
stp_file_id,
|
||||
Path(html_file_path).name,
|
||||
html_file_path,
|
||||
)
|
||||
finally:
|
||||
shutil.rmtree(html_out_dir, ignore_errors=True)
|
||||
stage_timings["persist_artifacts"] = round(time.perf_counter() - stage_started, 3)
|
||||
|
||||
# 9. 分析模具设计
|
||||
stage_started = time.perf_counter()
|
||||
loop = asyncio.get_running_loop()
|
||||
analysis_result = await loop.run_in_executor(
|
||||
self._occ_executor,
|
||||
lambda: self.geometry_analyzer.analyze_mold_design(
|
||||
geometry_data,
|
||||
product_material=requested_material,
|
||||
shape=shape,
|
||||
),
|
||||
analysis_result = await self.run_occ(
|
||||
"analyze_mold_design",
|
||||
{
|
||||
"geometry_data": geometry_data,
|
||||
"product_material": requested_material,
|
||||
"stp_path": file_path,
|
||||
},
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
|
||||
if analysis_result:
|
||||
@@ -486,15 +465,15 @@ class ProcessingService:
|
||||
# ─── 内部步骤 ───
|
||||
|
||||
async def _step_generate_mesh(
|
||||
self, shape, geometry_data: dict, file_path: str,
|
||||
self, geometry_data: dict, file_path: str,
|
||||
db_session: AsyncSession, stp_file_id: int, task_id: str,
|
||||
timeout: float = 600,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""生成多级LOD网格并持久化,一次OCC剖分+trimesh简化,失败不影响主流程"""
|
||||
"""生成多级LOD网格并持久化(方案 B:子进程内一次 OCC 剖分),失败不影响主流程"""
|
||||
mesh_result = None
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
mesh_result = await loop.run_in_executor(
|
||||
self._occ_executor, self.mesh_generator.generate_multi_lod_mesh, shape
|
||||
mesh_result = await self.run_occ(
|
||||
"generate_mesh", {"stp_path": file_path}, timeout=timeout
|
||||
)
|
||||
|
||||
lod0 = mesh_result.get("lods", {}).get("0", {})
|
||||
@@ -550,97 +529,31 @@ class ProcessingService:
|
||||
return mesh_result
|
||||
|
||||
async def _step_generate_cavity(
|
||||
self, shape, selected_material: dict, is_foam_material: bool, process_params: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""生成多方案分模结果。
|
||||
self, file_path: str, selected_material: dict, is_foam_material: bool,
|
||||
process_params: Dict[str, Any], task_id: str, timeout: float = 600,
|
||||
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
|
||||
"""生成多方案分模结果(方案 B:子进程内完成分模 + 方案形状 STEP 导出)。
|
||||
|
||||
D8:型腔是任务的核心产出,生成失败必须让任务 failed——
|
||||
此前异常在此被吞掉置 plan_result=None 继续主流程,最终任务
|
||||
completed,"完成"状态不可信。异常直接向编排层传播。
|
||||
异常直接向编排层传播。返回 (plan_result, export_manifest)。
|
||||
"""
|
||||
if not shape:
|
||||
raise RuntimeError("无有效几何 shape,无法生成模具型腔")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
plan_result = await loop.run_in_executor(
|
||||
self._occ_executor,
|
||||
lambda: self.multi_scheme_planner.generate_plan(
|
||||
shape=shape,
|
||||
material=selected_material,
|
||||
is_foam_material=is_foam_material,
|
||||
process_params=process_params,
|
||||
),
|
||||
result = await self.run_occ(
|
||||
"generate_cavity",
|
||||
{
|
||||
"stp_path": file_path,
|
||||
"task_id": task_id,
|
||||
"material": selected_material,
|
||||
"is_foam_material": is_foam_material,
|
||||
"process_params": process_params,
|
||||
"export_out_dir": os.path.abspath(self.cad_exporter.output_dir),
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
plan_result = result["plan_result"]
|
||||
logger.info(
|
||||
f"多方案分模完成: 生成 {len(plan_result.get('candidate_schemes', []))} 套方案"
|
||||
)
|
||||
return plan_result
|
||||
|
||||
def _cache_export_shapes(self, task_id: str, export_shapes: Dict[str, Dict[str, Any]]):
|
||||
self._export_shapes_cache[task_id] = export_shapes
|
||||
self._export_shapes_cache.move_to_end(task_id)
|
||||
# 逐出最旧任务的形状缓存(连原生 OCC shape 引用一起释放)
|
||||
while len(self._export_shapes_cache) > self._export_shapes_cache_max:
|
||||
self._export_shapes_cache.popitem(last=False)
|
||||
|
||||
def _persist_step_exports(
|
||||
self,
|
||||
task_id: str,
|
||||
original_filename: str,
|
||||
export_shapes: Dict[str, Dict[str, Any]],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not export_shapes:
|
||||
return None
|
||||
|
||||
base_filename = Path(original_filename).stem or f"mold_{task_id}"
|
||||
manifest = {
|
||||
"version": 1,
|
||||
"task_id": task_id,
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"schemes": {},
|
||||
}
|
||||
components = ["cavity", "core", "parting_surface", "product", "a_plate", "b_plate"]
|
||||
|
||||
for scheme_id, cavity_data in export_shapes.items():
|
||||
try:
|
||||
# 持久化装配体 STEP + 逐组件 STEP(后者是重启后按需
|
||||
# 重导出其他格式的几何来源,见 regenerate_export_from_persisted)
|
||||
result = self.cad_exporter.export_persisted_steps(
|
||||
cavity_data=cavity_data,
|
||||
base_filename=base_filename,
|
||||
components=components,
|
||||
task_id=task_id,
|
||||
scheme_id=scheme_id,
|
||||
)
|
||||
manifest["schemes"][scheme_id] = {
|
||||
"base_filename": result.get("base_filename"),
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"files": result.get("files", []),
|
||||
"errors": result.get("errors", []),
|
||||
"total_files": result.get("total_files", 0),
|
||||
"total_errors": result.get("total_errors", 0),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("持久化 STEP 导出失败: task=%s scheme=%s error=%s", task_id, scheme_id, exc)
|
||||
manifest["schemes"][scheme_id] = {
|
||||
"base_filename": base_filename,
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"files": [],
|
||||
"errors": [str(exc)],
|
||||
"total_files": 0,
|
||||
"total_errors": 1,
|
||||
}
|
||||
|
||||
return manifest
|
||||
|
||||
def get_export_shapes(self, task_id: str, scheme_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
scheme_map = self._export_shapes_cache.get(task_id)
|
||||
if not scheme_map:
|
||||
return None
|
||||
self._export_shapes_cache.move_to_end(task_id) # LRU 热点保活
|
||||
if scheme_id:
|
||||
return scheme_map.get(scheme_id)
|
||||
return next(iter(scheme_map.values()), None)
|
||||
return plan_result, result["export_manifest"]
|
||||
|
||||
async def regenerate_export_from_persisted(
|
||||
self,
|
||||
@@ -651,11 +564,12 @@ class ProcessingService:
|
||||
base_filename: str,
|
||||
scheme_files: List[Dict[str, Any]],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""内存导出缓存失效(如服务重启)后,从持久化 STEP 重建导出文件。
|
||||
"""持久化 STEP 缓存失效(如服务重启)后,从持久化 STEP 重建导出文件。
|
||||
|
||||
分析期已为每个方案持久化装配体 + 逐组件 STEP;
|
||||
缺失格式(STL/IGES/BRep)读回单组件 STEP 现场转换,
|
||||
用户无需重新分析。所有组件均不可用时返回 None。
|
||||
方案 B 后主进程不再持有 TopoDS 形状(导出持久化发生在子进程),
|
||||
内存缓存路径已随旧 _cache_export_shapes/get_export_shapes 一并删除;
|
||||
本方法读回单组件 STEP 现场转换缺失格式,用户无需重新分析。
|
||||
所有组件均不可用时返回 None。
|
||||
"""
|
||||
format_list = list(dict.fromkeys(formats or ["step", "stl"]))
|
||||
step_files = {
|
||||
@@ -693,7 +607,8 @@ class ProcessingService:
|
||||
os.path.dirname(step_path), f"{base_filename}_{comp}.{fmt}"
|
||||
)
|
||||
ok = await self.run_occ(
|
||||
self.cad_exporter.convert_component_step, step_path, out_path, fmt
|
||||
"convert_component_step",
|
||||
{"step_path": str(step_path), "out_path": str(out_path), "fmt": fmt},
|
||||
)
|
||||
if ok:
|
||||
files.append(
|
||||
@@ -763,8 +678,13 @@ class ProcessingService:
|
||||
stp_filename: str,
|
||||
pointcloud_data: Optional[Dict[str, Any]] = None,
|
||||
lod_data: Optional[Dict[str, Any]] = None,
|
||||
html_generator: HTMLGenerator = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""为候选分模方案生成轻量摘要链接(完整HTML仅最优方案按需生成)"""
|
||||
if html_generator is None:
|
||||
raise ValueError(
|
||||
"html_generator 不能为空(D11:摘要产物统一经任务临时目录上传 RustFS)"
|
||||
)
|
||||
candidate_schemes = detailed_cavity_json.get("candidate_schemes", [])
|
||||
if not candidate_schemes:
|
||||
return detailed_cavity_json
|
||||
@@ -777,10 +697,15 @@ class ProcessingService:
|
||||
base_stem = Path(stp_filename).stem.replace(" ", "_")
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
summary_name = f"mold_{base_stem}_{suffix}_{ts}_summary.json"
|
||||
summary_content = self.html_generator.generate_3d_viewer_summary(
|
||||
summary_content = html_generator.generate_3d_viewer_summary(
|
||||
geometry_data, cavity_data
|
||||
)
|
||||
self.html_generator.save_data_file(summary_content, summary_name)
|
||||
# D11:摘要 JSON 写任务临时目录后直传 RustFS 报告键,不落本地 html_output
|
||||
summary_path = html_generator.save_data_file(summary_content, summary_name)
|
||||
await rustfs_manager.upload_report_artifact(
|
||||
summary_name, Path(summary_path).read_bytes(),
|
||||
content_type="application/json",
|
||||
)
|
||||
scheme["summary_file"] = f"/html/{summary_name}"
|
||||
|
||||
best_scheme = CalculationService.get_best_scheme(detailed_cavity_json)
|
||||
@@ -789,6 +714,26 @@ class ProcessingService:
|
||||
|
||||
return detailed_cavity_json
|
||||
|
||||
async def _upload_report_artifacts(self, html_file_path: Path):
|
||||
"""D11:任务临时目录中的可视化产物(.html / _summary.json / _data.json)
|
||||
上传 RustFS 报告键(html/reports/{filename})。
|
||||
|
||||
HTML 内嵌相对 DATA_URL/SUMMARY_URL 引用两个 JSON,
|
||||
三者必须同键前缀可达(读侧 /html/{filename} 直取)。
|
||||
"""
|
||||
stem = html_file_path.with_suffix("")
|
||||
artifacts = [
|
||||
(html_file_path, "text/html; charset=utf-8"),
|
||||
(stem.with_name(stem.name + "_summary.json"), "application/json"),
|
||||
(stem.with_name(stem.name + "_data.json"), "application/json"),
|
||||
]
|
||||
for path, content_type in artifacts:
|
||||
if not path.exists():
|
||||
raise RuntimeError(f"可视化产物缺失: {path.name}")
|
||||
await rustfs_manager.upload_report_artifact(
|
||||
path.name, path.read_bytes(), content_type=content_type
|
||||
)
|
||||
|
||||
# ─── 指标持久化 ───
|
||||
|
||||
async def _save_analysis_metrics(self, session: AsyncSession, stp_file_id: int, analysis_result: dict):
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
# services/shape_loader.py
|
||||
"""按 task_id 从持久化存储重建 OCC 几何形状。
|
||||
|
||||
任务完成后 TopoDS_Shape 不驻留内存/Redis(原生内存与体积原因),
|
||||
需要几何的端点(倒扣检测、按需重导出等)通过 STP 原件重建:
|
||||
PG(object_key) -> RustFS 下载 -> 临时文件 -> OCC 单线程 executor 解析。
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from moldinsight.models import ProcessingTask, STPFile
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ShapeLoader:
|
||||
"""任务几何重建器"""
|
||||
|
||||
def __init__(self):
|
||||
from moldinsight.core.stp_parser import STPParser
|
||||
from moldinsight.services.processing_service import processing_service
|
||||
|
||||
self._parser = STPParser()
|
||||
self._processing = processing_service
|
||||
|
||||
async def load_shape_for_task(
|
||||
self, db_session: AsyncSession, task_id: str
|
||||
) -> Optional["object"]:
|
||||
"""重建任务的产品几何。任务不存在或 STP 原件不可用时返回 None。"""
|
||||
result = await db_session.execute(
|
||||
select(ProcessingTask, STPFile)
|
||||
.join(STPFile, ProcessingTask.stp_file_id == STPFile.id)
|
||||
.where(ProcessingTask.task_id == task_id)
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
logger.warning(f"几何重建失败:任务不存在 {task_id}")
|
||||
return None
|
||||
|
||||
_, stp_file = row
|
||||
if not stp_file.object_key:
|
||||
logger.warning(f"几何重建失败:任务缺少 object_key {task_id}")
|
||||
return None
|
||||
|
||||
from moldinsight.storage.rustfs_storage import rustfs_manager
|
||||
|
||||
try:
|
||||
data = await rustfs_manager.download_file(
|
||||
file_type="stp_files", object_key=stp_file.object_key
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"几何重建失败:STP 原件下载失败 {task_id}: {exc}")
|
||||
return None
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".stp", delete=False) as tmp:
|
||||
tmp.write(data)
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
try:
|
||||
shape = await self._processing.run_occ(
|
||||
self._parser.load_step_file, tmp_path
|
||||
)
|
||||
return shape
|
||||
except Exception as exc:
|
||||
logger.error(f"几何重建失败:STP 解析失败 {task_id}: {exc}")
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# 惰性单例:__init__ 会实例化 STPParser 并校验 OCC 可用性,
|
||||
# 延迟到首次真实使用,避免模块导入期失败拖垮路由加载
|
||||
_shape_loader: Optional[ShapeLoader] = None
|
||||
|
||||
|
||||
def get_shape_loader() -> ShapeLoader:
|
||||
global _shape_loader
|
||||
if _shape_loader is None:
|
||||
_shape_loader = ShapeLoader()
|
||||
return _shape_loader
|
||||
@@ -0,0 +1,76 @@
|
||||
# services/stp_materializer.py
|
||||
"""按 task_id 把 STP 原件从持久化存储落盘为临时文件(方案 B)。
|
||||
|
||||
任务完成后 TopoDS_Shape 不驻留内存/Redis;需要几何的端点(倒扣检测、
|
||||
按需重导出等)通过 STP 原件重建:PG(object_key) -> RustFS 下载 -> 临时文件,
|
||||
OCC 解析在常驻子进程内完成(occ_worker 的 detect_undercuts 等操作,见
|
||||
occ_process_pool)。本模块只负责把原件落到调用方可传路径的临时文件。
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from moldinsight.models import ProcessingTask, STPFile
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class STPMaterializer:
|
||||
"""任务 STP 原件落盘器"""
|
||||
|
||||
async def materialize_stp_for_task(
|
||||
self, db_session: AsyncSession, task_id: str
|
||||
) -> Optional[Path]:
|
||||
"""重建任务的产品几何原件为临时文件。
|
||||
|
||||
任务不存在或 STP 原件不可用时返回 None;返回的临时文件由调用方
|
||||
finally 清理(unlink)。
|
||||
"""
|
||||
result = await db_session.execute(
|
||||
select(ProcessingTask, STPFile)
|
||||
.join(STPFile, ProcessingTask.stp_file_id == STPFile.id)
|
||||
.where(ProcessingTask.task_id == task_id)
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
logger.warning(f"STP 原件落盘失败:任务不存在 {task_id}")
|
||||
return None
|
||||
|
||||
_, stp_file = row
|
||||
if not stp_file.object_key:
|
||||
logger.warning(f"STP 原件落盘失败:任务缺少 object_key {task_id}")
|
||||
return None
|
||||
|
||||
from moldinsight.storage.rustfs_storage import rustfs_manager
|
||||
|
||||
try:
|
||||
data = await rustfs_manager.download_file(
|
||||
file_type="stp_files", object_key=stp_file.object_key
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"STP 原件落盘失败:下载失败 {task_id}: {exc}")
|
||||
return None
|
||||
|
||||
original_name = Path(stp_file.original_filename or "model.stp").name or "model.stp"
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
suffix=Path(original_name).suffix or ".stp", prefix=f"mold_{task_id}_", delete=False
|
||||
)
|
||||
tmp.write(data)
|
||||
tmp.close()
|
||||
logger.debug(f"STP 原件已落盘: {tmp.name}")
|
||||
return Path(tmp.name)
|
||||
|
||||
|
||||
# 惰性单例
|
||||
_stp_materializer: Optional[STPMaterializer] = None
|
||||
|
||||
|
||||
def get_stp_materializer() -> STPMaterializer:
|
||||
global _stp_materializer
|
||||
if _stp_materializer is None:
|
||||
_stp_materializer = STPMaterializer()
|
||||
return _stp_materializer
|
||||
Reference in New Issue
Block a user