后端模块拆分

This commit is contained in:
2026-05-29 18:10:08 +08:00
parent 5bb9bc84ea
commit 823a387118
93 changed files with 198 additions and 833 deletions
+1
View File
@@ -0,0 +1 @@
# Services 模块
@@ -0,0 +1,93 @@
"""
铝金属价格数据服务
提供铝金属的当前价格和历史价格走势数据。
数据来源优先级:
1. 外部API(预留接口)
2. 模拟真实走势数据(当前使用)
数据基于上海期货交易所(SHFE)铝期货价格走势特征生成。
"""
import random
import hashlib
from datetime import datetime, timedelta
from typing import List, Dict, Optional
BASE_PRICE = 18950.0
PRICE_VOLATILITY = 120.0
TREND_DRIFT = 0.3
def _daily_seed(date_str: str) -> float:
h = hashlib.md5(date_str.encode()).hexdigest()
seed = int(h[:8], 16) / (16 ** 8)
return seed
def get_aluminum_current_price() -> Dict:
today = datetime.now().strftime("%Y-%m-%d")
seed = _daily_seed(today)
random.seed(int(seed * 1_000_000))
price = BASE_PRICE + (seed - 0.5) * PRICE_VOLATILITY * 2
price = round(price, 0)
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
prev_seed = _daily_seed(yesterday)
prev_price = BASE_PRICE + (prev_seed - 0.5) * PRICE_VOLATILITY * 2
prev_price = round(prev_price, 0)
change = price - prev_price
change_percent = round((change / prev_price) * 100, 2)
week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
week_seed = _daily_seed(week_ago)
week_price = BASE_PRICE + (week_seed - 0.5) * PRICE_VOLATILITY * 2
random.seed()
return {
"price": price,
"unit": "元/吨",
"currency": "CNY",
"date": today,
"change": round(change, 0),
"change_percent": change_percent,
"open": round(price - random.uniform(10, 50), 0),
"high": round(price + random.uniform(10, 60), 0),
"low": round(price - random.uniform(10, 60), 0),
"prev_close": prev_price,
"week_ago_price": round(week_price, 0),
}
def get_aluminum_price_history(days: int = 30) -> List[Dict]:
history = []
random.seed(42)
price_line = BASE_PRICE
for i in range(days, -1, -1):
date = (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d")
date_seed = _daily_seed(date)
drift = (date_seed - 0.5) * TREND_DRIFT
noise = (date_seed - 0.5) * PRICE_VOLATILITY * 1.5
price_line = price_line + drift + noise * 0.3
price_line = max(18200, min(19800, price_line))
open_price = round(price_line + (date_seed - 0.5) * 80, 0)
high_price = round(open_price + abs(date_seed - 0.5) * 160, 0)
low_price = round(open_price - abs(date_seed - 0.5) * 140, 0)
close_price = round(price_line, 0)
history.append({
"date": date,
"open": open_price,
"high": high_price,
"low": low_price,
"close": close_price,
})
random.seed()
return history
@@ -0,0 +1,427 @@
# services/calculation_service.py
"""模具工程参数计算服务 — 从 process_file_core 中抽取的纯计算逻辑"""
from typing import Dict, Any, List, Optional
from datetime import datetime
from pathlib import Path
class CalculationService:
"""将 process_file_core 中的工程计算逻辑抽取为独立服务,方便单测和复用"""
# ─── 基础计算 ───
@staticmethod
def calculate_product_weight(volume_mm3: float, density: float) -> float:
"""计算产品重量(克)"""
volume_cm3 = volume_mm3 / 1000
return volume_cm3 * density
@staticmethod
def calculate_projected_area(bbox_dims: List[float], parting_direction: str = "Z") -> float:
"""
计算投影面积(cm²)
Args:
bbox_dims: [长度, 宽度, 高度] (mm)
parting_direction: 开模方向,"Z" 表示上下开模(投影到XY平面),
"Y" 表示前后开模(投影到XZ平面),
"X" 表示左右开模(投影到YZ平面)
"""
if len(bbox_dims) < 3:
return 0.0
if parting_direction == "Z":
# Z轴开模 → 投影面积 = 长度 × 宽度
return (bbox_dims[0] * bbox_dims[1]) / 100
elif parting_direction == "Y":
return (bbox_dims[0] * bbox_dims[2]) / 100
elif parting_direction == "X":
return (bbox_dims[1] * bbox_dims[2]) / 100
# 默认 Z 轴
return (bbox_dims[0] * bbox_dims[1]) / 100
@staticmethod
def calculate_cavity_count(product_weight_g: float, projected_area_cm2: float) -> int:
"""
计算最优型腔数量
基于产品重量和投影面积:
- 小产品(< 50g)可以多型腔
- 大产品(> 1000g)通常单型腔
"""
if product_weight_g < 50:
cavity_count = 8
elif product_weight_g < 100:
cavity_count = 4
elif product_weight_g < 300:
cavity_count = 2
else:
cavity_count = 1
# 根据投影面积调整
if projected_area_cm2 > 400:
cavity_count = 1
elif projected_area_cm2 > 200 and cavity_count > 2:
cavity_count = 2
return cavity_count
@staticmethod
def calculate_clamping_force(
projected_area_cm2: float,
cavity_count: int,
runner_ratio: float = 0.20,
injection_pressure: float = 700,
is_foam: bool = False,
) -> int:
"""
计算所需夹紧力(吨)
塑料模具: 锁模力 = 投影面积 × 型腔数 × (1+流道比) × 注塑压力 / 1000
泡沫模具: 锁模力 = 投影面积(cm²) × 0.3 (泡沫材料系数)
Args:
projected_area_cm2: 投影面积 cm²
cavity_count: 型腔数
runner_ratio: 流道系统占型腔投影面积比(0.15-0.25)
injection_pressure: 注塑压力 kg/cm²
is_foam: 是否泡沫材料
"""
if is_foam:
# 泡沫模具: 锁模力(吨) = 投影面积(cm²) × 0.3
clamping_force_ton = int(projected_area_cm2 * 0.3)
else:
total_projected_area = projected_area_cm2 * cavity_count * (1 + runner_ratio)
clamping_force_ton = int(total_projected_area * injection_pressure / 1000)
return max(50, min(clamping_force_ton, 3000))
@staticmethod
def calculate_wall_thickness(volume_mm3: float, surface_area_mm2: float) -> Dict[str, float]:
"""计算壁厚范围"""
if surface_area_mm2 > 0 and volume_mm3 > 0:
avg = (volume_mm3 / surface_area_mm2) * 0.6
return {
"avg_thickness_mm": avg,
"wall_thickness_min": avg * 0.7,
"wall_thickness_max": avg * 1.3,
}
return {
"avg_thickness_mm": 2.5,
"wall_thickness_min": 2.0,
"wall_thickness_max": 3.0,
}
@staticmethod
def calculate_complexity(avg_thickness_mm: float) -> float:
"""计算复杂度评分(0~1)"""
return min((avg_thickness_mm / 5.0), 1.0) if avg_thickness_mm > 0 else 0.5
@staticmethod
def calculate_mold_size(
bbox_dims: List[float], cavity_count: int,
cavity_spacing: float = 30, edge_margin: float = 50,
) -> Dict[str, float]:
"""计算模具尺寸(长×宽×高),单位 mm"""
dim_x = max(bbox_dims[0] if len(bbox_dims) > 0 else 120, 120)
dim_y = max(bbox_dims[1] if len(bbox_dims) > 1 else 100, 100)
dim_z = max(bbox_dims[2] if len(bbox_dims) > 2 else 60, 60)
if cavity_count == 1:
length = dim_x + 2 * edge_margin
width = dim_y + 2 * edge_margin
elif cavity_count == 2:
length = 2 * dim_x + cavity_spacing + 2 * edge_margin
width = dim_y + 2 * edge_margin
elif cavity_count == 4:
length = 2 * dim_x + cavity_spacing + 2 * edge_margin
width = 2 * dim_y + cavity_spacing + 2 * edge_margin
else: # 8 型腔: 2x4
length = 4 * dim_x + 3 * cavity_spacing + 2 * edge_margin
width = 2 * dim_y + cavity_spacing + 2 * edge_margin
height = dim_z + 80 # 包含冷却系统
return {"length": length, "width": width, "height": height}
@staticmethod
def calculate_parting_line_length(bbox_dims: List[float], cavity_count: int) -> float:
"""计算分型线长度(mm)"""
if len(bbox_dims) >= 2:
return 2 * (bbox_dims[0] + bbox_dims[1]) * cavity_count
return 0.0
@staticmethod
def calculate_cycle_time(
wall_thickness_max: float, volume_cm3: float, cavity_count: int,
) -> int:
"""
估算成型周期(秒)
周期 = 冷却时间 + 注塑时间 + 顶出时间 + 开合模时间
"""
cooling_time = (wall_thickness_max ** 2) * 4
injection_time = max(3, volume_cm3 / 100)
ejection_time = 3
cycle_time = cooling_time + injection_time + ejection_time + 5
# 多型腔需要更长冷却时间
if cavity_count > 1:
cycle_time = cycle_time * (1 + 0.1 * (cavity_count - 1))
return int(cycle_time)
# ─── 组装方法 ───
@classmethod
def build_detailed_cavity_json(
cls,
geometry_data: Dict[str, Any],
material: Dict[str, Any],
file_path: str,
cavity_mesh_data: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
组装完整的 detailed_cavity_json(整合以上所有计算结果)
Args:
geometry_data: STP 解析得到的几何数据
material: MaterialService.get_material() 返回的材料属性字典
file_path: STP 文件路径
cavity_mesh_data: 型腔网格数据(可选)
"""
volume_mm3 = geometry_data.get("volume", 0)
surface_area_mm2 = geometry_data.get("surface_area", 0)
bbox = geometry_data.get("bounding_box", {})
bbox_dims = bbox.get("dimensions", [0, 0, 0])
material_density = material["density"]
shrinkage_rate = material["shrinkage"]
is_foam = material.get("is_foam", False)
# 泡沫模具优先 Z 轴开模(上下开模)
parting_direction = "Z"
# 各项计算
volume_cm3 = volume_mm3 / 1000
product_weight_g = cls.calculate_product_weight(volume_mm3, material_density)
projected_area_cm2 = cls.calculate_projected_area(bbox_dims, parting_direction)
cavity_count = cls.calculate_cavity_count(product_weight_g, projected_area_cm2)
clamping_force_ton = cls.calculate_clamping_force(
projected_area_cm2, cavity_count, is_foam=is_foam
)
wall = cls.calculate_wall_thickness(volume_mm3, surface_area_mm2)
complexity_score = cls.calculate_complexity(wall["avg_thickness_mm"])
mold_size = cls.calculate_mold_size(bbox_dims, cavity_count)
parting_line_length = cls.calculate_parting_line_length(bbox_dims, cavity_count)
cycle_time = cls.calculate_cycle_time(wall["wall_thickness_max"], volume_cm3, cavity_count)
injection_pressure = 700 # kg/cm²
detailed_cavity_json = {
"metadata": {
"file_name": Path(file_path).name,
"analysis_date": datetime.now().isoformat(),
"shrinkage_rate": shrinkage_rate,
"draft_angle": 2.0,
"selected_material": material["name"],
"is_foam": is_foam,
"parting_direction": parting_direction,
},
"product_analysis": {
"volume": volume_mm3,
"surface_area": surface_area_mm2,
"bounding_box": bbox,
},
"manufacturing_info": {
"recommended_material": material["name"],
"material_density": f"{material_density} g/cm³",
"estimated_clamping_force": f"{clamping_force_ton} 吨",
"clamping_force_formula": (
"投影面积(cm²) × 0.3" if is_foam
else "投影面积 × 型腔数 × (1+流道比) × 注塑压力 / 1000"
),
"estimated_mold_size": {
"length": int(mold_size["length"]),
"width": int(mold_size["width"]),
"height": int(mold_size["height"]),
},
"mold_material": "铝合金7075" if clamping_force_ton < 200 else "P20钢材",
"mold_hardness": "HB 150-170" if clamping_force_ton < 200 else "HRC 28-32",
"surface_finish": "Ra 0.8 μm",
"parting_line_length": f"{parting_line_length:.2f} mm",
"estimated_cycle_time": f"{cycle_time} 秒",
"injection_pressure": f"{injection_pressure} kg/cm²",
"parting_direction": parting_direction,
},
"mold_cavities": {
"cavity_count": cavity_count,
},
}
# 合并型腔网格数据
if cavity_mesh_data and "mold_cavities" in cavity_mesh_data:
mold_cavities = cavity_mesh_data["mold_cavities"]
for key in ("cavity", "core", "parting_surface"):
if key in mold_cavities:
detailed_cavity_json["mold_cavities"][key] = mold_cavities[key]
# 合并分模附加信息,保持普通模具与铝泡沫模具输出结构一致
if cavity_mesh_data:
if cavity_mesh_data.get("parting_surface"):
detailed_cavity_json["parting_surface"] = cavity_mesh_data["parting_surface"]
quality_checks = cavity_mesh_data.get("quality_checks", {})
if quality_checks:
detailed_cavity_json["quality_checks"] = quality_checks
undercut_regions = quality_checks.get("undercut_regions")
if undercut_regions:
detailed_cavity_json["undercut_regions"] = undercut_regions
side_actions = quality_checks.get("side_actions")
if side_actions:
detailed_cavity_json["side_actions"] = side_actions
# 添加型腔关键信息
detailed_cavity_json["mold_cavities"]["cavity_key_info"] = {
"geometric_characteristics": {
"product_weight": f"{product_weight_g:.2f} g",
"wall_thickness_range": f"{wall['wall_thickness_min']:.2f} - {wall['wall_thickness_max']:.2f} mm",
"complexity_score": round(complexity_score, 2),
"product_volume": f"{volume_cm3:.2f} cm³",
"projected_area": f"{projected_area_cm2:.2f} cm²",
},
"quality_considerations": {
"undercut_count": len(detailed_cavity_json.get("undercut_regions", [])),
"side_action_summary": detailed_cavity_json.get("side_actions", {}).get("summary", {}),
"potential_weld_lines": "center" if cavity_count > 1 else "minimal",
"sink_mark_areas": "thick_sections" if wall["wall_thickness_max"] > 4 else "minimal",
"warpage_risk": "medium" if wall["wall_thickness_max"] > 5 else "low",
},
}
return detailed_cavity_json
@classmethod
def build_plan_result(
cls,
geometry_data: Dict[str, Any],
material: Dict[str, Any],
file_path: str,
plan_result: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""构建多方案分模结果,并保留单方案兼容字段。"""
if not plan_result or not plan_result.get("candidate_schemes"):
legacy = cls.build_detailed_cavity_json(
geometry_data=geometry_data,
material=material,
file_path=file_path,
cavity_mesh_data=None,
)
result = {
"best_scheme_id": "scheme_1",
"candidate_schemes": [
{
"scheme_id": "scheme_1",
"rank": 1,
"title": "推荐方案",
"method": "legacy_fallback",
"score": 60.0,
"confidence_score": 45.0,
"is_fallback": True,
"fallback_reason": "多方案生成失败,已降级为兼容单方案输出",
"score_breakdown": {},
"summary": "当前模型未生成多方案,返回兼容单方案结果",
"parting": {
"axis": legacy.get("metadata", {}).get("parting_direction", "Z"),
"direction": None,
"surface": legacy.get("parting_surface", {}),
"line": [],
},
"cavity_data": legacy,
"key_info": legacy.get("mold_cavities", {}).get("cavity_key_info", {}),
"undercut_regions": legacy.get("undercut_regions", []),
"side_actions": legacy.get("side_actions", {}),
}
],
"global_summary": {
"scheme_count": 1,
"recommended_reason": "兼容旧版单方案结果",
},
"cavity_data": legacy,
"key_info": legacy.get("mold_cavities", {}).get("cavity_key_info", {}),
}
cls.attach_injection_system_summaries(result, material["name"])
return result
candidate_schemes = plan_result.get("candidate_schemes", [])
best_scheme = cls.get_best_scheme(plan_result)
result = {
"best_scheme_id": plan_result.get("best_scheme_id"),
"candidate_schemes": candidate_schemes,
"global_summary": plan_result.get("global_summary", {}),
"cavity_data": best_scheme.get("cavity_data", {}) if best_scheme else {},
"key_info": best_scheme.get("key_info", {}) if best_scheme else {},
}
cls.attach_injection_system_summaries(result, material["name"])
return result
@classmethod
def attach_injection_system_summaries(
cls,
plan_result: Dict[str, Any],
material_name: str,
) -> Dict[str, Any]:
"""为每个候选方案补充注塑模冷却/浇注摘要。"""
from moldinsight.core.mold_system_designer import MoldSystemDesigner
designer = MoldSystemDesigner()
for scheme in plan_result.get("candidate_schemes", []):
cavity_data = scheme.get("cavity_data") or {}
product_bbox = cavity_data.get("product_analysis", {}).get("bounding_box", {})
mold_size = cavity_data.get("manufacturing_info", {}).get("estimated_mold_size", {})
cavity_count = cavity_data.get("mold_cavities", {}).get("cavity_count", 1)
if not product_bbox or not mold_size:
continue
system_result = designer.design_complete_system(
mold_size=mold_size,
product_bbox=product_bbox,
material=material_name,
cavity_count=cavity_count,
)
cavity_data["injection_system"] = system_result
cavity_data.setdefault("manufacturing_info", {})
cavity_data["manufacturing_info"]["cooling_summary"] = {
"cooling_time": system_result.get("cooling", {}).get("cooling_time"),
"channel_count": system_result.get("cooling", {}).get("thermal_check", {}).get("channel_count"),
"flow_rate_lpm": system_result.get("cooling", {}).get("flow_rate", {}).get("flow_rate_lpm"),
}
cavity_data["manufacturing_info"]["gating_summary"] = {
"gate_type": system_result.get("gating", {}).get("gate_type"),
"runner_type": system_result.get("gating", {}).get("runner", {}).get("type"),
"estimated_cycle_time": system_result.get("overall_assessment", {}).get("estimated_cycle_time"),
}
best_scheme = cls.get_best_scheme(plan_result)
if best_scheme:
plan_result["injection_system"] = best_scheme.get("cavity_data", {}).get("injection_system")
return plan_result
@staticmethod
def get_best_scheme(plan_result: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
if not plan_result:
return None
schemes = plan_result.get("candidate_schemes", [])
if not schemes:
return None
best_scheme_id = plan_result.get("best_scheme_id")
if best_scheme_id:
for scheme in schemes:
if scheme.get("scheme_id") == best_scheme_id:
return scheme
return schemes[0]
@@ -0,0 +1,187 @@
from typing import Dict, Any, List, Optional
from moldinsight.core.mold_cam import MoldCAMDesigner
class CAMBundleService:
"""将分模任务结果组装为 CAM 准备包(MVP 骨架)。"""
def __init__(self) -> None:
self.cam_designer = MoldCAMDesigner()
def build_bundle(
self,
task_view: Dict[str, Any],
scheme_id: Optional[str] = None,
mold_steel: str = "P20",
surface_quality: str = "standard",
controller: str = "fanuc",
include_gcode: bool = False,
) -> Dict[str, Any]:
scheme = self._select_scheme(task_view, scheme_id)
cavity_data = scheme.get("cavity_data", {}) if scheme else {}
cavity_bbox = self._extract_cavity_bbox(cavity_data)
stock_bbox = self._build_stock_bbox(cavity_data, cavity_bbox)
cam_result = self.cam_designer.design_mold_cam(
cavity_bbox=cavity_bbox,
stock_bbox=stock_bbox,
mold_steel=mold_steel,
surface_quality=surface_quality,
controller=controller,
)
process_plan = self._build_process_plan(cam_result.get("operations", []))
tooling_suggestion = self._build_tooling_suggestion(cam_result.get("tools", {}))
manufacturing_warnings = self._build_warnings(
scheme=scheme,
cam_recommendations=cam_result.get("recommendations", []),
cavity_data=cavity_data,
)
bundle = {
"task_id": task_view.get("task_id"),
"scheme_id": (scheme or {}).get("scheme_id"),
"process_plan": process_plan,
"tooling_suggestion": tooling_suggestion,
"manufacturing_warnings": manufacturing_warnings,
"summary": cam_result.get("summary", {}),
"confidence": {
"score": (scheme or {}).get("confidence_score"),
"is_fallback": bool((scheme or {}).get("is_fallback", False)),
"fallback_reason": (scheme or {}).get("fallback_reason", ""),
},
}
if include_gcode:
bundle["gcode"] = cam_result.get("gcode", "")
bundle["gcode_lines"] = cam_result.get("gcode_lines", 0)
return bundle
@staticmethod
def _select_scheme(task_view: Dict[str, Any], scheme_id: Optional[str]) -> Dict[str, Any]:
schemes = task_view.get("candidate_schemes") or []
if not schemes:
return {
"scheme_id": "legacy",
"confidence_score": None,
"is_fallback": True,
"fallback_reason": "无候选分模方案,使用默认加工包",
"cavity_data": task_view.get("cavity_data", {}),
}
if scheme_id:
for scheme in schemes:
if scheme.get("scheme_id") == scheme_id:
return scheme
best_scheme_id = task_view.get("best_scheme_id")
if best_scheme_id:
for scheme in schemes:
if scheme.get("scheme_id") == best_scheme_id:
return scheme
return schemes[0]
@staticmethod
def _extract_cavity_bbox(cavity_data: Dict[str, Any]) -> Dict[str, Any]:
bbox = cavity_data.get("product_analysis", {}).get("bounding_box", {}) or {}
dims = bbox.get("dimensions") or [100.0, 100.0, 50.0]
if len(dims) < 3:
dims = [100.0, 100.0, 50.0]
center = bbox.get("center") or [0.0, 0.0, 0.0]
half_x = float(dims[0]) / 2.0
half_y = float(dims[1]) / 2.0
half_z = float(dims[2]) / 2.0
return {
"dimensions": [float(dims[0]), float(dims[1]), float(dims[2])],
"min": [float(center[0]) - half_x, float(center[1]) - half_y, float(center[2]) - half_z],
"max": [float(center[0]) + half_x, float(center[1]) + half_y, float(center[2]) + half_z],
}
@staticmethod
def _build_stock_bbox(cavity_data: Dict[str, Any], cavity_bbox: Dict[str, Any]) -> Dict[str, Any]:
mold_size = cavity_data.get("manufacturing_info", {}).get("estimated_mold_size", {}) or {}
dims = mold_size.get("length"), mold_size.get("width"), mold_size.get("height")
if not all(v is not None for v in dims):
dims = cavity_bbox.get("dimensions", [100.0, 100.0, 50.0])
dims = [float(dims[0]) * 1.4, float(dims[1]) * 1.4, max(80.0, float(dims[2]) * 1.8)]
else:
dims = [float(dims[0]), float(dims[1]), float(dims[2])]
return {
"dimensions": dims,
"min": [-dims[0] / 2.0, -dims[1] / 2.0, -dims[2] / 2.0],
"max": [dims[0] / 2.0, dims[1] / 2.0, dims[2] / 2.0],
}
@staticmethod
def _build_process_plan(operations: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
plan = []
for idx, op in enumerate(operations, start=1):
plan.append({
"seq": idx,
"operation": op.get("strategy", "unknown"),
"estimated_time_min": op.get("estimated_time_min", 0),
"tool_id": op.get("tool", {}).get("tool_id"),
"feed_rate_mm_min": op.get("tool", {}).get("feed_rate_mm_min"),
"spindle_speed_rpm": op.get("tool", {}).get("spindle_speed_rpm"),
})
return plan
@staticmethod
def _build_tooling_suggestion(tools: Dict[str, Any]) -> Dict[str, Any]:
roughing = tools.get("roughing", {})
finishing = tools.get("finishing", {})
return {
"roughing_tool": {
"tool_id": roughing.get("tool_id"),
"tool_type": roughing.get("tool_type"),
"diameter_mm": roughing.get("tool_diameter"),
},
"finishing_tool": {
"tool_id": finishing.get("tool_id"),
"tool_type": finishing.get("tool_type"),
"diameter_mm": finishing.get("tool_diameter"),
},
}
@staticmethod
def _build_warnings(
scheme: Dict[str, Any],
cam_recommendations: List[str],
cavity_data: Dict[str, Any],
) -> List[str]:
warnings: List[str] = []
for violation in scheme.get("dfm_violations", []) or []:
level = str(violation.get("level", "medium")).upper()
message = violation.get("message")
if message:
warnings.append(f"DFM[{level}]: {message}")
if scheme.get("is_fallback"):
reason = scheme.get("fallback_reason") or "分模结果使用回退路径"
warnings.append(f"分模回退: {reason}")
confidence_score = float(scheme.get("confidence_score") or 0.0)
if confidence_score and confidence_score < 60.0:
warnings.append(f"方案可信度偏低({confidence_score:.1f}),建议人工复核分型面与倒扣机构")
force_text = str(cavity_data.get("manufacturing_info", {}).get("estimated_clamping_force", ""))
if "吨" in force_text:
try:
force_val = float(force_text.replace("吨", "").strip())
if force_val > 1000:
warnings.append("预估锁模力较高,建议复核设备吨位与模板强度")
except ValueError:
pass
warnings.extend(cam_recommendations[:3])
if not warnings:
warnings.append("未发现明显制造风险,建议进入工艺评审")
return warnings
cam_bundle_service = CAMBundleService()
+467
View File
@@ -0,0 +1,467 @@
"""
LLM 增强分析服务
提供两个核心能力:
1. generate_design_report — 将分析 JSON 转换为结构化评审报告
2. recommend_parting_direction — 基于几何 + 制造约束推荐最优分型方向
适配层:OpenAI 兼容 API(支持 OpenAI / DeepSeek / vLLM / Ollama 等)
未配置 LLM 时静默降级,不影响主流程。
"""
import json
import re
from typing import Optional, Dict, Any, List
import httpx
from shared.config.settings import settings
from shared.utils.logger import get_logger
logger = get_logger(__name__)
_DESIGN_REPORT_SYSTEM = """你是一位资深注塑模具设计工程师,拥有 20 年模具 DFM 评审经验。
请根据提供的模具分析数据,生成一份专业的模具设计评审报告。
要求:
1. 使用中文
2. 按 "关键问题 → 工艺参数建议 → 改进建议" 结构组织
3. 技术术语准确(如:锁模力、投影面积、分型面、滑块、斜顶、拔模角、缩痕、熔接痕)
4. 每个问题标注优先级(high / medium / low)
5. 如果数据不足以判断某项,明确标注"数据不足,需人工确认"
严格输出 JSON,不要输出其他内容。JSON 格式:
{
"title": "模具设计评审报告",
"overview": "一段 1-2 句话的整体概述",
"sections": [
{
"heading": "关键问题",
"type": "issues",
"items": [
{"level": "high", "content": "拔模角不足,建议增加到 2° 以上"},
{"level": "medium", "content": "壁厚偏差较大,可能产生缩痕"}
]
},
{
"heading": "工艺参数建议",
"type": "params_table",
"headers": ["参数", "推荐值", "说明"],
"rows": [
["锁模力", "150 吨", "基于投影面积计算"],
["注塑温度", "230°C", "ABS 材料推荐值"]
]
},
{
"heading": "改进建议",
"type": "recommendations",
"items": [
"建议将主流道直径从 4mm 增加到 6mm",
"建议在所有垂直面增加 1-2° 拔模角"
]
}
],
"overall_score": 7.5
}"""
_DESIGN_REPORT_USER = """请根据以下模具分析数据生成评审报告:
## 产品信息
- 文件:{filename}
- 材料:{material}
- 体积:{volume}
- 表面积:{surface_area}
- 边界框:{bbox}
## 检测特征
{features}
## 质量指标
{quality_metrics}
## 分模方案
{schemes}
## 制造参数
- 推荐模具材料:{mold_material}
- 推荐模具硬度:{mold_hardness}
- 预估锁模力:{clamping_force}
- 模具尺寸(长×宽×高):{mold_size}
- 预估成型周期:{cycle_time}
- 拔模角:{draft_angle}
- 收缩率:{shrinkage_rate}
## 原始设计建议
{recommendations}
请生成 JSON 格式评审报告。issues 部分不要超过 8 条,每条内容简洁在一行内;
params_table 至少要包含锁模力、成型周期、模仁材料、推荐型腔数 4 行;
如果某项数据标记为"自动计算"或"自动选择",请在说明中注明"需人工确认";
overall_score 范围 1-10。"""
_SIDE_ACTION_ANALYSIS_SYSTEM = """你是一位资深注塑模具结构工程师。
请根据提供的 STP 分析结果,判断当前产品是否需要倒扣/抽芯机构,并输出标准化结论。
输出要求:
1. 严格输出 JSON,不要输出其他内容
2. 只允许基于已给数据判断,数据不足时必须标记为 manual_review
3. 结论面向工程评审,避免坐标、面索引、底层算法术语堆砌
4. 建议必须标准化、简洁、可执行
JSON 格式:
{
"status": "required|not_required|manual_review",
"confidence": 0.0,
"conclusion": "一句中文结论",
"mechanism_recommendation": "slider|lifter|mixed|none|manual_review",
"summary": "一段 40-80 字中文摘要",
"reasons": ["原因1", "原因2"],
"standard_advice": ["建议1", "建议2"],
"manual_review_items": ["复核项1", "复核项2"]
}"""
_SIDE_ACTION_ANALYSIS_USER = """请分析当前注塑件是否需要倒扣/抽芯机构:
## 产品信息
- 文件:{filename}
- 材料:{material}
- 边界框:{bbox}
## 特征检测
{features}
## 最优方案
{best_scheme}
## 规则分析结果
{side_actions}
## DFM 风险
{dfm_violations}
判断要求:
1. 如果规则结果明确显示无倒扣,可输出 not_required
2. 如果存在外侧倒扣,优先考虑 slider
3. 如果存在内侧倒扣,优先考虑 lifter
4. 如果内外侧倒扣同时存在,可输出 mixed
5. 如果数据不够支撑明确判断,输出 manual_review"""
_PARTING_SYSTEM = """你是一位注塑模具分模专家。
根据产品几何特征和多个候选分模方向的评分数据,推荐最优分模方向。
输出要求:严格输出 JSON,不要输出其他内容。
JSON 格式:
{
"recommended_axis": "Z",
"confidence": 0.85,
"reasoning": "详细的中文推理过程...",
"risk_notes": ["风险1", "风险2"],
"rankings": [{"axis":"Z","rank":1,"score":92,"note":"..."}]
}"""
_PARTING_USER = """请评估以下候选分模方向并推荐最优方案:
产品几何:
- 边界框 (mm):{bbox}
- 面法向分布:{normal_stats}
- 惯性矩:{inertia}
约束条件:
- 材料:{material}
- 型腔数:{cavity_count}
- 最大锁模力 (吨):{max_clamping_force}
- 泡沫材料:{is_foam}
候选方案:
{schemes}
请综合评估制造可行性、成本和风险,给出推荐。"""
class LLMService:
"""LLM 增强分析服务(单例)"""
def __init__(self):
self._enabled = settings.LLM_ENABLED
self._api_url = settings.LLM_API_URL.rstrip("/")
self._api_key = settings.LLM_API_KEY
self._model = settings.LLM_MODEL
self._timeout = settings.LLM_TIMEOUT
self._max_tokens = settings.LLM_MAX_TOKENS
if self._enabled:
logger.info(
"LLM 增强分析已启用: model=%s endpoint=%s",
self._model, self._api_url,
)
else:
logger.info("LLM 增强分析未启用(设置 LLM_ENABLED=true 启用)")
async def generate_design_report(
self,
analysis_result: Dict[str, Any],
detailed_cavity_json: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""生成模具设计评审报告 (结构化 JSON)"""
if not self._enabled:
return None
try:
prompt = self._build_design_report_prompt(analysis_result, detailed_cavity_json)
response = await self._chat(
_DESIGN_REPORT_SYSTEM,
prompt,
self._max_tokens,
expect_json=True,
)
if not response:
return None
result = self._parse_json_response(response)
if result:
logger.info("LLM 设计报告生成成功 (%d sections)", len(result.get("sections", [])))
return result
except Exception as e:
logger.warning("LLM 设计报告生成失败(不影响主流程): %s", e)
return None
async def generate_side_action_analysis(
self,
analysis_result: Dict[str, Any],
detailed_cavity_json: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""生成倒扣/抽芯 AI 标准化分析"""
if not self._enabled:
return None
try:
prompt = self._build_side_action_prompt(analysis_result, detailed_cavity_json)
response = await self._chat(
_SIDE_ACTION_ANALYSIS_SYSTEM,
prompt,
min(self._max_tokens, 1200),
expect_json=True,
)
if not response:
return None
result = self._parse_json_response(response)
if result:
logger.info(
"LLM 倒扣/抽芯分析生成成功: status=%s confidence=%s",
result.get("status"),
result.get("confidence"),
)
return result
except Exception as e:
logger.warning("LLM 倒扣/抽芯分析失败(不影响主流程): %s", e)
return None
@staticmethod
def compose_llm_report(
design_report: Optional[Dict[str, Any]],
side_action_analysis: Optional[Dict[str, Any]],
) -> Optional[str]:
"""将结构化报告和倒扣分析打包进 llm_report 字段,避免改动外部协议。
设计报告以 <!--DESIGN_REPORT_BEGIN--> / <!--DESIGN_REPORT_END--> 包裹的 JSON 嵌入,
倒扣分析以 <!--SIDE_ACTION_AI_BEGIN--> / <!--SIDE_ACTION_AI_END--> 包裹的 JSON 嵌入。
"""
sections: List[str] = []
if side_action_analysis:
payload = json.dumps(side_action_analysis, ensure_ascii=False)
sections.append(
"<!--SIDE_ACTION_AI_BEGIN-->\n"
f"{payload}\n"
"<!--SIDE_ACTION_AI_END-->"
)
if design_report:
payload = json.dumps(design_report, ensure_ascii=False)
sections.append(
"<!--DESIGN_REPORT_BEGIN-->\n"
f"{payload}\n"
"<!--DESIGN_REPORT_END-->"
)
merged = "\n\n".join(sections).strip()
return merged or None
async def recommend_parting_direction(
self,
geometry_data: Dict[str, Any],
candidate_schemes: List[Dict[str, Any]],
material: Dict[str, Any],
cavity_count: int = 1,
) -> Optional[Dict[str, Any]]:
"""推荐最优分型方向"""
if not self._enabled:
return None
try:
prompt = self._build_parting_prompt(geometry_data, candidate_schemes, material, cavity_count)
response = await self._chat(_PARTING_SYSTEM, prompt, min(self._max_tokens, 1200), expect_json=True)
if response:
result = self._parse_json_response(response)
if result:
logger.info("LLM 分型推荐: %s (%.2f)", result.get("recommended_axis", "?"), result.get("confidence", 0))
return result
return None
except Exception as e:
logger.warning("LLM 分型推荐失败(不影响主流程): %s", e)
return None
def _build_design_report_prompt(self, analysis_result, detailed_cavity_json) -> str:
features = json.dumps(analysis_result.get("detected_features", []), ensure_ascii=False, indent=2)
if len(features) > 4000:
features = features[:4000] + "\n... (已截断)"
schemes_text = ""
if detailed_cavity_json:
schemes = detailed_cavity_json.get("candidate_schemes", [])
if schemes:
schemes_text = json.dumps([{
"scheme_id": s.get("scheme_id"), "rank": s.get("rank"), "title": s.get("title"),
"score": s.get("score"), "summary": s.get("summary"),
"parting_axis": s.get("parting", {}).get("axis"),
"mold_structure_type": s.get("mold_structure_type"),
"dfm_violations": s.get("dfm_violations", []),
} for s in schemes], ensure_ascii=False, indent=2)
best = detailed_cavity_json.get("candidate_schemes", [{}])[0] if detailed_cavity_json else {}
cd = best.get("cavity_data", {}) if isinstance(best, dict) else {}
mfg = cd.get("manufacturing_info", {})
meta = cd.get("metadata", {})
return _DESIGN_REPORT_USER.format(
filename=meta.get("file_name", "unknown.stp"),
material=meta.get("selected_material", "ABS"),
volume=f"{analysis_result.get('geometry_data', {}).get('volume', 0):.1f} mm³",
surface_area=f"{analysis_result.get('geometry_data', {}).get('surface_area', 0):.1f} mm²",
bbox=json.dumps(analysis_result.get("geometry_data", {}).get("bounding_box", {}), ensure_ascii=False),
features=features or "无特征检测数据",
quality_metrics=json.dumps(analysis_result.get("quality_metrics", {}), ensure_ascii=False, indent=2),
schemes=schemes_text or "无分模方案数据",
mold_material=mfg.get("mold_material", "自动选择"),
mold_hardness=mfg.get("mold_hardness", "自动选择"),
clamping_force=mfg.get("estimated_clamping_force", "自动计算"),
mold_size=json.dumps(mfg.get("estimated_mold_size", {}), ensure_ascii=False),
cycle_time=mfg.get("estimated_cycle_time", "自动计算"),
draft_angle=f"{meta.get('draft_angle', 2.0)}°",
shrinkage_rate="自动计算",
recommendations=json.dumps(analysis_result.get("design_recommendations", []), ensure_ascii=False, indent=2) or "无",
)
def _build_side_action_prompt(self, analysis_result, detailed_cavity_json) -> str:
features = json.dumps(
analysis_result.get("detected_features", []),
ensure_ascii=False,
indent=2,
)
if len(features) > 2500:
features = features[:2500] + "\n... (已截断)"
best_scheme = {}
if detailed_cavity_json:
candidate_schemes = detailed_cavity_json.get("candidate_schemes", [])
best_scheme_id = detailed_cavity_json.get("best_scheme_id")
if candidate_schemes:
best_scheme = candidate_schemes[0]
if best_scheme_id:
for scheme in candidate_schemes:
if scheme.get("scheme_id") == best_scheme_id:
best_scheme = scheme
break
cavity_data = best_scheme.get("cavity_data", {}) if isinstance(best_scheme, dict) else {}
metadata = cavity_data.get("metadata", {}) if isinstance(cavity_data, dict) else {}
side_actions = (
best_scheme.get("side_actions")
or cavity_data.get("side_actions")
or {}
)
best_scheme_view = {
"scheme_id": best_scheme.get("scheme_id"),
"title": best_scheme.get("title"),
"score": best_scheme.get("score"),
"parting_axis": best_scheme.get("parting", {}).get("axis"),
"mold_structure_type": best_scheme.get("mold_structure_type"),
"undercut_regions_count": len(best_scheme.get("undercut_regions", []) or []),
}
side_actions_view = {
"summary": side_actions.get("summary", {}),
"recommendations": side_actions.get("recommendations", []),
"slider_count": len(side_actions.get("slider_mechanisms", []) or []),
"lifter_count": len(side_actions.get("lifter_mechanisms", []) or []),
}
dfm_violations = best_scheme.get("dfm_violations", []) if isinstance(best_scheme, dict) else []
return _SIDE_ACTION_ANALYSIS_USER.format(
filename=metadata.get("file_name", "unknown.stp"),
material=metadata.get("selected_material", "ABS"),
bbox=json.dumps(
analysis_result.get("geometry_data", {}).get("bounding_box", {}),
ensure_ascii=False,
),
features=features or "无特征检测数据",
best_scheme=json.dumps(best_scheme_view, ensure_ascii=False, indent=2),
side_actions=json.dumps(side_actions_view, ensure_ascii=False, indent=2),
dfm_violations=json.dumps(dfm_violations[:6], ensure_ascii=False, indent=2),
)
def _build_parting_prompt(self, geometry_data, candidate_schemes, material, cavity_count) -> str:
bbox = geometry_data.get("bounding_box", {})
axis_normal_stats = geometry_data.get("axis_normal_stats", {})
inertia = geometry_data.get("inertia_matrix", [])
inertia_diag = [inertia[i][i] if i < len(inertia) and i < len(inertia[i]) else 0.0 for i in range(3)]
schemes_text = json.dumps([{
"axis": s.get("parting", {}).get("axis") or s.get("axis"),
"score": s.get("score"), "summary": s.get("summary"),
"mold_structure_type": s.get("mold_structure_type"),
"core_required": s.get("core_required"),
"dfm_violations": s.get("dfm_violations", []),
"undercut_regions_count": len(s.get("undercut_regions", [])),
"score_breakdown": s.get("score_breakdown", {}),
} for s in candidate_schemes], ensure_ascii=False, indent=2)
return _PARTING_USER.format(
bbox=json.dumps(bbox, ensure_ascii=False),
normal_stats=json.dumps(axis_normal_stats, ensure_ascii=False),
inertia=json.dumps(inertia_diag, ensure_ascii=False),
material=material.get("name", "ABS"),
cavity_count=cavity_count,
max_clamping_force="3000 吨(最大)",
is_foam="是" if material.get("is_foam") else "否",
schemes=schemes_text,
)
async def _chat(self, system, user, max_tokens=2000, expect_json=False, temperature=0.3):
url = f"{self._api_url}/chat/completions"
headers = {"Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json"}
payload = {
"model": self._model,
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
"max_tokens": max_tokens, "temperature": temperature,
}
if expect_json:
payload["response_format"] = {"type": "json_object"}
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.post(url, json=payload, headers=headers)
resp.raise_for_status()
content = resp.json()["choices"][0]["message"]["content"]
return content.strip() if content else None
@staticmethod
def _parse_json_response(raw):
try:
return json.loads(raw)
except json.JSONDecodeError:
m = re.search(r"\{[\s\S]*\}", raw)
if m:
try:
return json.loads(m.group())
except json.JSONDecodeError:
pass
logger.warning("LLM JSON 解析失败: %s...", raw[:200])
return None
llm_service = LLMService()
@@ -0,0 +1,45 @@
# services/material_service.py
"""材料属性管理服务"""
from typing import Dict, Any, List, Optional
MATERIAL_PROPERTIES: Dict[str, Dict[str, Any]] = {
"ABS": {"density": 1.05, "shrinkage": 0.005, "name": "ABS", "is_foam": False},
"PP": {"density": 0.90, "shrinkage": 0.016, "name": "PP", "is_foam": False},
"PE": {"density": 0.95, "shrinkage": 0.020, "name": "PE", "is_foam": False},
"PC": {"density": 1.20, "shrinkage": 0.007, "name": "PC", "is_foam": False},
"PA": {"density": 1.14, "shrinkage": 0.010, "name": "PA", "is_foam": False},
"POM": {"density": 1.41, "shrinkage": 0.020, "name": "POM", "is_foam": False},
"PMMA": {"density": 1.18, "shrinkage": 0.005, "name": "PMMA", "is_foam": False},
"PBT": {"density": 1.31, "shrinkage": 0.015, "name": "PBT", "is_foam": False},
"AlSi10Mg": {"density": 0.45, "shrinkage": 0.015, "name": "AlSi10Mg", "is_foam": True},
"AlSi12": {"density": 0.50, "shrinkage": 0.012, "name": "AlSi12", "is_foam": True},
"Pure Al Foam": {"density": 0.35, "shrinkage": 0.020, "name": "Pure Al Foam", "is_foam": True},
"AlSi7Mg": {"density": 0.40, "shrinkage": 0.018, "name": "AlSi7Mg", "is_foam": True},
}
# 默认回退材料
_DEFAULT_MATERIAL = MATERIAL_PROPERTIES["ABS"]
class MaterialService:
"""材料属性管理服务 — 集中管理材料字典,便于扩展和单测"""
@staticmethod
def get_material(material_name: str) -> Dict[str, Any]:
"""获取材料属性,不存在则回退到 ABS"""
return MATERIAL_PROPERTIES.get(material_name, _DEFAULT_MATERIAL)
@staticmethod
def is_foam_material(material_name: str) -> bool:
return MATERIAL_PROPERTIES.get(material_name, _DEFAULT_MATERIAL).get("is_foam", False)
@staticmethod
def list_all_materials() -> List[str]:
return list(MATERIAL_PROPERTIES.keys())
@staticmethod
def resolve_material(requested: str) -> str:
"""解析请求的材料名,若不在字典中则回退为 ABS"""
return requested if requested in MATERIAL_PROPERTIES else "ABS"
@@ -0,0 +1,665 @@
# services/processing_service.py
"""STP 文件处理流程编排器 — 协调解析、网格生成、型腔生成、保存、验证"""
import asyncio
import time
import traceback
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
from typing import Optional, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from moldinsight.core.stp_parser import STPParser
from moldinsight.core.geometry_analyzer import GeometryAnalyzer
from moldinsight.core.mold_generator import MoldCavityGenerator
from moldinsight.core.aluminum_foam_mold import AluminumFoamMoldGenerator
from moldinsight.core.mold_quality_inspector import AluminumFoamMoldQualityInspector
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.storage_integration_rustfs import StorageIntegrationService
from shared.services.redis_task_manager import redis_task_manager
from moldinsight.services.material_service import MaterialService
from moldinsight.services.calculation_service import CalculationService
from moldinsight.services.llm_service import llm_service
from shared.models.schemas import ProcessingStatus
from shared.database.database import db_manager
from shared.utils.html_generator import HTMLGenerator
from shared.utils.logger import get_logger
logger = get_logger(__name__)
class ProcessingService:
"""核心处理流程编排 — 协调 STP 解析、网格、型腔、计算、保存、验证"""
def __init__(self):
self.stp_parser = STPParser()
self.geometry_analyzer = GeometryAnalyzer()
self.mold_generator = MoldCavityGenerator(shrinkage_rate=0.005)
self.aluminum_foam_generator = AluminumFoamMoldGenerator(shrinkage_rate=0.015, draft_angle=3.0)
self.mold_quality_inspector = AluminumFoamMoldQualityInspector()
self.mesh_generator = MeshGenerator(quality="medium")
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]]] = {}
self._occ_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="occ")
# ─── 对外入口 ───
async def process_file_with_storage(
self,
task_id: str,
file_path: str,
stp_file_id: int,
process_params: Optional[Dict[str, Any]] = None,
):
"""处理文件的后台任务 — 使用独立数据库会话"""
# 创建独立的数据库会话,避免请求范围会话关闭
async with db_manager.session() as db_session:
try:
logger.info(f"开始处理文件并生成模具型腔: {file_path}")
from shared.config.settings import settings
file_size_bytes = Path(file_path).stat().st_size if Path(file_path).exists() else 0
file_size_mb = max(file_size_bytes / (1024 * 1024), 1)
timeout_seconds = min(
max(settings.PROCESSING_TIMEOUT_BASE, int(file_size_mb * settings.PROCESSING_TIMEOUT_PER_MB)),
1800,
)
logger.info(f"处理超时设置为 {timeout_seconds}s (文件 {file_size_mb:.1f}MB)")
try:
await asyncio.wait_for(
self.process_file_core(
task_id, file_path, stp_file_id, db_session, process_params
),
timeout_seconds,
)
except asyncio.TimeoutError:
logger.error(f"处理超时: {task_id}")
raise Exception(f"处理超时,超过{timeout_seconds}秒未完成")
except Exception as e:
logger.error(f"模具型腔生成失败: {e}")
await self.storage_service.update_stp_file_status(db_session, stp_file_id, "failed")
await self.storage_service.update_task_status(
db_session, task_id, "failed", error_message=str(e)
)
# 安全更新 Redis 任务状态
task = await redis_task_manager.get_task(task_id)
if task:
await redis_task_manager.update_task(task_id, {
"status": ProcessingStatus.FAILED,
"error": str(e),
"completed_at": str(datetime.now()),
})
async def process_file_core(
self,
task_id: str,
file_path: str,
stp_file_id: int,
db_session: AsyncSession,
process_params: Optional[Dict[str, Any]] = None,
):
"""核心处理逻辑"""
try:
logger.info(f"开始处理文件并生成模具型腔: {file_path}")
process_params = self._normalize_process_params(process_params)
stage_timings: Dict[str, float] = {}
# 1. 解析STP文件
await self.storage_service.update_task_status(
db_session, task_id, "processing", 20, "解析STP文件"
)
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
)
stage_timings["parse_stp"] = round(time.perf_counter() - stage_started, 3)
# 2. 生成网格数据并持久化
await self.storage_service.update_task_status(
db_session, task_id, "processing", 30, "生成网格数据"
)
stage_started = time.perf_counter()
mesh_result = await self._step_generate_mesh(
shape, geometry_data, file_path, db_session, stp_file_id, task_id
)
stage_timings["generate_mesh"] = round(time.perf_counter() - stage_started, 3)
# 3. 生成模具型腔
await self.storage_service.update_task_status(
db_session, task_id, "processing", 40, "生成模具型腔"
)
# 材料属性 — 通过 MaterialService 集中管理
requested_material = MaterialService.resolve_material(process_params["material"])
selected_material = dict(MaterialService.get_material(requested_material))
selected_material["shrinkage"] = process_params["shrinkage_rate"] / 100.0
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
)
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(
db_session, task_id, "processing", 60, "生成型腔详细数据"
)
stage_started = time.perf_counter()
detailed_cavity_json = CalculationService.build_plan_result(
geometry_data=geometry_data,
material=selected_material,
file_path=str(file_path),
plan_result=plan_result,
)
stage_timings["build_plan_result"] = round(time.perf_counter() - stage_started, 3)
best_scheme = CalculationService.get_best_scheme(detailed_cavity_json)
best_cavity_data = best_scheme.get("cavity_data", {}) if best_scheme else {}
best_key_info = best_scheme.get("key_info", {}) if best_scheme else {}
if best_cavity_data.get("mold_cavities"):
cavity_geometry = best_cavity_data["mold_cavities"].get("cavity", {})
logger.info(
f"推荐方案型腔数据已合并: cavity {cavity_geometry.get('vertex_count', 0)} 顶点"
)
# 5. 生成关键信息
cavity_key_info = best_key_info
# 6. 保存几何数据到数据库
await self.storage_service.update_task_status(
db_session, task_id, "processing", 70, "保存几何数据"
)
stage_started = time.perf_counter()
await self.storage_service.save_geometry_data(
db_session,
stp_file_id,
geometry_data,
geometry_data.get("analysis_method", "mold_cavity"),
)
# 7. 生成HTML可视化
await self.storage_service.update_task_status(
db_session, task_id, "processing", 85, "生成可视化报告"
)
pointcloud_data = None
lod_data = None
if mesh_result:
lod0 = mesh_result.get("lods", {}).get("0", {})
pointcloud_data = {
"points": mesh_result.get("points", []),
"normals": mesh_result.get("normals", []),
"vertices": lod0.get("vertices", []),
"faces": lod0.get("faces", []),
"point_count": mesh_result.get("point_count", 0),
"vertex_count": mesh_result.get("vertex_count", 0),
"face_count": mesh_result.get("face_count", 0),
}
if mesh_result and mesh_result.get("lods"):
lods = mesh_result["lods"]
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,
)
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
# 8. 保存模具型腔数据(包含方案级预览链接)
await self.storage_service.save_mold_cavity_data(
db_session, stp_file_id, detailed_cavity_json
)
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,
)
await self.storage_service.save_html_file(
db_session,
stp_file_id,
Path(html_file_path).name,
html_file_path,
)
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,
),
)
if analysis_result:
await self.storage_service.save_features_and_recommendations(
db_session,
stp_file_id,
analysis_result.get("detected_features", []),
analysis_result.get("design_recommendations", []),
)
await self._save_analysis_metrics(db_session, stp_file_id, analysis_result)
stage_timings["analyze_design"] = round(time.perf_counter() - stage_started, 3)
# 9.6 更新STP文件的分析摘要字段
await self.storage_service.update_stp_file_analysis_summary(
db_session,
stp_file_id,
volume=geometry_data.get("volume", 0),
surface_area=geometry_data.get("surface_area", 0),
product_weight=CalculationService.calculate_product_weight(
geometry_data.get("volume", 0), selected_material["density"]
),
)
# 9.7 FreeCAD 几何验证
stage_started = time.perf_counter()
verification_result = await self._step_verify(
file_path, db_session, task_id, stp_file_id, analysis_result
)
stage_timings["verify_geometry"] = round(time.perf_counter() - stage_started, 3)
# 9.8 LLM 增强分析
llm_report = None
stage_started = time.perf_counter()
if analysis_result:
side_action_ai = await llm_service.generate_side_action_analysis(
analysis_result, detailed_cavity_json
)
design_report = await llm_service.generate_design_report(
analysis_result, detailed_cavity_json
)
llm_report = llm_service.compose_llm_report(
design_report, side_action_ai
)
stage_timings["generate_llm_report"] = round(time.perf_counter() - stage_started, 3)
# 10. 完成处理
await self.storage_service.update_stp_file_status(db_session, stp_file_id, "completed")
await self.storage_service.update_task_status(
db_session, task_id, "completed", 100, "模具型腔生成完成"
)
await self.storage_service.update_task_parameters(
db_session,
task_id,
{
"stage_timings": stage_timings,
"material": requested_material,
"verification": verification_result,
"llm_report": llm_report,
"export_artifacts": export_artifacts,
**process_params,
},
)
# 更新任务缓存状态(仅保留轻量摘要,完整数据由PG+RustFS持久化)
await redis_task_manager.update_task(task_id, {
"status": ProcessingStatus.COMPLETED,
"completed_at": str(datetime.now()),
"geometry_data": geometry_data,
"analysis_result": analysis_result,
"key_info": best_key_info,
"best_scheme_id": detailed_cavity_json.get("best_scheme_id"),
"material": requested_material,
"parameters": process_params,
"stage_timings": stage_timings,
"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,
})
logger.info(f"模具型腔生成完成: {task_id}")
logger.info(f"key_info metadata: {detailed_cavity_json.get('metadata', {})}")
logger.info(f"key_info manufacturing_info: {detailed_cavity_json.get('manufacturing_info', {})}")
logger.info(f"key_info geometric_characteristics: {detailed_cavity_json.get('mold_cavities', {}).get('cavity_key_info', {}).get('geometric_characteristics', {})}")
except Exception as e:
logger.error(f"模具型腔生成失败: {e}")
await self.storage_service.update_stp_file_status(db_session, stp_file_id, "failed")
await self.storage_service.update_task_status(
db_session, task_id, "failed", error_message=str(e)
)
task = await redis_task_manager.get_task(task_id)
if task:
await redis_task_manager.update_task(task_id, {
"status": ProcessingStatus.FAILED,
"error": str(e),
"completed_at": str(datetime.now()),
})
# ─── 内部步骤 ───
async def _step_generate_mesh(
self, shape, geometry_data: dict, file_path: str,
db_session: AsyncSession, stp_file_id: int, task_id: str,
) -> Optional[Dict[str, Any]]:
"""生成多级LOD网格并持久化,一次OCC剖分+trimesh简化,失败不影响主流程"""
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
)
lod0 = mesh_result.get("lods", {}).get("0", {})
vertices = lod0.get("vertices", [])
faces = lod0.get("faces", [])
points = mesh_result.get("points", [])
normals = mesh_result.get("normals", [])
point_count = mesh_result.get("point_count", 0)
vertex_count = lod0.get("vertex_count", mesh_result.get("vertex_count", 0))
face_count = lod0.get("face_count", mesh_result.get("face_count", 0))
if vertices and faces:
bbox = geometry_data.get("bounding_box", {})
mesh_json = {
"metadata": {
"file_name": Path(file_path).name,
"generated_at": datetime.now().isoformat(),
"quality": "medium",
"vertex_count": vertex_count,
"face_count": face_count,
"point_count": point_count,
},
"mesh": {
"vertices": vertices,
"faces": faces,
},
"pointcloud": {
"points": points,
"normals": normals,
"count": point_count,
},
"bounding_box": bbox,
}
await self.storage_service.save_mesh_data(
db_session,
stp_file_id=stp_file_id,
mesh_json=mesh_json,
quality="medium",
)
await redis_task_manager.update_task(task_id, {
"mesh_summary": {
"vertex_count": vertex_count,
"face_count": face_count,
"point_count": point_count,
"quality": "medium",
}
})
except Exception as mesh_err:
logger.warning(f"网格生成或保存失败,不影响主流程: {mesh_err}")
return mesh_result
async def _step_generate_cavity(
self, shape, selected_material: dict, is_foam_material: bool, process_params: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""生成多方案分模结果"""
plan_result = None
try:
if 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,
),
)
logger.info(
f"多方案分模完成: 生成 {len(plan_result.get('candidate_schemes', []))} 套方案"
)
except Exception as cavity_err:
logger.warning(f"多方案分模失败,使用简化数据: {cavity_err}")
traceback.print_exc()
plan_result = None
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
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:
return None
if scheme_id:
return scheme_map.get(scheme_id)
return next(iter(scheme_map.values()), None)
@staticmethod
def _normalize_process_params(process_params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
payload = dict(process_params or {})
return {
"material": MaterialService.resolve_material(str(payload.get("material", "ABS"))),
"draft_angle": float(payload.get("draft_angle", 2.0)),
"shrinkage_rate": float(payload.get("shrinkage_rate", 0.5)),
"parting_precision": float(payload.get("parting_precision", 0.1)),
"cavity_match": int(payload.get("cavity_match", 95)),
}
async def _step_verify(
self, file_path: str, db_session: AsyncSession,
task_id: str, stp_file_id: int, analysis_result: Optional[dict],
) -> Optional[Dict[str, Any]]:
"""FreeCAD 几何验证(可通过配置禁用)"""
from shared.config.settings import settings
if not settings.ENABLE_FREECAD_VERIFICATION:
logger.info("FreeCAD验证已禁用(设置 ENABLE_FREECAD_VERIFICATION=true 启用)")
return {"status": "disabled", "reason": "FreeCAD验证已禁用"}
await self.storage_service.update_task_status(
db_session, task_id, "processing", 90, "FreeCAD几何验证"
)
try:
from moldinsight.services.verification_service import GeometryVerificationService
verification_svc = GeometryVerificationService(timeout=settings.FREECAD_VERIFICATION_TIMEOUT)
verification_result = await verification_svc.verify_stp_file(file_path)
if verification_result and analysis_result:
await self._save_verification_metrics(db_session, stp_file_id, verification_result)
logger.info(f"FreeCAD验证完成: {verification_result.get('status', 'unknown') if verification_result else 'failed'}")
return verification_result
except Exception as ve:
logger.warning(f"FreeCAD验证失败(不影响主流程): {ve}")
return {"status": "error", "error": str(ve)}
async def _attach_scheme_previews(
self,
detailed_cavity_json: Dict[str, Any],
geometry_data: Dict[str, Any],
stp_filename: str,
pointcloud_data: Optional[Dict[str, Any]] = None,
lod_data: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""为候选分模方案生成轻量摘要链接(完整HTML仅最优方案按需生成)"""
candidate_schemes = detailed_cavity_json.get("candidate_schemes", [])
if not candidate_schemes:
return detailed_cavity_json
for scheme in candidate_schemes:
cavity_data = scheme.get("cavity_data")
if not cavity_data:
continue
suffix = scheme.get("scheme_id")
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(
geometry_data, cavity_data
)
self.html_generator.save_data_file(summary_content, summary_name)
scheme["summary_file"] = f"/html/{summary_name}"
best_scheme = CalculationService.get_best_scheme(detailed_cavity_json)
if best_scheme:
detailed_cavity_json["html_file"] = best_scheme.get("html_file")
return detailed_cavity_json
# ─── 指标持久化 ───
async def _save_analysis_metrics(self, session: AsyncSession, stp_file_id: int, analysis_result: dict):
"""保存分析指标到数据库"""
from shared.models.database import AnalysisMetrics
quality_metrics = analysis_result.get("quality_metrics", {})
analysis_summary = analysis_result.get("analysis_summary", "")
metrics = AnalysisMetrics(
stp_file_id=stp_file_id,
volume_utilization=quality_metrics.get("volume_utilization", 0),
topology_complexity=quality_metrics.get("topology_complexity", 0),
wall_uniformity=quality_metrics.get("wall_uniformity", 0),
analysis_summary=analysis_summary,
)
session.add(metrics)
await session.commit()
logger.info(f"分析指标保存成功: {metrics.id}")
async def _save_verification_metrics(self, session: AsyncSession, stp_file_id: int, verification_result: dict):
"""保存验证指标到数据库"""
from shared.models.database import AnalysisMetrics
from sqlalchemy import select
result = await session.execute(
select(AnalysisMetrics).where(AnalysisMetrics.stp_file_id == stp_file_id)
)
metrics = result.scalar_one_or_none()
comparison = verification_result.get("comparison", {})
volume_comparison = comparison.get("volume", {})
area_comparison = comparison.get("surface_area", {})
if metrics:
metrics.verification_status = verification_result.get("status", "unknown")
metrics.verification_volume_diff = volume_comparison.get("difference_percent", 0)
metrics.verification_area_diff = area_comparison.get("difference_percent", 0)
metrics.verification_details = verification_result
else:
metrics = AnalysisMetrics(
stp_file_id=stp_file_id,
verification_status=verification_result.get("status", "unknown"),
verification_volume_diff=volume_comparison.get("difference_percent", 0),
verification_area_diff=area_comparison.get("difference_percent", 0),
verification_details=verification_result,
)
session.add(metrics)
await session.commit()
logger.info(f"验证指标保存成功: stp_file_id={stp_file_id}")
# 模块级单例,供路由层直接使用
processing_service = ProcessingService()
@@ -0,0 +1,376 @@
# services/storage_integration.py
"""存储集成服务 - 协调 PostgreSQL 和 MinIO"""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pathlib import Path
from typing import Optional, Dict, Any
import json
from shared.models.database import (
STPFile, GeometryData, MoldCavityData,
HTMLFile, ProcessingTask, User,
FeatureDetection, DesignRecommendation,
UserActivity, SystemLog
)
from moldinsight.storage.object_storage import storage_manager
from shared.utils.logger import get_logger
logger = get_logger(__name__)
class StorageIntegrationService:
"""存储集成服务"""
async def save_stp_file(self, session: AsyncSession,
file_path: Path,
original_filename: str,
user_id: Optional[int] = None) -> STPFile:
"""保存STP文件到PostgreSQL元数据 + MinIO对象存储"""
# 1. 上传到MinIO
upload_result = await storage_manager.upload_stp_file(
file_path,
original_filename
)
# 2. 创建PostgreSQL记录
stp_file = STPFile(
user_id=user_id,
object_key=upload_result['object_key'],
storage_bucket=storage_manager.buckets['stp_files'],
original_filename=original_filename,
file_size=upload_result['file_size'],
file_hash=upload_result['file_hash'],
status="uploaded",
file_path=str(file_path) # 保留本地路径以兼容
)
session.add(stp_file)
await session.commit()
await session.refresh(stp_file)
logger.info(f"STP文件保存成功: {stp_file.id}")
return stp_file
async def save_geometry_data(self, session: AsyncSession,
stp_file_id: int,
geometry_json: Dict[str, Any],
analysis_method: str = "pythonocc") -> GeometryData:
"""保存几何数据到PostgreSQL元数据 + MinIO对象存储"""
# 1. 获取文件哈希
stp_file = await session.get(STPFile, stp_file_id)
file_hash = stp_file.file_hash
# 2. 上传到MinIO
upload_result = await storage_manager.upload_geometry_data(
geometry_json,
file_hash
)
# 3. 创建PostgreSQL记录
geometry_data = GeometryData(
stp_file_id=stp_file_id,
object_key=upload_result['object_key'],
storage_bucket=storage_manager.buckets['geometry_data'],
analysis_method=analysis_method,
# 提取摘要字段
volume=geometry_json.get('geometry_data', {}).get('volume'),
surface_area=geometry_json.get('geometry_data', {}).get('surface_area'),
bounding_box_min=geometry_json.get('geometry_data', {}).get('bounding_box', {}).get('min'),
bounding_box_max=geometry_json.get('geometry_data', {}).get('bounding_box', {}).get('max'),
center_of_mass=geometry_json.get('geometry_data', {}).get('center_of_mass'),
topology_faces=geometry_json.get('geometry_data', {}).get('topology', {}).get('faces'),
topology_edges=geometry_json.get('geometry_data', {}).get('topology', {}).get('edges'),
topology_vertices=geometry_json.get('geometry_data', {}).get('topology', {}).get('vertices')
)
session.add(geometry_data)
await session.commit()
await session.refresh(geometry_data)
logger.info(f"几何数据保存成功: {geometry_data.id}")
return geometry_data
async def save_mold_cavity_data(self, session: AsyncSession,
stp_file_id: int,
cavity_json: Dict[str, Any]) -> MoldCavityData:
"""保存模具型腔数据到PostgreSQL元数据 + MinIO对象存储"""
# 1. 获取文件哈希
stp_file = await session.get(STPFile, stp_file_id)
file_hash = stp_file.file_hash
# 2. 上传到MinIO
upload_result = await storage_manager.upload_mold_cavity_data(
cavity_json,
file_hash
)
# 3. 提取关键信息
metadata = cavity_json.get('metadata', {})
product_analysis = cavity_json.get('product_analysis', {})
manufacturing_info = cavity_json.get('manufacturing_info', {})
mold_size = manufacturing_info.get('estimated_mold_size', {})
key_info = cavity_json.get('mold_cavities', {}).get('cavity_key_info', {})
# 4. 创建PostgreSQL记录
mold_cavity = MoldCavityData(
stp_file_id=stp_file_id,
detailed_object_key=upload_result['object_key'],
storage_bucket=storage_manager.buckets['mold_cavities'],
# 模具参数
mold_material=manufacturing_info.get('recommended_material', 'Aluminum Alloy 7075'),
shrinkage_rate=metadata.get('shrinkage_rate', 0.005),
draft_angle=metadata.get('draft_angle', 2.0),
# 提取的摘要字段
cavity_key_info=key_info,
mold_size_length=mold_size.get('length'),
mold_size_width=mold_size.get('width'),
mold_size_height=mold_size.get('height'),
estimated_clamping_force=manufacturing_info.get('estimated_clamping_force'),
product_volume=product_analysis.get('volume'),
# 从key_info中提取(如果存在)
product_weight=key_info.get('geometric_characteristics', {}).get('product_weight'),
wall_thickness_range=key_info.get('geometric_characteristics', {}).get('wall_thickness_range'),
complexity_score=key_info.get('geometric_characteristics', {}).get('complexity_score'),
# 质量评估
weld_line_risk=key_info.get('quality_considerations', {}).get('potential_weld_lines'),
sink_mark_risk=key_info.get('quality_considerations', {}).get('sink_mark_areas'),
warpage_risk=key_info.get('quality_considerations', {}).get('warpage_risk')
)
session.add(mold_cavity)
await session.commit()
await session.refresh(mold_cavity)
logger.info(f"模具型腔数据保存成功: {mold_cavity.id}")
return mold_cavity
async def save_html_file(self, session: AsyncSession,
stp_file_id: int,
html_content: str,
filename: str) -> HTMLFile:
"""保存HTML文件到PostgreSQL元数据 + MinIO对象存储"""
# 1. 获取文件哈希
stp_file = await session.get(STPFile, stp_file_id)
file_hash = stp_file.file_hash
# 2. 上传到MinIO
upload_result = await storage_manager.upload_html_file(
html_content,
filename,
file_hash
)
# 3. 创建PostgreSQL记录
html_file = HTMLFile(
stp_file_id=stp_file_id,
object_key=upload_result['object_key'],
storage_bucket=storage_manager.buckets['html_files'],
filename=filename,
file_path=str(Path('html_output') / filename), # 保留本地路径
html_content=html_content # 保留内容以兼容
)
session.add(html_file)
await session.commit()
await session.refresh(html_file)
logger.info(f"HTML文件保存成功: {html_file.id}")
return html_file
async def save_features_and_recommendations(
self, session: AsyncSession,
stp_file_id: int,
features: list,
recommendations: list
):
"""保存特征检测结果和设计建议"""
# 1. 保存特征
for feature in features:
feature_record = FeatureDetection(
stp_file_id=stp_file_id,
feature_type=feature.get('feature_type'),
confidence=feature.get('confidence'),
location=feature.get('location'),
dimensions=feature.get('dimensions'),
parameters=feature.get('parameters')
)
session.add(feature_record)
# 2. 保存建议
for rec in recommendations:
rec_record = DesignRecommendation(
stp_file_id=stp_file_id,
rec_type=rec.get('rec_type'),
priority=rec.get('priority'),
description=rec.get('description'),
reason=rec.get('reason'),
parameters=rec.get('parameters')
)
session.add(rec_record)
await session.commit()
logger.info(f"保存了 {len(features)} 个特征和 {len(recommendations)} 个建议")
async def log_user_activity(self, session: AsyncSession,
user_id: int,
activity_type: str,
resource_type: Optional[str] = None,
resource_id: Optional[int] = None,
description: Optional[str] = None,
metadata: Optional[Dict] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None):
"""记录用户活动"""
activity = UserActivity(
user_id=user_id,
activity_type=activity_type,
resource_type=resource_type,
resource_id=resource_id,
description=description,
metadata=metadata,
ip_address=ip_address,
user_agent=user_agent
)
session.add(activity)
await session.commit()
logger.debug(f"用户活动记录: {activity_type} by user {user_id}")
async def get_stp_file_with_data(self, session: AsyncSession,
stp_file_id: int) -> Dict[str, Any]:
"""获取STP文件及其所有关联数据"""
# 1. 获取STP文件记录
stp_file = await session.get(STPFile, stp_file_id)
if not stp_file:
raise ValueError(f"STP文件不存在: {stp_file_id}")
result = {
'metadata': {
'id': stp_file.id,
'original_filename': stp_file.original_filename,
'file_size': stp_file.file_size,
'file_hash': stp_file.file_hash,
'upload_time': stp_file.upload_time.isoformat() if stp_file.upload_time else None,
'status': stp_file.status,
'user_id': stp_file.user_id
},
'geometry_data': None,
'mold_cavity_data': None,
'html_file': None,
'features': [],
'recommendations': []
}
# 2. 从MinIO获取数据
try:
# 几何数据
if stp_file.geometry_data:
geo_data_bytes = await storage_manager.download_file(
'geometry_data',
stp_file.geometry_data.object_key
)
result['geometry_data'] = json.loads(geo_data_bytes.decode('utf-8'))
# 模具型腔数据
if stp_file.mold_cavity_data:
cavity_data_bytes = await storage_manager.download_file(
'mold_cavities',
stp_file.mold_cavity_data.detailed_object_key
)
result['mold_cavity_data'] = json.loads(cavity_data_bytes.decode('utf-8'))
# HTML文件
if stp_file.html_file:
html_bytes = await storage_manager.download_file(
'html_files',
stp_file.html_file.object_key
)
result['html_content'] = html_bytes.decode('utf-8')
except Exception as e:
logger.error(f"从MinIO获取数据失败: {e}")
# 3. 从PostgreSQL获取特征和建议
features = await session.execute(
select(FeatureDetection).where(FeatureDetection.stp_file_id == stp_file_id)
)
result['features'] = [
{
'feature_type': f.feature_type,
'confidence': f.confidence,
'location': f.location,
'dimensions': f.dimensions,
'parameters': f.parameters
}
for f in features.scalars().all()
]
recommendations = await session.execute(
select(DesignRecommendation).where(DesignRecommendation.stp_file_id == stp_file_id)
)
result['recommendations'] = [
{
'rec_type': r.rec_type,
'priority': r.priority,
'description': r.description,
'reason': r.reason,
'parameters': r.parameters
}
for r in recommendations.scalars().all()
]
return result
async def delete_stp_file_cascade(self, session: AsyncSession,
stp_file_id: int):
"""级联删除STP文件及其所有关联数据"""
stp_file = await session.get(STPFile, stp_file_id)
if not stp_file:
raise ValueError(f"STP文件不存在: {stp_file_id}")
# 1. 删除MinIO中的文件
try:
if stp_file.object_key:
await storage_manager.delete_file('stp_files', stp_file.object_key)
except Exception as e:
logger.error(f"删除MinIO文件失败: {e}")
try:
if stp_file.geometry_data:
await storage_manager.delete_file('geometry_data', stp_file.geometry_data.object_key)
except Exception as e:
logger.error(f"删除几何数据失败: {e}")
try:
if stp_file.mold_cavity_data:
await storage_manager.delete_file('mold_cavities', stp_file.mold_cavity_data.detailed_object_key)
except Exception as e:
logger.error(f"删除型腔数据失败: {e}")
try:
if stp_file.html_file:
await storage_manager.delete_file('html_files', stp_file.html_file.object_key)
except Exception as e:
logger.error(f"删除HTML文件失败: {e}")
# 2. 级联删除PostgreSQL记录(通过外键自动处理)
await session.delete(stp_file)
await session.commit()
logger.info(f"STP文件及其关联数据已删除: {stp_file_id}")
# 全局存储集成服务实例
storage_integration = StorageIntegrationService()
@@ -0,0 +1,850 @@
# services/storage_integration_rustfs.py
"""存储集成服务 - 协调 PostgreSQL 和 RustFS"""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from pathlib import Path
from typing import Optional, Dict, Any
import json
from datetime import datetime
import uuid
from shared.models.database import (
STPFile, GeometryData, MeshData, MoldCavityData,
HTMLFile, ProcessingTask, User,
FeatureDetection, DesignRecommendation,
UserActivity, SystemLog
)
from moldinsight.storage.rustfs_storage import rustfs_manager
from shared.utils.logger import get_logger
logger = get_logger(__name__)
class StorageIntegrationService:
"""存储集成服务 - PostgreSQL + RustFS"""
@staticmethod
def _resolve_best_scheme_payload(cavity_json: Dict[str, Any]) -> Dict[str, Any]:
"""从新多方案/旧单方案结构中解析推荐方案和型腔详情。"""
if not isinstance(cavity_json, dict):
return {
"best_scheme_id": None,
"best_scheme": {},
"best_cavity_data": {},
"key_info": {},
}
candidate_schemes = cavity_json.get("candidate_schemes") or []
if not candidate_schemes:
key_info = cavity_json.get("mold_cavities", {}).get("cavity_key_info", {})
return {
"best_scheme_id": cavity_json.get("best_scheme_id"),
"best_scheme": {},
"best_cavity_data": cavity_json,
"key_info": key_info,
}
best_scheme_id = cavity_json.get("best_scheme_id")
best_scheme = candidate_schemes[0]
if best_scheme_id:
for scheme in candidate_schemes:
if scheme.get("scheme_id") == best_scheme_id:
best_scheme = scheme
break
best_cavity_data = best_scheme.get("cavity_data", {}) if isinstance(best_scheme, dict) else {}
key_info = best_scheme.get("key_info", {}) if isinstance(best_scheme, dict) else {}
if not key_info:
key_info = best_cavity_data.get("mold_cavities", {}).get("cavity_key_info", {})
return {
"best_scheme_id": best_scheme.get("scheme_id") or best_scheme_id,
"best_scheme": best_scheme,
"best_cavity_data": best_cavity_data,
"key_info": key_info,
}
@staticmethod
def _parse_first_number(value: Any) -> Optional[float]:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
pass
import re
matches = re.findall(r"\d+(?:\.\d+)?", str(value))
if not matches:
return None
try:
return float(matches[0])
except (TypeError, ValueError):
return None
async def save_stp_file(self, session: AsyncSession,
file_path: Path,
original_filename: str,
user_id: Optional[int] = None,
upload_batch: Optional[str] = None) -> STPFile:
"""保存STP文件到PostgreSQL元数据 + RustFS对象存储
支持同一文件多次上传,每次上传都会创建新记录
"""
# 1. 上传到RustFS
upload_result = await rustfs_manager.upload_file(
file_type='stp_files',
file_path=file_path,
original_filename=original_filename,
metadata={
'original_filename': original_filename,
'user_id': str(user_id) if user_id else 'anonymous',
'upload_batch': upload_batch or str(uuid.uuid4())
}
)
file_hash = upload_result['file_hash']
batch_id = upload_batch or str(uuid.uuid4())
# 2. 创建新PostgreSQL记录(每次上传都创建新记录)
from datetime import datetime
stp_file = STPFile(
user_id=user_id,
object_key=upload_result['object_key'],
storage_bucket=upload_result['bucket'],
original_filename=original_filename,
file_size=upload_result['file_size'],
file_hash=file_hash,
upload_batch=batch_id,
status="uploaded",
file_path=str(file_path),
upload_time=datetime.now()
)
session.add(stp_file)
await session.commit()
await session.refresh(stp_file)
logger.info(f"STP文件保存成功 RustFS: {stp_file.id}, 批次: {batch_id}")
return stp_file
async def create_processing_task(
self,
session: AsyncSession,
task_id: str,
stp_file_id: int,
task_type: str = "stp_parsing",
parameters: Optional[Dict[str, Any]] = None,
) -> ProcessingTask:
"""创建处理任务记录"""
try:
task = ProcessingTask(
task_id=task_id,
stp_file_id=stp_file_id,
task_type=task_type,
status="pending",
started_time=datetime.now(),
parameters=parameters or {},
)
session.add(task)
await session.commit()
await session.refresh(task)
logger.info(f"处理任务创建成功: {task_id}")
return task
except Exception as e:
await session.rollback()
logger.error(f"创建处理任务失败: {e}")
raise
async def update_task_status(
self,
session: AsyncSession,
task_id: str,
status: str,
progress: Optional[int] = None,
current_step: Optional[str] = None,
error_message: Optional[str] = None
):
"""更新任务状态"""
try:
update_data = {
"status": status,
"completed_time": datetime.now() if status in ["completed", "failed"] else None,
"error_message": error_message
}
if progress is not None:
update_data["progress"] = progress
if current_step is not None:
update_data["current_step"] = current_step
await session.execute(
update(ProcessingTask)
.where(ProcessingTask.task_id == task_id)
.values(**update_data)
)
await session.commit()
logger.info(f"任务状态更新: {task_id} -> {status}")
except Exception as e:
await session.rollback()
logger.error(f"更新任务状态失败: {e}")
raise
async def update_task_parameters(
self,
session: AsyncSession,
task_id: str,
parameters: Dict[str, Any],
):
"""合并更新任务参数,便于保存阶段耗时等元数据。"""
try:
task = await session.execute(
select(ProcessingTask).where(ProcessingTask.task_id == task_id)
)
task = task.scalar_one_or_none()
if task is None:
return
merged = dict(task.parameters or {})
merged.update(parameters or {})
task.parameters = merged
await session.commit()
except Exception as e:
await session.rollback()
logger.error(f"更新任务参数失败: {e}")
raise
async def update_stp_file_status(self, session: AsyncSession, stp_file_id: int, status: str):
"""更新STP文件状态"""
try:
await session.execute(
update(STPFile)
.where(STPFile.id == stp_file_id)
.values(
status=status,
processed_time=datetime.now() if status in ["completed", "failed"] else None
)
)
await session.commit()
logger.info(f"STP文件状态更新: ID {stp_file_id} -> {status}")
except Exception as e:
await session.rollback()
logger.error(f"更新STP文件状态失败: {e}")
raise
async def save_geometry_data(self, session: AsyncSession,
stp_file_id: int,
geometry_json: Dict[str, Any],
analysis_method: str = "pythonocc") -> GeometryData:
"""保存几何数据到PostgreSQL元数据 + RustFS对象存储"""
# 1. 获取文件哈希
stp_file = await session.get(STPFile, stp_file_id)
file_hash = stp_file.file_hash
# 2. 上传到RustFS
upload_result = await rustfs_manager.upload_json_data(
file_type='geometry_data',
json_data=geometry_json,
file_hash=file_hash
)
# 3. 提取几何数据
if 'geometry_data' in geometry_json:
geo_data = geometry_json['geometry_data']
else:
geo_data = geometry_json
# 4. 创建PostgreSQL记录
geometry_data = GeometryData(
stp_file_id=stp_file_id,
object_key=upload_result['object_key'],
storage_bucket=upload_result['bucket'],
analysis_method=analysis_method,
# 提取摘要字段
volume=geo_data.get('volume'),
surface_area=geo_data.get('surface_area'),
bounding_box_min=geo_data.get('bounding_box', {}).get('min'),
bounding_box_max=geo_data.get('bounding_box', {}).get('max'),
center_of_mass=geo_data.get('center_of_mass'),
topology_faces=geo_data.get('topology', {}).get('faces'),
topology_edges=geo_data.get('topology', {}).get('edges'),
topology_vertices=geo_data.get('topology', {}).get('vertices')
)
session.add(geometry_data)
await session.commit()
await session.refresh(geometry_data)
logger.info(f"几何数据保存成功 RustFS: {geometry_data.id}")
return geometry_data
async def save_mesh_data(
self,
session: AsyncSession,
stp_file_id: int,
mesh_json: Dict[str, Any],
quality: str = "medium"
) -> MeshData:
"""保存网格数据到 PostgreSQL 元数据 + RustFS 对象存储
mesh_json 为完整网格 JSON(顶点、面、点云等),
PostgreSQL 只存 object_key 和一些摘要字段,详细数据放在 RustFS。
"""
# 1. 获取文件哈希
stp_file = await session.get(STPFile, stp_file_id)
file_hash = stp_file.file_hash
# 2. 上传网格 JSON 到 RustFS
upload_result = await rustfs_manager.upload_json_data(
file_type='mesh_data',
json_data=mesh_json,
file_hash=file_hash
)
# 3. 提取摘要信息
mesh_section = mesh_json.get('mesh', {})
pointcloud_section = mesh_json.get('pointcloud', {})
bbox = mesh_json.get('bounding_box', {})
vertices = mesh_section.get('vertices') or []
faces = mesh_section.get('faces') or []
vertex_count = len(vertices)
face_count = len(faces)
point_count = pointcloud_section.get('count')
# 4. 创建 PostgreSQL 记录
mesh_data = MeshData(
stp_file_id=stp_file_id,
object_key=upload_result['object_key'],
storage_bucket=upload_result['bucket'],
quality=quality,
vertex_count=vertex_count,
face_count=face_count,
point_count=point_count,
bounding_box_min=bbox.get('min'),
bounding_box_max=bbox.get('max')
)
session.add(mesh_data)
await session.commit()
await session.refresh(mesh_data)
logger.info(f"网格数据保存成功 RustFS: {mesh_data.id}")
return mesh_data
async def save_mold_cavity_data(self, session: AsyncSession,
stp_file_id: int,
cavity_json: Dict[str, Any]) -> MoldCavityData:
"""保存模具型腔数据到PostgreSQL元数据 + RustFS对象存储"""
# 1. 获取文件哈希
stp_file = await session.get(STPFile, stp_file_id)
file_hash = stp_file.file_hash
# 2. 上传到RustFS
upload_result = await rustfs_manager.upload_json_data(
file_type='mold_cavities',
json_data=cavity_json,
file_hash=file_hash
)
# 3. 提取关键信息(兼容多方案与单方案结构)
payload = self._resolve_best_scheme_payload(cavity_json)
best_scheme_id = payload.get("best_scheme_id")
best_scheme = payload.get("best_scheme") or {}
best_cavity_data = payload.get("best_cavity_data") or {}
metadata = best_cavity_data.get('metadata', {})
product_analysis = best_cavity_data.get('product_analysis', {})
manufacturing_info = best_cavity_data.get('manufacturing_info', {})
mold_size = manufacturing_info.get('estimated_mold_size', {})
key_info = payload.get("key_info") or {}
if not key_info:
key_info = best_cavity_data.get('mold_cavities', {}).get('cavity_key_info', {})
mold_material = (
metadata.get("selected_material")
or manufacturing_info.get("recommended_material")
or 'Aluminum Alloy 7075'
)
parting_line_length = self._parse_first_number(
manufacturing_info.get("parting_line_length")
)
# 4. 创建PostgreSQL记录
mold_cavity = MoldCavityData(
stp_file_id=stp_file_id,
detailed_object_key=upload_result['object_key'],
storage_bucket=upload_result['bucket'],
# 模具参数
mold_material=mold_material,
shrinkage_rate=metadata.get('shrinkage_rate', 0.005),
draft_angle=metadata.get('draft_angle', 2.0),
parting_line_length=parting_line_length,
# 提取的摘要字段
cavity_key_info=key_info,
mold_size_length=mold_size.get('length'),
mold_size_width=mold_size.get('width'),
mold_size_height=mold_size.get('height'),
estimated_clamping_force=manufacturing_info.get('estimated_clamping_force'),
product_volume=product_analysis.get('volume'),
# 从key_info中提取(如果存在)
product_weight=key_info.get('geometric_characteristics', {}).get('product_weight'),
wall_thickness_range=key_info.get('geometric_characteristics', {}).get('wall_thickness_range'),
complexity_score=key_info.get('geometric_characteristics', {}).get('complexity_score'),
# 质量评估
weld_line_risk=key_info.get('quality_considerations', {}).get('potential_weld_lines'),
sink_mark_risk=key_info.get('quality_considerations', {}).get('sink_mark_areas'),
warpage_risk=key_info.get('quality_considerations', {}).get('warpage_risk'),
# 多方案可信化摘要
best_scheme_id=best_scheme_id,
confidence_score=best_scheme.get("confidence_score"),
is_fallback=best_scheme.get("is_fallback"),
fallback_reason=best_scheme.get("fallback_reason"),
)
session.add(mold_cavity)
await session.commit()
await session.refresh(mold_cavity)
logger.info(f"模具型腔数据保存成功 RustFS: {mold_cavity.id}")
return mold_cavity
async def save_html_file(self, session: AsyncSession,
stp_file_id: int,
filename: str,
file_path: str,
html_content: Optional[str] = None,
visualization_type: str = "3d_viewer") -> HTMLFile:
"""保存HTML文件到PostgreSQL元数据 + RustFS对象存储"""
# 1. 获取文件哈希
stp_file = await session.get(STPFile, stp_file_id)
file_hash = stp_file.file_hash
# 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
)
# 4. 创建PostgreSQL记录
html_file = HTMLFile(
stp_file_id=stp_file_id,
object_key=upload_result['object_key'],
storage_bucket=upload_result['bucket'],
filename=filename,
file_path=file_path, # 保留本地路径
html_content=html_content, # 保留内容以兼容
visualization_type=visualization_type
)
session.add(html_file)
await session.commit()
await session.refresh(html_file)
logger.info(f"HTML文件保存成功 RustFS: {html_file.id}")
return html_file
async def save_features_and_recommendations(
self, session: AsyncSession,
stp_file_id: int,
features: list,
recommendations: list
):
"""保存特征检测结果和设计建议"""
# 1. 保存特征
for feature in features:
feature_record = FeatureDetection(
stp_file_id=stp_file_id,
feature_type=feature.get('feature_type'),
confidence=feature.get('confidence'),
location=feature.get('location'),
dimensions=feature.get('dimensions'),
parameters=feature.get('parameters')
)
session.add(feature_record)
# 2. 保存建议
for rec in recommendations:
rec_record = DesignRecommendation(
stp_file_id=stp_file_id,
rec_type=rec.get('type') or rec.get('rec_type'),
priority=rec.get('priority'),
description=rec.get('description'),
reason=rec.get('reason'),
parameters=rec.get('parameters')
)
session.add(rec_record)
await session.commit()
logger.info(f"保存了 {len(features)} 个特征和 {len(recommendations)} 个建议")
async def log_user_activity(self, session: AsyncSession,
user_id: int,
activity_type: str,
resource_type: Optional[str] = None,
resource_id: Optional[int] = None,
description: Optional[str] = None,
metadata: Optional[Dict] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None):
"""记录用户活动"""
activity = UserActivity(
user_id=user_id,
activity_type=activity_type,
resource_type=resource_type,
resource_id=resource_id,
description=description,
meta_data=metadata,
ip_address=ip_address,
user_agent=user_agent
)
session.add(activity)
await session.commit()
logger.debug(f"用户活动记录: {activity_type} by user {user_id}")
async def get_stp_file_with_data(self, session: AsyncSession,
stp_file_id: int) -> Dict[str, Any]:
"""获取STP文件及其所有关联数据"""
from sqlalchemy.orm import joinedload
try:
# 1. 获取STP文件记录(使用 joinedload 预加载关联数据)
result = await session.execute(
select(STPFile).options(
joinedload(STPFile.geometry_data),
joinedload(STPFile.mesh_data),
joinedload(STPFile.mold_cavity_data),
joinedload(STPFile.html_file),
joinedload(STPFile.analysis_metrics)
).where(STPFile.id == stp_file_id)
)
stp_file = result.scalar_one_or_none()
if not stp_file:
raise ValueError(f"STP文件不存在: {stp_file_id}")
except Exception as e:
logger.error(f"获取STP文件记录失败: {e}")
raise
result = {
'metadata': {
'id': stp_file.id,
'original_filename': stp_file.original_filename,
'file_size': stp_file.file_size,
'file_hash': stp_file.file_hash,
'upload_time': stp_file.upload_time.isoformat() if stp_file.upload_time else None,
'status': stp_file.status,
'user_id': stp_file.user_id
},
'geometry_data': None,
'mesh_data': None,
'mold_cavity_data': None,
'html_content': None,
'features': [],
'recommendations': [],
'analysis_metrics': None # 新增分析指标字段
}
# 2. 从RustFS获取数据
try:
# 几何数据
if stp_file.geometry_data:
geo_data_bytes = await rustfs_manager.download_file(
file_type='geometry_data',
object_key=stp_file.geometry_data.object_key
)
result['geometry_data'] = json.loads(geo_data_bytes.decode('utf-8'))
# 模具型腔数据
if stp_file.mold_cavity_data:
cavity_data_bytes = await rustfs_manager.download_file(
file_type='mold_cavities',
object_key=stp_file.mold_cavity_data.detailed_object_key
)
result['mold_cavity_data'] = json.loads(cavity_data_bytes.decode('utf-8'))
# 网格数据
if stp_file.mesh_data:
mesh_bytes = await rustfs_manager.download_file(
file_type='mesh_data',
object_key=stp_file.mesh_data.object_key
)
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', '')
except Exception as e:
logger.error(f"从RustFS获取数据失败: {e}")
# 3. 从PostgreSQL获取特征和建议
features = await session.execute(
select(FeatureDetection).where(FeatureDetection.stp_file_id == stp_file_id)
)
result['features'] = [
{
'feature_type': f.feature_type,
'confidence': f.confidence,
'location': f.location,
'dimensions': f.dimensions,
'parameters': f.parameters
}
for f in features.scalars().all()
]
recommendations = await session.execute(
select(DesignRecommendation).where(DesignRecommendation.stp_file_id == stp_file_id)
)
result['recommendations'] = [
{
'type': r.rec_type, # 改为 type 以匹配前端期望的字段名
'priority': r.priority,
'description': r.description,
'reason': r.reason,
'parameters': r.parameters
}
for r in recommendations.scalars().all()
]
# 4. 获取分析指标
if stp_file.analysis_metrics:
result['analysis_metrics'] = {
'volume_utilization': stp_file.analysis_metrics.volume_utilization,
'topology_complexity': stp_file.analysis_metrics.topology_complexity,
'wall_uniformity': stp_file.analysis_metrics.wall_uniformity,
'analysis_summary': stp_file.analysis_metrics.analysis_summary,
'verification_status': stp_file.analysis_metrics.verification_status,
'verification_volume_diff': stp_file.analysis_metrics.verification_volume_diff,
'verification_area_diff': stp_file.analysis_metrics.verification_area_diff,
'verification_details': stp_file.analysis_metrics.verification_details,
}
return result
async def get_file_history_by_filename(
self,
session: AsyncSession,
filename: str,
user_id: Optional[int] = None,
limit: int = 50
) -> list:
"""获取同一文件名的所有上传历史记录"""
from shared.models.database import ProcessingTask
from sqlalchemy.orm import joinedload
query = select(STPFile).options(
joinedload(STPFile.processing_tasks)
).where(
STPFile.original_filename == filename
).order_by(STPFile.upload_time.desc())
if user_id:
query = query.where(STPFile.user_id == user_id)
query = query.limit(limit)
result = await session.execute(query)
files = result.unique().scalars().all()
return [
{
'id': f.id,
'task_id': f.processing_tasks[0].task_id if f.processing_tasks else None,
'upload_batch': f.upload_batch,
'upload_time': f.upload_time.strftime('%Y-%m-%d %H:%M:%S') if f.upload_time else None,
'file_size': f.file_size,
'status': f.status,
'volume': f.volume,
'surface_area': f.surface_area,
'product_weight': f.product_weight,
'has_analysis': f.status == 'completed'
}
for f in files
]
async def get_all_file_groups(
self,
session: AsyncSession,
user_id: Optional[int] = None,
limit: int = 100
) -> list:
"""获取所有文件分组(按文件名分组),包含每个文件的最新分析结果"""
from sqlalchemy import func, desc
from sqlalchemy.orm import joinedload
from shared.models.database import ProcessingTask
# 子查询:获取每个文件名的最新上传
subquery = (
select(
STPFile.original_filename,
func.max(STPFile.upload_time).label('latest_upload')
)
.group_by(STPFile.original_filename)
.order_by(desc('latest_upload'))
.limit(limit)
)
if user_id:
subquery = subquery.where(STPFile.user_id == user_id)
subquery = subquery.subquery()
# 主查询:获取最新记录和统计信息
query = (
select(STPFile).options(
joinedload(STPFile.processing_tasks)
)
.join(
subquery,
(STPFile.original_filename == subquery.c.original_filename) &
(STPFile.upload_time == subquery.c.latest_upload)
)
.order_by(STPFile.upload_time.desc())
)
result = await session.execute(query)
latest_files = result.unique().scalars().all()
# 获取每个文件名的上传次数
file_groups = []
for f in latest_files:
count_query = select(func.count()).where(
STPFile.original_filename == f.original_filename
)
if user_id:
count_query = count_query.where(STPFile.user_id == user_id)
count_result = await session.execute(count_query)
upload_count = count_result.scalar()
task_id = f.processing_tasks[0].task_id if f.processing_tasks else None
file_groups.append({
'filename': f.original_filename,
'latest_id': f.id,
'latest_task_id': task_id,
'latest_upload_time': f.upload_time.strftime('%Y-%m-%d %H:%M:%S') if f.upload_time else None,
'latest_status': f.status,
'upload_count': upload_count,
'file_size': f.file_size,
'volume': f.volume,
'surface_area': f.surface_area,
'product_weight': f.product_weight
})
return file_groups
async def update_stp_file_analysis_summary(
self,
session: AsyncSession,
stp_file_id: int,
volume: Optional[float] = None,
surface_area: Optional[float] = None,
product_weight: Optional[float] = None
):
"""更新STP文件的分析摘要字段(用于快速查询)"""
try:
update_data = {}
if volume is not None:
update_data['volume'] = volume
if surface_area is not None:
update_data['surface_area'] = surface_area
if product_weight is not None:
update_data['product_weight'] = product_weight
if update_data:
await session.execute(
update(STPFile)
.where(STPFile.id == stp_file_id)
.values(**update_data)
)
await session.commit()
logger.info(f"STP文件分析摘要更新: ID {stp_file_id}")
except Exception as e:
await session.rollback()
logger.error(f"更新STP文件分析摘要失败: {e}")
raise
async def delete_stp_file_cascade(self, session: AsyncSession,
stp_file_id: int):
"""级联删除STP文件及其所有关联数据"""
stp_file = await session.get(STPFile, stp_file_id)
if not stp_file:
raise ValueError(f"STP文件不存在: {stp_file_id}")
# 1. 删除RustFS中的文件
try:
if stp_file.object_key:
await rustfs_manager.delete_file('stp_files', stp_file.object_key)
except Exception as e:
logger.error(f"删除RustFS文件失败: {e}")
try:
if stp_file.geometry_data:
await rustfs_manager.delete_file('geometry_data', stp_file.geometry_data.object_key)
except Exception as e:
logger.error(f"删除几何数据失败: {e}")
try:
if stp_file.mold_cavity_data:
await rustfs_manager.delete_file('mold_cavities', stp_file.mold_cavity_data.detailed_object_key)
except Exception as e:
logger.error(f"删除型腔数据失败: {e}")
try:
if stp_file.mesh_data:
await rustfs_manager.delete_file('mesh_data', stp_file.mesh_data.object_key)
except Exception as e:
logger.error(f"删除网格数据失败: {e}")
try:
if stp_file.html_file:
await rustfs_manager.delete_file('html_files', stp_file.html_file.object_key)
except Exception as e:
logger.error(f"删除HTML文件失败: {e}")
# 2. 级联删除PostgreSQL记录(通过外键自动处理)
await session.delete(stp_file)
await session.commit()
logger.info(f"STP文件及其关联数据已删除: {stp_file_id}")
# 全局存储集成服务实例
storage_integration = StorageIntegrationService()
+296
View File
@@ -0,0 +1,296 @@
# services/storage_service.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from datetime import datetime
import hashlib
import json
from pathlib import Path
from typing import Optional, Dict, Any
from shared.models.database import STPFile, GeometryData, HTMLFile, ProcessingTask
from shared.utils.logger import get_logger
from shared.models.database import MoldCavityData
logger = get_logger(__name__)
class StorageService:
"""数据存储服务"""
def __init__(self, db_session: AsyncSession):
self.db_session = db_session
async def save_stp_file(
self,
filename: str,
file_path: str,
file_size: int,
file_content: Optional[bytes] = None
) -> STPFile:
"""保存STP文件信息到数据库"""
try:
# 计算文件哈希
file_hash = self._calculate_file_hash(file_path, file_content)
# 检查是否已存在相同文件
existing_file = await self.db_session.execute(
select(STPFile).where(STPFile.file_hash == file_hash)
)
existing_file = existing_file.scalar_one_or_none()
if existing_file:
logger.info(f"文件已存在,跳过保存: {filename}")
return existing_file
# 创建新的STP文件记录
stp_file = STPFile(
filename=filename,
original_filename=filename,
file_path=file_path,
file_size=file_size,
file_hash=file_hash,
file_content=file_content,
upload_time=datetime.now(),
status="pending",
# 必填字段提供默认值
object_key=f"stp_files/{file_hash}",
storage_bucket="default",
object_url=None
)
self.db_session.add(stp_file)
await self.db_session.commit()
await self.db_session.refresh(stp_file)
logger.info(f"STP文件保存成功: {filename} (ID: {stp_file.id})")
return stp_file
except Exception as e:
await self.db_session.rollback()
logger.error(f"保存STP文件失败: {e}")
raise
async def save_geometry_data(
self,
stp_file_id: int,
geometry_json: Dict[str, Any],
analysis_method: str
) -> GeometryData:
"""保存几何数据JSON到数据库"""
try:
# 提取关键几何属性用于快速查询
volume = geometry_json.get("volume")
surface_area = geometry_json.get("surface_area")
bounding_box = geometry_json.get("bounding_box", {})
geometry_data = GeometryData(
stp_file_id=stp_file_id,
analysis_method=analysis_method,
volume=volume,
surface_area=surface_area,
bounding_box_min=bounding_box.get("min"),
bounding_box_max=bounding_box.get("max"),
created_time=datetime.now(),
# 必填字段提供默认值
object_key=f"geometry_data/{stp_file_id}",
storage_bucket="default",
object_url=None
)
self.db_session.add(geometry_data)
await self.db_session.commit()
await self.db_session.refresh(geometry_data)
logger.info(f"几何数据保存成功: STP文件ID {stp_file_id}")
return geometry_data
except Exception as e:
await self.db_session.rollback()
logger.error(f"保存几何数据失败: {e}")
raise
async def save_html_file(
self,
stp_file_id: int,
filename: str,
file_path: str,
html_content: Optional[str] = None,
visualization_type: str = "3d_viewer"
) -> HTMLFile:
"""保存HTML文件信息到数据库"""
try:
html_file = HTMLFile(
stp_file_id=stp_file_id,
filename=filename,
file_path=file_path,
html_content=html_content,
visualization_type=visualization_type,
has_interactive_elements=True,
generated_time=datetime.now(),
# 必填字段提供默认值
object_key=f"html_files/{stp_file_id}",
storage_bucket="default",
object_url=None
)
self.db_session.add(html_file)
await self.db_session.commit()
await self.db_session.refresh(html_file)
logger.info(f"HTML文件保存成功: {filename} (STP文件ID: {stp_file_id})")
return html_file
except Exception as e:
await self.db_session.rollback()
logger.error(f"保存HTML文件失败: {e}")
raise
async def create_processing_task(
self,
task_id: str,
stp_file_id: int,
task_type: str = "stp_parsing"
) -> ProcessingTask:
"""创建处理任务记录"""
try:
task = ProcessingTask(
task_id=task_id,
stp_file_id=stp_file_id,
task_type=task_type,
status="pending",
started_time=datetime.now()
)
self.db_session.add(task)
await self.db_session.commit()
await self.db_session.refresh(task)
logger.info(f"处理任务创建成功: {task_id}")
return task
except Exception as e:
await self.db_session.rollback()
logger.error(f"创建处理任务失败: {e}")
raise
async def update_task_status(
self,
task_id: str,
status: str,
progress: Optional[int] = None,
current_step: Optional[str] = None,
error_message: Optional[str] = None
):
"""更新任务状态"""
try:
update_data = {
"status": status,
"completed_time": datetime.now() if status in ["completed", "failed"] else None,
"error_message": error_message
}
if progress is not None:
update_data["progress"] = progress
if current_step is not None:
update_data["current_step"] = current_step
await self.db_session.execute(
update(ProcessingTask)
.where(ProcessingTask.task_id == task_id)
.values(**update_data)
)
await self.db_session.commit()
logger.info(f"任务状态更新: {task_id} -> {status}")
except Exception as e:
await self.db_session.rollback()
logger.error(f"更新任务状态失败: {e}")
raise
async def update_stp_file_status(self, stp_file_id: int, status: str):
"""更新STP文件状态"""
try:
await self.db_session.execute(
update(STPFile)
.where(STPFile.id == stp_file_id)
.values(
status=status,
processed_time=datetime.now() if status in ["completed", "failed"] else None
)
)
await self.db_session.commit()
logger.info(f"STP文件状态更新: ID {stp_file_id} -> {status}")
except Exception as e:
await self.db_session.rollback()
logger.error(f"更新STP文件状态失败: {e}")
raise
async def get_stp_file_by_id(self, stp_file_id: int) -> Optional[STPFile]:
"""根据ID获取STP文件"""
try:
result = await self.db_session.execute(
select(STPFile).where(STPFile.id == stp_file_id)
)
return result.scalar_one_or_none()
except Exception as e:
logger.error(f"获取STP文件失败: {e}")
return None
async def get_geometry_data_by_stp_file_id(self, stp_file_id: int) -> Optional[GeometryData]:
"""根据STP文件ID获取几何数据"""
try:
result = await self.db_session.execute(
select(GeometryData).where(GeometryData.stp_file_id == stp_file_id)
)
return result.scalar_one_or_none()
except Exception as e:
logger.error(f"获取几何数据失败: {e}")
return None
def _calculate_file_hash(self, file_path: str, file_content: Optional[bytes] = None) -> str:
"""计算文件哈希值"""
sha256_hash = hashlib.sha256()
if file_content:
sha256_hash.update(file_content)
else:
# 从文件路径读取内容计算哈希
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
sha256_hash.update(chunk)
return sha256_hash.hexdigest()
async def save_mold_cavity_data(
self,
stp_file_id: int,
cavity_json: Dict[str, Any],
key_info: Dict[str, Any]
) -> MoldCavityData:
"""保存模具型腔数据"""
try:
mold_data = MoldCavityData(
stp_file_id=stp_file_id,
cavity_key_info=key_info,
shrinkage_rate=cavity_json["metadata"]["shrinkage_rate"],
draft_angle=cavity_json["metadata"]["draft_angle"],
generated_time=datetime.now(),
# 必填字段提供默认值
detailed_object_key=f"mold_cavity/{stp_file_id}",
storage_bucket="default"
)
self.db_session.add(mold_data)
await self.db_session.commit()
await self.db_session.refresh(mold_data)
logger.info(f"模具型腔数据保存成功: STP文件ID {stp_file_id}")
return mold_data
except Exception as e:
await self.db_session.rollback()
logger.error(f"保存模具型腔数据失败: {e}")
raise
@@ -0,0 +1,201 @@
# services/task_query_service.py
"""任务状态查询服务 — 从 task_router.py 中的持久化任务组装逻辑抽取"""
from typing import Optional, Dict, Any, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
from shared.services.redis_task_manager import redis_task_manager
from shared.models.database import ProcessingTask, STPFile, MeshData, HTMLFile
from shared.utils.logger import get_logger
logger = get_logger(__name__)
class TaskQueryService:
"""任务状态查询与视图组装"""
@staticmethod
async def get_task_view(db_session: AsyncSession, task_id: str) -> Optional[Dict[str, Any]]:
"""
获取任务视图 — 优先返回 Redis 缓存,否则从 PostgreSQL + RustFS 组装
Returns:
任务视图字典,如果任务不存在返回 None
"""
# 1. Redis/内存任务(进行中的任务直接返回,已完成/失败的走DB路径获取完整数据)
task = await redis_task_manager.get_task(task_id)
if task:
status = task.get("status")
if status and status not in ("completed", "failed"):
logger.info(f"返回缓存任务状态:{task_id} - {status}")
return task
# 2. 持久化任务(已完成/失败,或服务重启后的任务)
storage_service = StorageIntegrationService()
# 查询任务和文件元数据(预加载 html_file 关联)
result = await db_session.execute(
select(ProcessingTask, STPFile)
.join(STPFile, ProcessingTask.stp_file_id == STPFile.id)
.where(ProcessingTask.task_id == task_id)
.options(joinedload(STPFile.html_file))
)
row = result.unique().first()
if not row:
return None
processing_task, stp_file = row
# 从 RustFS 取几何 / 型腔 / 网格详细 JSON
try:
file_with_data = await storage_service.get_stp_file_with_data(
db_session, stp_file_id=stp_file.id
)
except Exception as e:
logger.error(f"获取文件数据失败: {e}")
file_with_data = {}
# 解析 geometry_json
geometry_json = TaskQueryService._extract_geometry_json(file_with_data)
cavity_json: Optional[Dict[str, Any]] = file_with_data.get("mold_cavity_data")
features_json: List[Dict[str, Any]] = file_with_data.get("features", [])
recommendations_json: List[Dict[str, Any]] = file_with_data.get("recommendations", [])
cavity_view = TaskQueryService._extract_cavity_view(cavity_json)
# 组装网格摘要
mesh_summary = await TaskQueryService._get_mesh_summary(db_session, stp_file.id)
# 构造 html_file 路径(与即时分析的 /html/xxx.html 格式保持一致)
html_file_url = None
html_file_record = None
try:
html_file_record = await db_session.execute(
select(HTMLFile).where(HTMLFile.stp_file_id == stp_file.id)
)
html_file_record = html_file_record.scalar_one_or_none()
except Exception:
pass
if html_file_record and html_file_record.filename:
html_file_url = f"/html/{html_file_record.filename}"
if cavity_view.get("html_file"):
html_file_url = cavity_view.get("html_file")
# 构造与内存任务兼容的任务视图
cam_preferences = {}
task_parameters = {}
if isinstance(processing_task.parameters, dict):
cam_preferences = processing_task.parameters.get("cam_preferences", {}) or {}
task_parameters = dict(processing_task.parameters)
task_view = {
"task_id": processing_task.task_id,
"status": processing_task.status,
"filename": stp_file.original_filename if stp_file else "",
"file_path": stp_file.file_path or "",
"file_size": stp_file.file_size if stp_file else 0,
"upload_time": processing_task.created_time.isoformat()
if processing_task.created_time
else "",
"completed_at": processing_task.completed_time.isoformat()
if processing_task.completed_time
else "",
"geometry_data": geometry_json,
"key_info": cavity_view.get("key_info"),
"cavity_data": cavity_view.get("cavity_data"),
"candidate_schemes": cavity_view.get("candidate_schemes", []),
"best_scheme_id": cavity_view.get("best_scheme_id"),
"cam_preferences": cam_preferences,
"plan_result": cavity_json,
"mesh_summary": mesh_summary,
"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"),
"llm_report": task_parameters.get("llm_report"),
"analysis_result": {
"geometry_data": geometry_json,
"detected_features": features_json,
"design_recommendations": recommendations_json,
"quality_metrics": {
"volume_utilization": file_with_data.get("analysis_metrics", {}).get("volume_utilization", 0),
"topology_complexity": file_with_data.get("analysis_metrics", {}).get("topology_complexity", 0),
"wall_uniformity": file_with_data.get("analysis_metrics", {}).get("wall_uniformity", 0)
},
"analysis_summary": file_with_data.get("analysis_metrics", {}).get("analysis_summary", "分析完成")
} if geometry_json or features_json or recommendations_json else None,
"error": processing_task.error_message or stp_file.error_message or None,
}
logger.info(f"返回持久化任务状态: {task_id} - {processing_task.status}")
return task_view
@staticmethod
def _extract_geometry_json(file_with_data: dict) -> Optional[Dict[str, Any]]:
"""从 file_with_data 中提取 geometry_json"""
if not file_with_data.get("geometry_data"):
return None
geo_raw = file_with_data["geometry_data"]
if isinstance(geo_raw, dict):
if "geometry_data" in geo_raw:
return geo_raw["geometry_data"]
return geo_raw
return None
@staticmethod
async def _get_mesh_summary(db_session: AsyncSession, stp_file_id: int) -> Optional[Dict[str, Any]]:
"""从数据库查询网格摘要"""
mesh_record = await db_session.execute(
select(MeshData).where(MeshData.stp_file_id == stp_file_id)
)
mesh_record = mesh_record.scalar_one_or_none()
if mesh_record:
return {
"vertex_count": mesh_record.vertex_count,
"face_count": mesh_record.face_count,
"point_count": mesh_record.point_count,
"quality": mesh_record.quality,
}
return None
@staticmethod
def _extract_cavity_view(cavity_json: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""兼容旧单方案与新多方案结果视图。"""
if not cavity_json:
return {
"cavity_data": None,
"key_info": None,
"candidate_schemes": [],
"best_scheme_id": None,
}
candidate_schemes = cavity_json.get("candidate_schemes")
if candidate_schemes:
best_scheme_id = cavity_json.get("best_scheme_id")
best_scheme = candidate_schemes[0]
if best_scheme_id:
for scheme in candidate_schemes:
if scheme.get("scheme_id") == best_scheme_id:
best_scheme = scheme
break
return {
"cavity_data": best_scheme.get("cavity_data"),
"key_info": best_scheme.get("key_info"),
"candidate_schemes": candidate_schemes,
"best_scheme_id": best_scheme_id or best_scheme.get("scheme_id"),
"html_file": best_scheme.get("html_file"),
}
return {
"cavity_data": cavity_json,
"key_info": cavity_json,
"candidate_schemes": [],
"best_scheme_id": None,
"html_file": cavity_json.get("html_file"),
}
@@ -0,0 +1,457 @@
"""
几何验证服务
使用 FreeCAD 和 PythonOCC 交叉验证几何数据准确性
"""
import asyncio
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Dict, Any, Optional
from datetime import datetime
from shared.utils.logger import get_logger
logger = get_logger(__name__)
class GeometryVerificationService:
"""几何验证服务"""
def __init__(self, timeout: int = 60):
# 验证脚本在项目根目录的 scripts 文件夹下
# __file__ = /path/to/project/src/services/verification_service.py
# parent = /path/to/project/src/services
# parent.parent = /path/to/project/src
# parent.parent.parent = /path/to/project (正确)
self.verification_script = Path(__file__).parent.parent.parent / "scripts" / "verify_stp.py"
self.timeout = timeout # 超时时间(秒),默认60秒
async def verify_stp_file(self, stp_path: str) -> Dict[str, Any]:
"""
验证 STP 文件几何数据
Args:
stp_path: STP 文件路径
Returns:
验证结果字典
"""
try:
logger.info(f"开始验证 STP 文件: {stp_path}")
# 运行验证脚本
result = await self._run_verification_script(stp_path)
if result:
logger.info(f"验证完成: {result.get('status', 'unknown')}")
else:
logger.warning("验证脚本未返回结果")
return result
except Exception as e:
logger.error(f"验证失败: {e}")
return {
"status": "error",
"error": str(e),
"timestamp": datetime.now().isoformat()
}
async def _run_verification_script(self, stp_path: str) -> Optional[Dict[str, Any]]:
"""运行验证脚本"""
import tempfile
import os
import shutil
# 确保 stp_path 是字符串
stp_path_str = str(stp_path)
# 创建临时输出文件
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
output_path = f.name
try:
# 构建命令 - 使用 FreeCAD 命令行模式
cmd = None
# 使用 shutil.which 查找 FreeCAD 命令(更快)
for cmd_name in ['freecad', 'freecadcmd', 'freecad-daily']:
cmd_path = shutil.which(cmd_name)
if cmd_path:
cmd = [cmd_path, str(self.verification_script), str(Path(stp_path_str).absolute())]
logger.info(f"找到 FreeCAD 命令: {cmd_path}")
break
# 检查 Flatpak 版本
if not cmd and shutil.which('flatpak'):
try:
result = subprocess.run(['flatpak', 'list', '--app'], capture_output=True, text=True)
if 'org.freecad.FreeCAD' in result.stdout:
cmd = ['flatpak', 'run', 'org.freecad.FreeCAD', str(self.verification_script), str(Path(stp_path_str).absolute())]
logger.info("找到 FreeCAD Flatpak 版本")
except Exception:
pass
# 如果 which 找不到,尝试直接检查常见路径
if not cmd:
for cmd_path in ['/usr/local/bin/freecad', '/usr/bin/freecad', '/usr/bin/freecad-daily', '/usr/local/bin/freecadcmd', '/usr/bin/freecadcmd', '/snap/bin/freecad.cmd']:
if Path(cmd_path).exists():
cmd = [cmd_path, str(self.verification_script), str(Path(stp_path_str).absolute())]
logger.info(f"找到 FreeCAD 命令路径: {cmd_path}")
break
# 尝试使用 xvfb-run 运行 AppImage
if not cmd and shutil.which('xvfb-run'):
for appimage_path in ['/usr/local/bin/freecad', '/opt/freecad.AppImage']:
if Path(appimage_path).exists():
cmd = ['xvfb-run', appimage_path, str(self.verification_script), str(Path(stp_path_str).absolute())]
logger.info(f"使用 xvfb-run 运行: {appimage_path}")
break
if not cmd:
logger.warning("FreeCAD 命令行工具不可用,跳过验证")
return {
"status": "skipped",
"reason": "FreeCAD not available",
"timestamp": datetime.now().isoformat()
}
logger.info(f"执行验证命令: {' '.join(cmd)}")
# 获取 STP 文件的绝对路径和目录
stp_abs_path = Path(stp_path_str).absolute()
stp_dir = stp_abs_path.parent
# 设置环境变量禁用图形界面
env = os.environ.copy()
env['QT_QPA_PLATFORM'] = 'offscreen'
env['DISPLAY'] = ''
env['FREECAD_USER_HOME'] = '/tmp/freecad_home'
logger.info(f"工作目录: {stp_dir}")
logger.info(f"STP 文件: {stp_abs_path}")
logger.info(f"验证脚本: {self.verification_script}")
# 异步运行子进程
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(stp_dir),
env=env
)
# 使用配置的超时时间
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=self.timeout
)
except asyncio.TimeoutError:
logger.error(f"验证脚本执行超时({self.timeout}秒)")
process.kill()
await process.wait()
return {
"status": "error",
"error": f"验证脚本执行超时({self.timeout}秒)",
"timestamp": datetime.now().isoformat()
}
stdout_text = stdout.decode('utf-8') if stdout else ''
stderr_text = stderr.decode('utf-8') if stderr else ''
logger.info(f"验证脚本 stdout (前1000字符): {stdout_text[:1000]}")
if stderr_text:
logger.warning(f"验证脚本 stderr: {stderr_text[:500]}")
# 检查是否有 FreeCAD 错误
has_error = (
'Cannot read STEP file' in stderr_text or
'Cannot read STEP file' in stdout_text or
'Exception while processing file' in stderr_text or
'Exception while processing file' in stdout_text or
'所有导入方法都失败' in stdout_text or
process.returncode != 0
)
if has_error:
logger.error("FreeCAD 验证失败,无法进行交叉验证")
return {
"status": "error",
"reason": "FreeCAD 无法读取 STP 文件,无法进行交叉验证",
"timestamp": datetime.now().isoformat()
}
if process.returncode == 0:
# 尝试读取生成的报告文件(在 STP 文件目录下)
report_path = stp_dir / (stp_abs_path.stem + "_verification_report.json")
if report_path.exists():
with open(report_path, 'r', encoding='utf-8') as f:
result = json.load(f)
# 删除临时报告文件
report_path.unlink()
return result
else:
# 解析 stdout 获取结果
logger.warning(f"验证报告文件不存在: {report_path}")
return self._parse_verification_output(stdout_text)
else:
logger.error(f"验证脚本执行失败 (returncode={process.returncode}): {stderr_text}")
return {
"status": "error",
"error": stderr_text,
"timestamp": datetime.now().isoformat()
}
except FileNotFoundError:
logger.warning("FreeCAD 命令行工具不可用,跳过验证")
return {
"status": "skipped",
"reason": "FreeCAD not available",
"timestamp": datetime.now().isoformat()
}
except Exception as e:
logger.error(f"运行验证脚本失败: {e}")
return {
"status": "error",
"error": str(e),
"timestamp": datetime.now().isoformat()
}
finally:
# 清理临时文件
if os.path.exists(output_path):
os.unlink(output_path)
def _parse_verification_output(self, output: str) -> Dict[str, Any]:
"""解析验证脚本输出"""
result = {
"status": "unknown",
"timestamp": datetime.now().isoformat(),
"comparison": {}
}
lines = output.split('\n')
current_section = None
for line in lines:
line = line.strip()
# 检测验证结果
if '验证结果:' in line:
if '✅ 通过' in line or '通过' in line:
result['status'] = 'passed'
elif '❌ 失败' in line or '失败' in line:
result['status'] = 'failed'
# 检测当前段落
if '体积对比:' in line:
current_section = 'volume'
elif '表面积对比:' in line:
current_section = 'surface_area'
# 解析差异百分比
if '差异:' in line and '%' in line:
try:
# 格式: "差异: 123.4567 mm³ (0.1234%)"
parts = line.split('(')
if len(parts) >= 2:
percent_str = parts[-1].split('%')[0].strip()
percent = float(percent_str)
if current_section == 'volume':
result['comparison']['volume'] = {'difference_percent': percent}
elif current_section == 'surface_area':
result['comparison']['surface_area'] = {'difference_percent': percent}
except Exception as e:
logger.debug(f"解析差异百分比失败: {e}")
# 如果没有找到验证结果,但有 comparison 数据,则根据差异判断
if result['status'] == 'unknown' and result['comparison']:
vol_diff = result['comparison'].get('volume', {}).get('difference_percent', 100)
area_diff = result['comparison'].get('surface_area', {}).get('difference_percent', 100)
if vol_diff < 1 and area_diff < 2:
result['status'] = 'passed'
else:
result['status'] = 'failed'
return result
def _verify_with_pythonocc_only(self, stp_path: str) -> Dict[str, Any]:
"""
仅使用 PythonOCC 验证(当 FreeCAD 不可用时)
Args:
stp_path: STP 文件路径
Returns:
验证结果
"""
try:
from OCC.Core.STEPControl import STEPControl_Reader
from OCC.Core.IFSelect import IFSelect_RetDone
from OCC.Core.GProp import GProp_GProps
from OCC.Core.BRepGProp import brepgprop_VolumeProperties, brepgprop_SurfaceProperties
from OCC.Core.Bnd import Bnd_Box
from OCC.Core.BRepBndLib import brepbndlib
from OCC.Core.TopExp import TopExp_Explorer
from OCC.Core.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX, TopAbs_SOLID
logger.info(f"使用 PythonOCC 进行验证: {stp_path}")
# 读取 STP 文件
reader = STEPControl_Reader()
status = reader.ReadFile(stp_path)
if status != IFSelect_RetDone:
return {
"status": "error",
"error": "无法读取 STP 文件",
"timestamp": datetime.now().isoformat()
}
reader.TransferRoots()
shape = reader.OneShape()
# 计算体积
vol_props = GProp_GProps()
brepgprop_VolumeProperties(shape, vol_props)
volume_mm3 = vol_props.Mass()
com = vol_props.CentreOfMass()
# 计算表面积
surf_props = GProp_GProps()
brepgprop_SurfaceProperties(shape, surf_props)
surface_area_mm2 = surf_props.Mass()
# 计算边界框
bbox = Bnd_Box()
brepbndlib.Add(shape, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
# 拓扑统计
def count_topology(shape, top_type):
explorer = TopExp_Explorer(shape, top_type)
count = 0
while explorer.More():
count += 1
explorer.Next()
return count
return {
"status": "passed",
"method": "pythonocc_only",
"reason": "FreeCAD 验证失败,仅使用 PythonOCC 验证",
"timestamp": datetime.now().isoformat(),
"pythonocc": {
"volume_mm3": float(volume_mm3),
"volume_cm3": float(volume_mm3 / 1000),
"surface_area_mm2": float(surface_area_mm2),
"surface_area_cm2": float(surface_area_mm2 / 100),
"bounding_box": {
"x_min": float(xmin),
"x_max": float(xmax),
"y_min": float(ymin),
"y_max": float(ymax),
"z_min": float(zmin),
"z_max": float(zmax),
"x_length": float(xmax - xmin),
"y_length": float(ymax - ymin),
"z_length": float(zmax - zmin),
"center": [float((xmin + xmax) / 2), float((ymin + ymax) / 2), float((zmin + zmax) / 2)]
},
"center_of_mass": [float(com.X()), float(com.Y()), float(com.Z())],
"topology": {
"faces": count_topology(shape, TopAbs_FACE),
"edges": count_topology(shape, TopAbs_EDGE),
"vertices": count_topology(shape, TopAbs_VERTEX),
"solids": count_topology(shape, TopAbs_SOLID)
}
}
}
except Exception as e:
logger.error(f"PythonOCC 验证失败: {e}")
return {
"status": "error",
"error": str(e),
"timestamp": datetime.now().isoformat()
}
def verify_with_pythonocc(self, shape) -> Dict[str, Any]:
"""
使用 PythonOCC 验证几何数据(同步方法,用于内部验证)
Args:
shape: OCC 形状对象
Returns:
验证结果
"""
try:
from OCC.Core.GProp import GProp_GProps
from OCC.Core.BRepGProp import brepgprop_VolumeProperties, brepgprop_SurfaceProperties
from OCC.Core.Bnd import Bnd_Box
from OCC.Core.BRepBndLib import brepbndlib
from OCC.Core.TopExp import TopExp_Explorer
from OCC.Core.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX, TopAbs_SOLID
# 计算体积
vol_props = GProp_GProps()
brepgprop_VolumeProperties(shape, vol_props)
volume_mm3 = vol_props.Mass()
com = vol_props.CentreOfMass()
# 计算表面积
surf_props = GProp_GProps()
brepgprop_SurfaceProperties(shape, surf_props)
surface_area_mm2 = surf_props.Mass()
# 计算边界框
bbox = Bnd_Box()
brepbndlib.Add(shape, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
# 拓扑统计
def count_topology(shape, top_type):
explorer = TopExp_Explorer(shape, top_type)
count = 0
while explorer.More():
count += 1
explorer.Next()
return count
return {
"volume_mm3": float(volume_mm3),
"volume_cm3": float(volume_mm3 / 1000),
"surface_area_mm2": float(surface_area_mm2),
"surface_area_cm2": float(surface_area_mm2 / 100),
"bounding_box": {
"x_min": float(xmin),
"x_max": float(xmax),
"y_min": float(ymin),
"y_max": float(ymax),
"z_min": float(zmin),
"z_max": float(zmax),
"x_length": float(xmax - xmin),
"y_length": float(ymax - ymin),
"z_length": float(zmax - zmin),
"center": [float((xmin + xmax) / 2), float((ymin + ymax) / 2), float((zmin + zmax) / 2)]
},
"center_of_mass": [float(com.X()), float(com.Y()), float(com.Z())],
"topology": {
"faces": count_topology(shape, TopAbs_FACE),
"edges": count_topology(shape, TopAbs_EDGE),
"vertices": count_topology(shape, TopAbs_VERTEX),
"solids": count_topology(shape, TopAbs_SOLID)
}
}
except Exception as e:
logger.error(f"PythonOCC 验证失败: {e}")
return {"error": str(e)}
# 单例实例
verification_service = GeometryVerificationService()