diff --git a/src/api/v1/advanced_router.py b/src/api/v1/advanced_router.py index b68f64a..60f1469 100644 --- a/src/api/v1/advanced_router.py +++ b/src/api/v1/advanced_router.py @@ -1,17 +1,28 @@ from pathlib import Path import os +from datetime import datetime +from urllib.parse import quote from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from services.auth_service import get_current_active_user from services.redis_task_manager import redis_task_manager from services.processing_service import processing_service +from services.storage_integration_rustfs import StorageIntegrationService +from services.task_query_service import TaskQueryService +from database.database import get_db_session from models.database import User +from models.database import ProcessingTask, STPFile +from core.cad_exporter import CADExporter from utils.logger import get_logger logger = get_logger(__name__) router = APIRouter() +cad_exporter = CADExporter() +storage_service = StorageIntegrationService() _api_routes_cache = {} @@ -51,6 +62,125 @@ async def _get_task_data(task_id: str) -> dict: return None +async def _ensure_task_access( + db_session: AsyncSession, + task_id: str, + user_id: int, +): + row = await db_session.execute( + select(ProcessingTask, STPFile) + .join(STPFile, ProcessingTask.stp_file_id == STPFile.id) + .where(ProcessingTask.task_id == task_id) + ) + row = row.first() + if not row: + raise HTTPException(404, "任务不存在") + + _, stp_file = row + owner_id = getattr(stp_file, "user_id", None) + if owner_id is not None and owner_id != user_id: + raise HTTPException(403, "无权访问该任务的导出文件") + + return row + + +def _get_export_artifacts(task_data: dict) -> dict: + if not isinstance(task_data, dict): + return {} + direct = task_data.get("export_artifacts") + if isinstance(direct, dict): + return direct + parameters = task_data.get("parameters") + if isinstance(parameters, dict) and isinstance(parameters.get("export_artifacts"), dict): + return parameters.get("export_artifacts") + return {} + + +def _expand_components(components): + requested = components or ["cavity", "core"] + if "all" in requested: + return ["cavity", "core", "parting_surface"] + return list(dict.fromkeys(requested)) + + +def _augment_export_files(task_id: str, files): + items = [] + for file in files or []: + item = dict(file) + relative_path = item.get("relative_path") + if not relative_path and item.get("filepath"): + relative_path = cad_exporter.get_relative_path(item["filepath"]) + if relative_path: + relative_path = str(relative_path).replace("\\", "/").strip("/") + item["relative_path"] = relative_path + item["download_path"] = f"/api/export-download/{quote(relative_path, safe='/')}?task_id={task_id}" + items.append(item) + return items + + +def _merge_export_artifacts(existing: dict, export_result: dict) -> dict: + merged = dict(existing or {}) + schemes = dict(merged.get("schemes") or {}) + scheme_id = export_result.get("scheme_id") or "default" + previous = dict(schemes.get(scheme_id) or {}) + + file_map = {} + for file in previous.get("files", []): + file_map[(file.get("component"), file.get("format"))] = file + for file in export_result.get("files", []): + file_map[(file.get("component"), file.get("format"))] = file + + schemes[scheme_id] = { + "base_filename": export_result.get("base_filename") or previous.get("base_filename"), + "generated_at": datetime.now().isoformat(), + "files": sorted( + file_map.values(), + key=lambda item: (item.get("component", ""), item.get("format", "")), + ), + "errors": export_result.get("errors", []), + "total_files": len(file_map), + "total_errors": len(export_result.get("errors", [])), + } + + merged["version"] = 1 + merged["task_id"] = export_result.get("task_id") or merged.get("task_id") + merged["generated_at"] = merged.get("generated_at") or datetime.now().isoformat() + merged["schemes"] = schemes + return merged + + +def _select_persisted_files(task_id: str, task_data: dict, scheme_id: str, formats, components): + artifacts = _get_export_artifacts(task_data) + scheme_data = (artifacts.get("schemes") or {}).get(scheme_id) + if not scheme_data: + return None + + component_list = _expand_components(components) + format_list = list(dict.fromkeys(formats or ["step", "stl"])) + expected = {(component, fmt) for component in component_list for fmt in format_list} + + available = [] + available_keys = set() + for file in scheme_data.get("files", []): + component = file.get("component") + fmt = file.get("format") + if component not in component_list or fmt not in format_list: + continue + relative_path = str(file.get("relative_path") or "").replace("\\", "/").strip("/") + if not relative_path: + continue + full_path = os.path.join(cad_exporter.output_dir, relative_path.replace("/", os.sep)) + if not os.path.exists(full_path): + continue + available.append(file) + available_keys.add((component, fmt)) + + if expected and not expected.issubset(available_keys): + return None + + return _augment_export_files(task_id, available) + + @router.post("/optimize-layout") async def optimize_cavity_layout( request: Request, @@ -279,6 +409,7 @@ async def simulate_machining( async def export_mold_results( request: Request, current_user: User = Depends(get_current_active_user), + db_session: AsyncSession = Depends(get_db_session), ): body = await request.json() task_id = body.get("task_id") @@ -289,13 +420,37 @@ async def export_mold_results( if not task_id: raise HTTPException(404, "缺少 task_id") - task_data = await _get_task_data(task_id) + await _ensure_task_access(db_session, task_id, current_user.id) + task_data = await TaskQueryService.get_task_view(db_session, task_id) if not task_data: raise HTTPException(404, "任务不存在") + resolved_scheme_id = scheme_id or task_data.get("best_scheme_id") or "default" + persisted_files = _select_persisted_files( + task_id=task_id, + task_data=task_data, + scheme_id=resolved_scheme_id, + formats=formats, + components=components, + ) + if persisted_files: + return { + "status": "success", + "data": { + "base_filename": Path(task_data.get("filename", f"mold_{task_id}")).stem, + "task_id": task_id, + "scheme_id": resolved_scheme_id, + "files": persisted_files, + "errors": [], + "total_files": len(persisted_files), + "total_errors": 0, + "source": "persisted", + }, + } + cavity_shapes = processing_service.get_export_shapes( task_id, - scheme_id or task_data.get("best_scheme_id"), + resolved_scheme_id, ) filename = task_data.get("filename", f"mold_{task_id}") @@ -305,33 +460,62 @@ async def export_mold_results( "导出缓存已失效或任务尚未完成,请重新分析后再导出以保证方案一致性", ) - exporter = _get_cached("cad_exporter") - if not exporter: - raise HTTPException(503, "服务不可用:核心模块未加载,请检查 PythonOCC 环境") - base_filename = Path(filename).stem - result = exporter.export_mold_results( + 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, ) + 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 storage_service.update_task_parameters( + db_session, + task_id, + {"export_artifacts": merged_artifacts}, + ) + await redis_task_manager.update_task(task_id, {"export_artifacts": merged_artifacts}) + return {"status": "success", "data": result} @router.get("/export-download/{filepath:path}") async def download_export_file( filepath: str, + task_id: str, current_user: User = Depends(get_current_active_user), + db_session: AsyncSession = Depends(get_db_session), ): from fastapi.responses import FileResponse - exporter = _get_cached("cad_exporter") - if not exporter: - raise HTTPException(503, "服务不可用") - full_path = os.path.join(exporter.output_dir, filepath) + + if not task_id: + raise HTTPException(400, "缺少 task_id") + + await _ensure_task_access(db_session, task_id, current_user.id) + task_data = await TaskQueryService.get_task_view(db_session, task_id) + if not task_data: + raise HTTPException(404, "任务不存在") + + allowed_paths = set() + artifacts = _get_export_artifacts(task_data) + for scheme in (artifacts.get("schemes") or {}).values(): + for file in scheme.get("files", []): + relative_path = str(file.get("relative_path") or "").replace("\\", "/").strip("/") + if relative_path: + allowed_paths.add(relative_path) + + normalized_path = str(filepath or "").replace("\\", "/").strip("/") + if normalized_path not in allowed_paths: + raise HTTPException(403, "该文件不在任务允许下载清单中") + + full_path = os.path.join(cad_exporter.output_dir, normalized_path.replace("/", os.sep)) if not os.path.exists(full_path): raise HTTPException(404, "文件不存在") - if not os.path.abspath(full_path).startswith(os.path.abspath(exporter.output_dir)): + if not os.path.abspath(full_path).startswith(os.path.abspath(cad_exporter.output_dir)): raise HTTPException(403, "禁止访问") media_types = { ".step": "application/step", ".stp": "application/step", @@ -348,10 +532,7 @@ async def get_export_recommendations( target: str = "ug", current_user: User = Depends(get_current_active_user), ): - exporter = _get_cached("cad_exporter") - if not exporter: - raise HTTPException(503, "服务不可用") - result = exporter.get_export_recommendations(target) + result = cad_exporter.get_export_recommendations(target) return {"status": "success", "data": result} diff --git a/src/core/cad_exporter.py b/src/core/cad_exporter.py index ddb3523..2fa1980 100644 --- a/src/core/cad_exporter.py +++ b/src/core/cad_exporter.py @@ -26,6 +26,7 @@ FreeCAD 导入建议: """ import os +import re from typing import Dict, List, Any, Optional, Tuple from pathlib import Path from utils.logger import get_logger @@ -40,6 +41,35 @@ class CADExporter: self.output_dir = output_dir os.makedirs(output_dir, exist_ok=True) + @staticmethod + def _safe_segment(value: Optional[str], fallback: str) -> str: + text = str(value or "").strip() + if not text: + text = fallback + text = re.sub(r"[^A-Za-z0-9._-]+", "_", text) + return text[:80] or fallback + + def build_export_dir( + self, + base_filename: str, + task_id: Optional[str] = None, + scheme_id: Optional[str] = None, + ) -> str: + if task_id: + task_segment = self._safe_segment(task_id, "task") + scheme_segment = self._safe_segment(scheme_id, "default") + return os.path.join(self.output_dir, task_segment, scheme_segment) + return os.path.join(self.output_dir, self._safe_segment(base_filename, "mold")) + + def get_relative_path(self, filepath: str) -> str: + full_path = Path(filepath).resolve() + output_root = Path(self.output_dir).resolve() + try: + relative = full_path.relative_to(output_root) + except ValueError: + relative = Path(os.path.basename(filepath)) + return relative.as_posix() + def export_step(self, shape: Any, filepath: str, schema: str = "AP214") -> bool: """ @@ -207,7 +237,9 @@ class CADExporter: def export_mold_results(self, cavity_data: Dict, base_filename: str, formats: List[str] = None, - components: List[str] = None) -> Dict[str, Any]: + components: List[str] = None, + task_id: Optional[str] = None, + scheme_id: Optional[str] = None) -> Dict[str, Any]: """ 批量导出模具设计结果 @@ -225,11 +257,17 @@ class CADExporter: if components is None: components = ["cavity", "core"] - export_dir = os.path.join(self.output_dir, base_filename) + export_dir = self.build_export_dir( + base_filename=base_filename, + task_id=task_id, + scheme_id=scheme_id, + ) os.makedirs(export_dir, exist_ok=True) results = { "base_filename": base_filename, + "task_id": task_id, + "scheme_id": scheme_id, "export_dir": export_dir, "files": [], "errors": [], @@ -277,11 +315,13 @@ class CADExporter: if success: file_size = os.path.getsize(filepath) + relative_path = self.get_relative_path(filepath) results["files"].append({ "component": comp_name, "component_label": label, "format": fmt, "filepath": filepath, + "relative_path": relative_path, "filename": os.path.basename(filepath), "size_bytes": file_size, "size_readable": self._format_file_size(file_size), diff --git a/src/core/parting_scheme_scorer.py b/src/core/parting_scheme_scorer.py index 0f7b1b4..2568f40 100644 --- a/src/core/parting_scheme_scorer.py +++ b/src/core/parting_scheme_scorer.py @@ -9,12 +9,14 @@ class PartingSchemeScorer: scored = [] for scheme in schemes: score_breakdown = self._score_scheme(scheme) + undercut_priority_bonus = self._build_undercut_priority_bonus(scheme, score_breakdown) total_score = round( - score_breakdown["manufacturability"] * 0.30 - + score_breakdown["undercut_complexity"] * 0.25 - + score_breakdown["parting_quality"] * 0.20 + score_breakdown["manufacturability"] * 0.25 + + score_breakdown["undercut_complexity"] * 0.35 + + score_breakdown["parting_quality"] * 0.15 + score_breakdown["machining_cost"] * 0.15 - + score_breakdown["risk"] * 0.10, + + score_breakdown["risk"] * 0.10 + + undercut_priority_bonus, 2, ) @@ -22,6 +24,7 @@ class PartingSchemeScorer: fallback = self._assess_fallback(scored_scheme, score_breakdown) scored_scheme["score_breakdown"] = score_breakdown scored_scheme["score"] = total_score + scored_scheme["undercut_priority_bonus"] = round(undercut_priority_bonus, 2) scored_scheme["is_fallback"] = fallback["is_fallback"] scored_scheme["fallback_reason"] = fallback["fallback_reason"] scored_scheme["dfm_violations"] = self._build_dfm_violations(scored_scheme) @@ -61,10 +64,34 @@ class PartingSchemeScorer: summary = side_actions.get("summary", {}) slider_count = len(side_actions.get("slider_mechanisms", [])) lifter_count = len(side_actions.get("lifter_mechanisms", [])) + total_mechanism_count = int(summary.get("total_mechanism_count", slider_count + lifter_count) or 0) + total_undercut_area = float( + (side_actions.get("undercut_analysis", {}) or {}).get("total_undercut_area") + or 0.0 + ) + has_pneumatic = any( + str(item.get("actuation", "")).lower() == "pneumatic" + for item in side_actions.get("slider_mechanisms", []) + ) undercut_count = len(undercut_regions) - undercut_complexity = max(35.0, 100.0 - undercut_count * 12.0 - slider_count * 8.0 - lifter_count * 6.0) - if summary.get("complexity") == "high": - undercut_complexity = max(30.0, undercut_complexity - 10.0) + complexity = str(summary.get("complexity", "")).lower() + + undercut_penalty = 0.0 + if total_mechanism_count > 0: + undercut_penalty += 20.0 + undercut_penalty += undercut_count * 10.0 + undercut_penalty += slider_count * 6.0 + undercut_penalty += lifter_count * 5.0 + if has_pneumatic: + undercut_penalty += 10.0 + if complexity == "moderate": + undercut_penalty += 6.0 + elif complexity == "complex": + undercut_penalty += 14.0 + elif complexity == "very_complex": + undercut_penalty += 24.0 + undercut_penalty += min(total_undercut_area / 500.0, 12.0) + undercut_complexity = max(20.0, 100.0 - undercut_penalty) parting_line = scheme.get("parting", {}).get("line", []) parting_length = self._calculate_polyline_length(parting_line) @@ -106,6 +133,33 @@ class PartingSchemeScorer: "risk": round(risk, 2), } + @staticmethod + def _build_undercut_priority_bonus( + scheme: Dict[str, Any], + score_breakdown: Dict[str, float], + ) -> float: + cavity_data = scheme.get("cavity_data", {}) + quality_checks = cavity_data.get("quality_checks", {}) + side_actions = quality_checks.get("side_actions") or cavity_data.get("side_actions", {}) + summary = side_actions.get("summary", {}) + slider_count = len(side_actions.get("slider_mechanisms", [])) + lifter_count = len(side_actions.get("lifter_mechanisms", [])) + total_mechanism_count = int(summary.get("total_mechanism_count", slider_count + lifter_count) or 0) + has_pneumatic = any( + str(item.get("actuation", "")).lower() == "pneumatic" + for item in side_actions.get("slider_mechanisms", []) + ) + + if total_mechanism_count == 0: + return 18.0 + + penalty = 12.0 + total_mechanism_count * 4.0 + if has_pneumatic: + penalty += 8.0 + if float(score_breakdown.get("manufacturability", 0.0)) < 80.0: + penalty += 4.0 + return -penalty + def _assess_fallback(self, scheme: Dict[str, Any], score_breakdown: Dict[str, float]) -> Dict[str, Any]: cavity_data = scheme.get("cavity_data", {}) mold_cavities = cavity_data.get("mold_cavities", {}) diff --git a/src/core/side_action_designer.py b/src/core/side_action_designer.py index 2fe7343..88dac99 100644 --- a/src/core/side_action_designer.py +++ b/src/core/side_action_designer.py @@ -253,7 +253,7 @@ class SliderMechanismDesigner: "block_size": slide_block_size, "guide_type": guide_type, "locking_mechanism": self._select_locking(slide_angle), - "actuation": "hydraulic" if slide_stroke > 50 else "mechanical", + "actuation": "pneumatic" if slide_stroke > 50 else "mechanical", "components": self._generate_components(slide_block_size, guide_type), "manufacturing_notes": self._generate_slider_notes(slide_angle, slide_stroke), } @@ -341,7 +341,7 @@ class SliderMechanismDesigner: if angle > 25: notes.append("滑块角度较大,需确保锁紧可靠") if stroke > 50: - notes.append("抽芯行程较长,建议使用液压抽芯") + notes.append("抽芯行程较长,建议使用气动抽芯") if stroke > 80: notes.append("大行程抽芯,需校核导滑槽强度") notes.append("滑块需设置限位装置,防止脱出") @@ -485,7 +485,6 @@ class SideActionDesigner: "total_lifter_count": len(lifter_mechanisms), "total_mechanism_count": total_mechanisms, "complexity": undercut_result["complexity"], - "has_hydraulic": any(s.get("actuation") == "hydraulic" for s in slider_mechanisms), } recommendations = self._generate_overall_recommendations(summary, undercut_result) @@ -518,8 +517,8 @@ class SideActionDesigner: if summary["total_lifter_count"] > 0: recs.append(f"需要 {summary['total_lifter_count']} 个斜顶机构处理内侧倒扣") - if summary["has_hydraulic"]: - recs.append("大行程抽芯需使用液压系统,需配置液压站") + if summary["total_slider_count"] > 0: + recs.append("如存在大行程滑块,建议优先评估气动抽芯回路并预留稳定供气") if summary["complexity"] == "very_complex": recs.append("侧向分型机构复杂,建议评估是否可通过产品修改简化") diff --git a/src/services/processing_service.py b/src/services/processing_service.py index 6936078..adaf460 100644 --- a/src/services/processing_service.py +++ b/src/services/processing_service.py @@ -17,6 +17,7 @@ from core.aluminum_foam_mold import AluminumFoamMoldGenerator from core.mold_quality_inspector import AluminumFoamMoldQualityInspector from core.mesh_generator import MeshGenerator from core.multi_scheme_planner import MultiSchemeMoldPlanner +from core.cad_exporter import CADExporter from services.storage_integration_rustfs import StorageIntegrationService from services.redis_task_manager import redis_task_manager from services.material_service import MaterialService @@ -43,6 +44,7 @@ class ProcessingService: self.html_generator = HTMLGenerator() self.storage_service = StorageIntegrationService() self.multi_scheme_planner = MultiSchemeMoldPlanner() + self.cad_exporter = CADExporter() self._export_shapes_cache: Dict[str, Dict[str, Dict[str, Any]]] = {} # ─── 对外入口 ─── @@ -145,10 +147,16 @@ class ProcessingService: ) 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, + ) # 4. 生成详细JSON数据 — 委托 CalculationService await self.storage_service.update_task_status( @@ -315,6 +323,7 @@ class ProcessingService: "material": requested_material, "verification": verification_result, "llm_report": llm_report, + "export_artifacts": export_artifacts, **process_params, }, ) @@ -334,6 +343,7 @@ class ProcessingService: "html_file": best_scheme.get("html_file", f"/html/{Path(html_file_path).name}") if best_scheme else f"/html/{Path(html_file_path).name}", "verification": verification_result, "llm_report": llm_report, + "export_artifacts": export_artifacts, "status": ProcessingStatus.COMPLETED, "completed_at": str(datetime.now()), }) @@ -447,6 +457,55 @@ class ProcessingService: def _cache_export_shapes(self, task_id: str, export_shapes: Dict[str, Dict[str, Any]]): self._export_shapes_cache[task_id] = export_shapes + 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"] + + for scheme_id, cavity_data in export_shapes.items(): + try: + result = self.cad_exporter.export_mold_results( + cavity_data=cavity_data, + base_filename=base_filename, + formats=["step"], + 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: diff --git a/src/services/task_query_service.py b/src/services/task_query_service.py index e786228..57c28fa 100644 --- a/src/services/task_query_service.py +++ b/src/services/task_query_service.py @@ -112,6 +112,7 @@ class TaskQueryService: "html_file": html_file_url, "material": task_parameters.get("material"), "parameters": task_parameters, + "export_artifacts": task_parameters.get("export_artifacts"), "stage_timings": task_parameters.get("stage_timings", {}), "verification": task_parameters.get("verification") or file_with_data.get("analysis_metrics", {}).get("verification_details"), diff --git a/static/vue-app.js b/static/vue-app.js index 404342d..34ed909 100644 --- a/static/vue-app.js +++ b/static/vue-app.js @@ -1598,7 +1598,7 @@ const ResultView = { reasons: [ totalCount > 0 ? `规则分析识别出 ${totalCount} 处侧向机构需求` : '规则分析未识别出明确侧向机构需求', summary.complexity ? `当前复杂度判定为 ${summary.complexity}` : '当前复杂度信息不足', - summary.has_hydraulic ? '规则分析提示可能涉及液压抽芯' : '未发现液压抽芯硬性提示' + '当前倒扣方案若存在大行程滑块,优先按气动抽芯条件评估' ].filter(Boolean), standard_advice: (sideActions.recommendations || []).slice(0, 4), manual_review_items: [ @@ -1826,7 +1826,10 @@ const ResultView = { if (result.status === 'success' && result.data.files) { for (const file of result.data.files) { - const downloadUrl = `/api/export-download/${file.filepath.replace(/\\/g, '/').split('/').slice(-2).join('/')}`; + const relativePath = file.relative_path + || (file.filepath ? file.filepath.replace(/\\/g, '/').split('/').slice(-2).join('/') : ''); + const downloadUrl = file.download_path + || `/api/export-download/${encodeURI(relativePath)}?task_id=${encodeURIComponent(taskId)}`; await downloadWithAuth(downloadUrl, file.filename); } addNotification(`已导出 ${result.data.files.length} 个 ${format.toUpperCase()} 文件`, 'success');