后端模块拆分
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user