批次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:
2026-09-18 17:22:01 +08:00
parent 0e6b3b1811
commit e728dcd226
36 changed files with 1474 additions and 565 deletions
@@ -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
+135 -190
View File
@@ -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):
-88
View File
@@ -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