后端设计治理:批次 0-4 全部完成(安全/部署/一致性/结构/架构)

按 ROADMAP §3.1 治理批次推进的后端设计审查整改:

- 批次 0(安全):/api/status/{task_id} 补 JWT 鉴权与任务归属校验;
  pythonocc_available 真实探测;bcrypt 超 72 字节显式拒绝;
  SECRET_KEY/RUSTFS_* 惰性校验,代码侧弱默认移除
- 批次 1(部署正确性):主处理链路改走 RustFS(分派入参 stp_file_id 化,
  worker 按 object_key 下载);AUTO_MIGRATE 开关 + 迁移目录 alembic/→migrations/
  修复包遮蔽(自动迁移此前从未真正生效);OCC 镜像改 conda 原生执行 +
  基础镜像 tag 锁定;compose 关键项改 ${VAR:?} 强制显式配置
- 批次 2(任务一致性):删除 Redis 进程内存回退,PG 为任务状态单一事实源;
  批量元数据入库(processing_tasks.batch_id,迁移 a3f8c2d91e47);
  型腔失败任务标 failed 不再静默 completed;事务边界收口
  (数据本体写 flush-only、失败先回滚再置 failed、进度更新保留即时 commit)
- 批次 3(API 与代码结构):592 行 advanced_router 拆为 design/cost/machining/
  export 四子路由,请求体全量 Pydantic 化;ROUTE_MODULES + route_registry
  (/api/health 呈现 degraded,DEBUG fail fast);纯计算端点统一 to_thread;
  StorageIntegrationService 按职责三拆;MAX_FILE_SIZE 接线生效、
  celery 复用 Settings.redis_url;管理员重置密码改 JSON body(端到端断裂修复);
  openapi.json 重导出(76 paths)+ 前端 gen:api
- 批次 4(架构演进):共享 ORM 按模块拆分(shared/models/base.py + identity.py、
  moldinsight/models/、inventory/models/,删除三条无使用方的跨模块
  relationship,跨模块桥接收敛为裸 FK 硬规则,无兼容 facade);
  OCC executor 重建补 cancel_futures=True(消除旧队列被慢恢复线程
  并行消化的数据竞争);OCC 吞吐方案设计先行
  (docs/topics/performance/OCC_THROUGHPUT.md);顺手清偿 D15
  (vite.config.ts 未用参数致 npm run build 失败)

测试基线:125 passed, 2 skipped(pytest + sqlite+aiosqlite;归属边界、
路由契约、配置治理、鉴权回归等随批新增)
文档同步:STATUS / TECH_DEBT / ROADMAP / ARCHITECTURE / API_CONTRACT /
OPERATIONS / AGENTS

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-17 16:15:49 +08:00
parent 4537faf2c4
commit 0e6b3b1811
82 changed files with 6513 additions and 3709 deletions
+36 -14
View File
@@ -3,32 +3,54 @@ import importlib
from shared.config.settings import settings
from shared.utils.logger import get_logger
from moldinsight.api.route_registry import route_load_status
logger = get_logger(__name__)
router = APIRouter()
def _safe_include(module_path: str, label: str):
# 业务路由装载清单:新增路由必须登记于此。
# 失败语义(原 _safe_include 仅 WARNING 跳过,进程带病启动不可感知):
# - 非 DEBUG:记录进 route_load_status["failed"],/api/health 呈现 degraded
# - DEBUG:直接抛错 fail fast——开发环境路由缺失必须当场暴露
ROUTE_MODULES = [
# (label, module_path, debug_only)
("健康检查", "moldinsight.api.health_router", False),
("上传", "moldinsight.api.upload_router", False),
("批量", "moldinsight.api.batch_router", False),
("任务", "moldinsight.api.task_router", False),
("历史", "moldinsight.api.history_router", False),
("CAM", "moldinsight.api.cam_router", False),
("设计", "moldinsight.api.design_router", False),
("成本", "moldinsight.api.cost_router", False),
("加工", "moldinsight.api.machining_router", False),
("导出", "moldinsight.api.export_router", False),
("铝价", "moldinsight.api.aluminum_price_routes", False),
# 调试端点会 dump 全量任务数据,仅 DEBUG 模式注册(双重防线:还需登录)
("调试", "moldinsight.api.debug_router", True),
]
def _safe_include(label: str, module_path: str, debug_only: bool = False):
if debug_only and not settings.DEBUG:
route_load_status["disabled"].append({"label": label, "module": module_path})
return
try:
module = importlib.import_module(module_path)
router_obj = getattr(module, "router", None)
if router_obj is None:
raise ValueError("未找到 router 对象")
router.include_router(router_obj)
route_load_status["loaded"].append({"label": label, "module": module_path})
logger.info(f"{label} 路由加载成功")
except Exception as exc:
logger.warning(f"{label} 路由加载失败,已跳过: {exc}")
route_load_status["failed"].append(
{"label": label, "module": module_path, "error": str(exc)}
)
logger.error(f"{label} 路由加载失败: {exc}")
if settings.DEBUG:
raise
_safe_include("moldinsight.api.health_router", "健康检查")
_safe_include("moldinsight.api.upload_router", "上传")
_safe_include("moldinsight.api.batch_router", "批量")
_safe_include("moldinsight.api.task_router", "任务")
_safe_include("moldinsight.api.history_router", "历史")
_safe_include("moldinsight.api.cam_router", "CAM")
_safe_include("moldinsight.api.advanced_router", "高级")
_safe_include("moldinsight.api.aluminum_price_routes", "铝价")
# 调试端点会 dump 全量任务数据,仅 DEBUG 模式注册(双重防线:还需登录)
if settings.DEBUG:
_safe_include("moldinsight.api.debug_router", "调试")
for _label, _module_path, _debug_only in ROUTE_MODULES:
_safe_include(_label, _module_path, _debug_only)
-592
View File
@@ -1,592 +0,0 @@
from pathlib import Path
import os
from datetime import datetime
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from shared.services.auth_service import get_current_active_user
from shared.services.redis_task_manager import redis_task_manager
from moldinsight.services.processing_service import processing_service
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
from moldinsight.services.task_query_service import TaskQueryService
from shared.database.database import get_db_session
from shared.models.database import User
from moldinsight.core.cad_exporter import CADExporter
from shared.utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter()
cad_exporter = CADExporter()
storage_service = StorageIntegrationService()
_cached_instances = {}
def _get_cached_import(key: str):
"""惰性导入核心模块,避免路由器模块级加载时的循环依赖。"""
if key in _cached_instances:
return _cached_instances[key]
try:
if key == "side_action_designer":
from moldinsight.core.side_action_designer import SideActionDesigner
instance = SideActionDesigner()
elif key == "cavity_layout_optimizer":
from moldinsight.core.cavity_layout_optimizer import CavityLayoutOptimizer
instance = CavityLayoutOptimizer()
elif key == "mold_system_designer":
from moldinsight.core.mold_system_designer import MoldSystemDesigner
instance = MoldSystemDesigner()
elif key == "mold_cam_designer":
from moldinsight.core.mold_cam import MoldCAMDesigner
instance = MoldCAMDesigner()
elif key == "collision_detector":
from moldinsight.core.mold_machining import CollisionDetector
instance = CollisionDetector()
elif key == "toolpath_optimizer":
from moldinsight.core.mold_machining import ToolpathOptimizer
instance = ToolpathOptimizer()
elif key == "edm_designer":
from moldinsight.core.mold_machining import EDMElectrodeDesigner
instance = EDMElectrodeDesigner()
elif key == "machining_simulator":
from moldinsight.core.mold_machining import MachiningSimulator
instance = MachiningSimulator()
else:
return None
_cached_instances[key] = instance
return instance
except Exception as e:
logger.warning(f"核心模块 {key} 加载失败: {e}")
return None
async def _ensure_task_access(
db_session: AsyncSession,
task_id: str,
user_id: int,
):
# 归属校验统一走 TaskQueryService(与 /api/status 共用,含 404/403 语义)
return await TaskQueryService.ensure_task_access(db_session, task_id, user_id)
def _get_export_artifacts(task_data: dict) -> dict:
if not isinstance(task_data, dict):
return {}
direct = task_data.get("export_artifacts")
if isinstance(direct, dict):
return direct
parameters = task_data.get("parameters")
if isinstance(parameters, dict) and isinstance(parameters.get("export_artifacts"), dict):
return parameters.get("export_artifacts")
return {}
def _expand_components(components):
requested = components or ["cavity", "core"]
if "all" in requested:
return ["cavity", "core", "parting_surface"]
return list(dict.fromkeys(requested))
def _augment_export_files(task_id: str, files):
items = []
for file in files or []:
item = dict(file)
relative_path = item.get("relative_path")
if not relative_path and item.get("filepath"):
relative_path = cad_exporter.get_relative_path(item["filepath"])
if relative_path:
relative_path = str(relative_path).replace("\\", "/").strip("/")
item["relative_path"] = relative_path
item["download_path"] = f"/api/export-download/{quote(relative_path, safe='/')}?task_id={task_id}"
items.append(item)
return items
def _merge_export_artifacts(existing: dict, export_result: dict) -> dict:
merged = dict(existing or {})
schemes = dict(merged.get("schemes") or {})
scheme_id = export_result.get("scheme_id") or "default"
previous = dict(schemes.get(scheme_id) or {})
file_map = {}
for file in previous.get("files", []):
file_map[(file.get("component"), file.get("format"))] = file
for file in export_result.get("files", []):
file_map[(file.get("component"), file.get("format"))] = file
schemes[scheme_id] = {
"base_filename": export_result.get("base_filename") or previous.get("base_filename"),
"generated_at": datetime.now().isoformat(),
"files": sorted(
file_map.values(),
key=lambda item: (item.get("component", ""), item.get("format", "")),
),
"errors": export_result.get("errors", []),
"total_files": len(file_map),
"total_errors": len(export_result.get("errors", [])),
}
merged["version"] = 1
merged["task_id"] = export_result.get("task_id") or merged.get("task_id")
merged["generated_at"] = merged.get("generated_at") or datetime.now().isoformat()
merged["schemes"] = schemes
return merged
def _select_persisted_files(task_id: str, task_data: dict, scheme_id: str, formats, components):
artifacts = _get_export_artifacts(task_data)
scheme_data = (artifacts.get("schemes") or {}).get(scheme_id)
if not scheme_data:
return None
component_list = _expand_components(components)
format_list = list(dict.fromkeys(formats or ["step", "stl"]))
expected = {(component, fmt) for component in component_list for fmt in format_list}
available = []
available_keys = set()
for file in scheme_data.get("files", []):
component = file.get("component")
fmt = file.get("format")
if component not in component_list or fmt not in format_list:
continue
relative_path = str(file.get("relative_path") or "").replace("\\", "/").strip("/")
if not relative_path:
continue
full_path = os.path.join(cad_exporter.output_dir, relative_path.replace("/", os.sep))
if not os.path.exists(full_path):
continue
available.append(file)
available_keys.add((component, fmt))
if expected and not expected.issubset(available_keys):
return None
return _augment_export_files(task_id, available)
@router.post("/optimize-layout")
async def optimize_cavity_layout(
request: Request,
current_user: User = Depends(get_current_active_user),
):
body = await request.json()
product_bbox = body.get("product_bbox", {"dimensions": [100, 100, 50]})
cavity_count = body.get("cavity_count", 1)
mold_base_size = body.get("mold_base_size")
layout_type = body.get("layout_type", "auto")
if cavity_count < 1 or cavity_count > 64:
raise HTTPException(400, "型腔数量必须在 1-64 之间")
optimizer = _get_cached_import("cavity_layout_optimizer")
if not optimizer:
raise HTTPException(503, "服务不可用:核心模块未加载")
result = optimizer.optimize_layout(
product_bbox=product_bbox,
cavity_count=cavity_count,
mold_base_size=mold_base_size,
layout_type=layout_type,
)
return {"status": "success", "data": result}
@router.post("/design-cooling")
async def design_cooling_system(
request: Request,
current_user: User = Depends(get_current_active_user),
):
body = await request.json()
mold_size = body.get("mold_size", {"length": 300, "width": 300, "height": 200})
product_bbox = body.get("product_bbox", {"dimensions": [100, 100, 50]})
material = body.get("material", "ABS")
cavity_count = body.get("cavity_count", 1)
cycle_time_target = body.get("cycle_time_target")
from moldinsight.core.mold_system_designer import CoolingSystemDesigner
designer = CoolingSystemDesigner()
result = designer.design_cooling_system(
mold_size=mold_size, product_bbox=product_bbox,
material=material, cavity_count=cavity_count,
cycle_time_target=cycle_time_target,
)
return {"status": "success", "data": result}
@router.post("/design-gating")
async def design_gating_system(
request: Request,
current_user: User = Depends(get_current_active_user),
):
body = await request.json()
product_bbox = body.get("product_bbox", {"dimensions": [100, 100, 50]})
material = body.get("material", "ABS")
cavity_count = body.get("cavity_count", 1)
gate_type = body.get("gate_type", "auto")
layout_positions = body.get("layout_positions")
from moldinsight.core.mold_system_designer import GatingSystemDesigner
designer = GatingSystemDesigner()
result = designer.design_gating_system(
product_bbox=product_bbox, material=material,
cavity_count=cavity_count, gate_type=gate_type,
layout_positions=layout_positions,
)
return {"status": "success", "data": result}
@router.post("/design-mold-system")
async def design_complete_mold_system(
request: Request,
current_user: User = Depends(get_current_active_user),
):
body = await request.json()
mold_size = body.get("mold_size", {"length": 300, "width": 300, "height": 200})
product_bbox = body.get("product_bbox", {"dimensions": [100, 100, 50]})
material = body.get("material", "ABS")
cavity_count = body.get("cavity_count", 1)
gate_type = body.get("gate_type", "auto")
cycle_time_target = body.get("cycle_time_target")
layout_positions = body.get("layout_positions")
ds = _get_cached_import("mold_system_designer")
if not ds:
raise HTTPException(503, "服务不可用:核心模块未加载")
result = ds.design_complete_system(
mold_size=mold_size, product_bbox=product_bbox,
material=material, cavity_count=cavity_count,
gate_type=gate_type, cycle_time_target=cycle_time_target,
layout_positions=layout_positions,
)
return {"status": "success", "data": result}
@router.post("/detect-undercuts")
async def detect_undercuts(
request: Request,
current_user: User = Depends(get_current_active_user),
db_session: AsyncSession = Depends(get_db_session),
):
body = await request.json()
task_id = body.get("task_id")
parting_direction = body.get("parting_direction", [0, 0, 1])
mold_size = body.get("mold_size", {"length": 300, "width": 300, "height": 200})
if not task_id:
raise HTTPException(400, "缺少 task_id")
await _ensure_task_access(db_session, task_id, current_user.id)
sd = _get_cached_import("side_action_designer")
if not sd:
raise HTTPException(503, "服务不可用:核心模块未加载")
# 从持久化 STP 原件重建几何(此前传 shape=None 会被兜底吞掉,永远返回"无倒扣")
from moldinsight.services.shape_loader import get_shape_loader
shape = await get_shape_loader().load_shape_for_task(db_session, task_id)
if shape is None:
raise HTTPException(410, "任务几何不可用:无法从存储重建 STP 形状,请重新上传分析")
result = await processing_service.run_occ(
sd.analyze_and_design,
shape,
parting_direction,
mold_size,
)
return {"status": "success", "data": result}
@router.post("/cost-estimate")
async def estimate_cost(
request: Request,
current_user: User = Depends(get_current_active_user),
db_session: AsyncSession = Depends(get_db_session),
):
"""模具成本估算:优先使用 LLM,未启用时降级为规则式估算"""
body = await request.json()
task_id = body.get("task_id")
if not task_id:
raise HTTPException(400, "缺少 task_id")
await _ensure_task_access(db_session, task_id, current_user.id)
# 统一走任务视图:进行中读 Redis,完成态由 PG+RustFS 组装(Redis 大对象已瘦身)
task_data = await TaskQueryService.get_task_view(db_session, task_id)
if not task_data:
raise HTTPException(404, "任务不存在")
analysis_result = task_data.get("analysis_result")
if not analysis_result:
raise HTTPException(400, "该任务尚未完成分析")
detailed_context = {
"candidate_schemes": task_data.get("candidate_schemes", []),
"geometry_data": task_data.get("geometry_data", {}),
"metadata": {"selected_material": task_data.get("material")},
}
# 优先使用 LLM
from moldinsight.services.llm_service import llm_service
result = await llm_service.estimate_cost(analysis_result, detailed_context)
if result is not None:
result["source"] = "ai"
return {"status": "success", "data": result}
# LLM 未启用或失败,降级为规则估算
from moldinsight.services.cost_estimate_service import estimate_cost_by_rules
rules_result = estimate_cost_by_rules(analysis_result, detailed_context)
return {"status": "success", "data": rules_result}
@router.post("/design-cam")
async def design_mold_cam(
request: Request,
current_user: User = Depends(get_current_active_user),
):
body = await request.json()
cavity_bbox = body.get("cavity_bbox", {"dimensions": [100, 100, 50], "min": [-50, -50, -25], "max": [50, 50, 25]})
stock_bbox = body.get("stock_bbox", {"dimensions": [150, 150, 100], "min": [-75, -75, -50], "max": [75, 75, 50]})
mold_steel = body.get("mold_steel", "P20")
surface_quality = body.get("surface_quality", "standard")
controller = body.get("controller", "fanuc")
cam = _get_cached_import("mold_cam_designer")
if not cam:
raise HTTPException(503, "服务不可用:核心模块未加载")
result = cam.design_mold_cam(
cavity_bbox=cavity_bbox, stock_bbox=stock_bbox,
mold_steel=mold_steel, surface_quality=surface_quality,
controller=controller,
)
return {"status": "success", "data": result}
@router.post("/check-collision")
async def check_toolpath_collision(
request: Request,
current_user: User = Depends(get_current_active_user),
):
body = await request.json()
toolpath_points = body.get("toolpath_points", [[0, 0, 50], [10, 10, -5], [20, 20, -10]])
tool = body.get("tool", {"diameter": 10, "flute_length": 30, "shank_diameter": 10})
stock_bbox = body.get("stock_bbox", {"min": [-50, -50, -25], "max": [50, 50, 25]})
clamp_positions = body.get("clamp_positions")
cd = _get_cached_import("collision_detector")
if not cd:
raise HTTPException(503, "服务不可用:核心模块未加载")
result = cd.check_toolpath_safety(toolpath_points, tool, stock_bbox, clamp_positions)
return {"status": "success", "data": result}
@router.post("/optimize-toolpath")
async def optimize_toolpath(
request: Request,
current_user: User = Depends(get_current_active_user),
):
body = await request.json()
toolpath_points = body.get("toolpath_points", [[0, 0, 50], [10, 10, -5], [20, 20, -10]])
cutting_params = body.get("cutting_params", {"feed_rate_mm_min": 500})
stock_bbox = body.get("stock_bbox")
to = _get_cached_import("toolpath_optimizer")
if not to:
raise HTTPException(503, "服务不可用:核心模块未加载")
result = to.optimize_toolpath(toolpath_points, cutting_params, stock_bbox)
return {"status": "success", "data": result}
@router.post("/design-electrodes")
async def design_edm_electrodes(
request: Request,
current_user: User = Depends(get_current_active_user),
):
body = await request.json()
undercut_regions = body.get("undercut_regions", [{"center": [0, 0, 0], "area": 100, "type": "undercut"}])
cavity_bbox = body.get("cavity_bbox", {"dimensions": [100, 100, 50]})
material = body.get("material", "copper")
spark_gap = body.get("spark_gap", 0.05)
overburn = body.get("overburn", 0.1)
ed = _get_cached_import("edm_designer")
if not ed:
raise HTTPException(503, "服务不可用:核心模块未加载")
result = ed.design_electrodes(undercut_regions, cavity_bbox, material, spark_gap, overburn)
return {"status": "success", "data": result}
@router.post("/simulate-machining")
async def simulate_machining(
request: Request,
current_user: User = Depends(get_current_active_user),
):
body = await request.json()
operations = body.get("operations", [{"strategy": "z_level_roughing", "levels": [{"z": -5}]}])
stock_bbox = body.get("stock_bbox", {"dimensions": [100, 100, 50], "min": [-50, -50, -25], "max": [50, 50, 25]})
resolution = body.get("resolution", 2.0)
ms = _get_cached_import("machining_simulator")
if not ms:
raise HTTPException(503, "服务不可用:核心模块未加载")
result = ms.simulate_machining(operations, stock_bbox, resolution)
return {"status": "success", "data": result}
@router.post("/export-mold")
async def export_mold_results(
request: Request,
current_user: User = Depends(get_current_active_user),
db_session: AsyncSession = Depends(get_db_session),
):
body = await request.json()
task_id = body.get("task_id")
scheme_id = body.get("scheme_id")
formats = body.get("formats", ["step", "stl"])
components = body.get("components", ["cavity", "core"])
if not task_id:
raise HTTPException(404, "缺少 task_id")
await _ensure_task_access(db_session, task_id, current_user.id)
task_data = await TaskQueryService.get_task_view(db_session, task_id)
if not task_data:
raise HTTPException(404, "任务不存在")
resolved_scheme_id = scheme_id or task_data.get("best_scheme_id") or "default"
persisted_files = _select_persisted_files(
task_id=task_id,
task_data=task_data,
scheme_id=resolved_scheme_id,
formats=formats,
components=components,
)
if persisted_files:
return {
"status": "success",
"data": {
"base_filename": Path(task_data.get("filename", f"mold_{task_id}")).stem,
"task_id": task_id,
"scheme_id": resolved_scheme_id,
"files": persisted_files,
"errors": [],
"total_files": len(persisted_files),
"total_errors": 0,
"source": "persisted",
},
}
cavity_shapes = processing_service.get_export_shapes(
task_id,
resolved_scheme_id,
)
filename = task_data.get("filename", f"mold_{task_id}")
if not cavity_shapes:
# 内存 shape 缓存失效(如服务重启):从持久化的单组件 STEP
# 现场转换缺失格式,用户无需重新分析
artifacts = _get_export_artifacts(task_data)
scheme_data = (artifacts.get("schemes") or {}).get(resolved_scheme_id)
if scheme_data:
base_filename = scheme_data.get("base_filename") or Path(filename).stem
regenerated = await processing_service.regenerate_export_from_persisted(
task_id=task_id,
scheme_id=resolved_scheme_id,
formats=formats,
components=_expand_components(components),
base_filename=base_filename,
scheme_files=scheme_data.get("files", []),
)
if regenerated:
regenerated["files"] = _augment_export_files(
task_id, regenerated.get("files", [])
)
# 合并进持久化 manifest,后续请求直接命中持久化路径
merged_artifacts = _merge_export_artifacts(artifacts, regenerated)
await storage_service.update_task_parameters(
db_session,
task_id,
{"export_artifacts": merged_artifacts},
)
# D9:存储方法已不再自行 commit,请求侧显式提交
await db_session.commit()
await redis_task_manager.update_task(
task_id, {"export_artifacts": merged_artifacts}
)
TaskQueryService.invalidate_task_view(task_id)
return {"status": "success", "data": regenerated}
raise HTTPException(
409,
"导出缓存已失效或任务尚未完成,请重新分析后再导出以保证方案一致性",
)
base_filename = Path(filename).stem
result = cad_exporter.export_mold_results(
cavity_data=cavity_shapes,
base_filename=base_filename,
formats=formats,
components=components,
task_id=task_id,
scheme_id=resolved_scheme_id,
)
result["files"] = _augment_export_files(task_id, result.get("files", []))
result["source"] = "generated"
merged_artifacts = _merge_export_artifacts(_get_export_artifacts(task_data), result)
await storage_service.update_task_parameters(
db_session,
task_id,
{"export_artifacts": merged_artifacts},
)
# D9:存储方法已不再自行 commit,请求侧显式提交
await db_session.commit()
await redis_task_manager.update_task(task_id, {"export_artifacts": merged_artifacts})
TaskQueryService.invalidate_task_view(task_id) # parameters 已变更,缓存视图失效
return {"status": "success", "data": result}
@router.get("/export-download/{filepath:path}")
async def download_export_file(
filepath: str,
task_id: str,
current_user: User = Depends(get_current_active_user),
db_session: AsyncSession = Depends(get_db_session),
):
from fastapi.responses import FileResponse
if not task_id:
raise HTTPException(400, "缺少 task_id")
await _ensure_task_access(db_session, task_id, current_user.id)
task_data = await TaskQueryService.get_task_view(db_session, task_id)
if not task_data:
raise HTTPException(404, "任务不存在")
allowed_paths = set()
artifacts = _get_export_artifacts(task_data)
for scheme in (artifacts.get("schemes") or {}).values():
for file in scheme.get("files", []):
relative_path = str(file.get("relative_path") or "").replace("\\", "/").strip("/")
if relative_path:
allowed_paths.add(relative_path)
normalized_path = str(filepath or "").replace("\\", "/").strip("/")
if normalized_path not in allowed_paths:
raise HTTPException(403, "该文件不在任务允许下载清单中")
full_path = os.path.join(cad_exporter.output_dir, normalized_path.replace("/", os.sep))
if not os.path.exists(full_path):
raise HTTPException(404, "文件不存在")
if not os.path.abspath(full_path).startswith(os.path.abspath(cad_exporter.output_dir)):
raise HTTPException(403, "禁止访问")
media_types = {
".step": "application/step", ".stp": "application/step",
".iges": "application/iges", ".igs": "application/iges",
".stl": "model/stl", ".brep": "application/octet-stream",
}
ext = Path(full_path).suffix.lower()
media_type = media_types.get(ext, "application/octet-stream")
return FileResponse(full_path, media_type=media_type, filename=os.path.basename(full_path))
@router.get("/export-recommendations")
async def get_export_recommendations(
target: str = "ug",
current_user: User = Depends(get_current_active_user),
):
result = cad_exporter.get_export_recommendations(target)
return {"status": "success", "data": result}
+7 -4
View File
@@ -18,19 +18,22 @@ from sqlalchemy.orm import joinedload
from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user
from shared.models.database import User, ProcessingTask, STPFile
from shared.models.identity import User
from moldinsight.models import ProcessingTask, STPFile
from shared.models.schemas import ProcessingStatus, create_task_info
from shared.services.redis_task_manager import redis_task_manager
from shared.utils.file_handler import FileHandler
from shared.utils.logger import get_logger
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
from shared.config.settings import settings
from moldinsight.services.task_storage_service import TaskStorageService
from moldinsight.services.task_dispatcher import dispatch_processing
logger = get_logger(__name__)
router = APIRouter()
file_handler = FileHandler()
# D14:上传限制接 settings(MAX_FILE_SIZE 此前为死配置,文件处理器硬编码 50MB)
file_handler = FileHandler(upload_dir=settings.UPLOAD_DIR, max_file_size=settings.MAX_FILE_SIZE)
@router.post("/batch-upload")
@@ -60,7 +63,7 @@ async def batch_upload(
batch_id = str(uuid.uuid4())
tasks: List[Dict[str, Any]] = []
storage_service = StorageIntegrationService()
storage_service = TaskStorageService()
for file in files:
# 文件类型检查
+41 -26
View File
@@ -1,10 +1,15 @@
from fastapi import APIRouter, Depends, HTTPException, Request
import asyncio
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime
from shared.database.database import get_db_session
from shared.models.database import User, ProcessingTask
from shared.models.identity import User
from moldinsight.models import ProcessingTask
from shared.services.auth_service import get_current_active_user
from moldinsight.services.cam_bundle_service import cam_bundle_service
from moldinsight.services.task_query_service import TaskQueryService
@@ -21,20 +26,25 @@ DEFAULT_CAM_PREFERENCES = {
}
class CamPlanRequest(BaseModel):
"""未提供的偏好字段回落到任务持久化偏好,再回落到默认值。"""
task_id: str
scheme_id: Optional[str] = None
mold_steel: Optional[str] = None
surface_quality: Optional[str] = None
controller: Optional[str] = None
include_gcode: Optional[bool] = None
@router.post("/cam/plan")
async def generate_cam_plan(
request: Request,
body: CamPlanRequest,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
"""基于任务分模结果生成 CAM 准备包(MVP)。"""
_ = current_user
body = await request.json()
task_id = body.get("task_id")
scheme_id = body.get("scheme_id")
if not task_id:
raise HTTPException(status_code=400, detail="缺少 task_id")
task_id = body.task_id
task_result = await db_session.execute(
select(ProcessingTask).where(ProcessingTask.task_id == task_id)
@@ -47,23 +57,26 @@ async def generate_cam_plan(
processing_task.parameters.get("cam_preferences", {}) or {}
)
mold_steel = body.get(
"mold_steel",
persisted_preferences.get("mold_steel", DEFAULT_CAM_PREFERENCES["mold_steel"]),
# 注意 include_gcode 显式判 None:False 是有效值,不能走 or 回落
mold_steel = (
body.mold_steel
if body.mold_steel is not None
else persisted_preferences.get("mold_steel", DEFAULT_CAM_PREFERENCES["mold_steel"])
)
surface_quality = body.get(
"surface_quality",
persisted_preferences.get("surface_quality", DEFAULT_CAM_PREFERENCES["surface_quality"]),
surface_quality = (
body.surface_quality
if body.surface_quality is not None
else persisted_preferences.get("surface_quality", DEFAULT_CAM_PREFERENCES["surface_quality"])
)
controller = body.get(
"controller",
persisted_preferences.get("controller", DEFAULT_CAM_PREFERENCES["controller"]),
controller = (
body.controller
if body.controller is not None
else persisted_preferences.get("controller", DEFAULT_CAM_PREFERENCES["controller"])
)
include_gcode = bool(
body.get(
"include_gcode",
persisted_preferences.get("include_gcode", DEFAULT_CAM_PREFERENCES["include_gcode"]),
)
include_gcode = (
body.include_gcode
if body.include_gcode is not None
else persisted_preferences.get("include_gcode", DEFAULT_CAM_PREFERENCES["include_gcode"])
)
task_view = await TaskQueryService.get_task_view(db_session, task_id)
@@ -73,9 +86,11 @@ async def generate_cam_plan(
raise HTTPException(status_code=400, detail="任务尚未完成,无法生成CAM计划")
try:
data = cam_bundle_service.build_bundle(
# CAM 刀路计算为纯 Python 重计算,投放线程池避免阻塞事件循环
data = await asyncio.to_thread(
cam_bundle_service.build_bundle,
task_view=task_view,
scheme_id=scheme_id,
scheme_id=body.scheme_id,
mold_steel=mold_steel,
surface_quality=surface_quality,
controller=controller,
+47
View File
@@ -0,0 +1,47 @@
"""核心计算模块的惰性装载器(原 advanced_router._get_cached_import,D1 拆分时上提共用)。
- 惰性导入:避免路由模块级加载核心包(含 OCC 重模块)的导入开销与循环依赖
- 装载失败返回 None 且不缓存失败(与原实现一致,端点统一 503「服务不可用」)
- 实例缓存:设计/加工模块为纯 Python 计算(构造后无 self 突变,方法仅读入参),
可安全地被 asyncio.to_thread 并发调用;OCC 相关的 side_action_designer
必须经 processing_service.run_occ 的单线程 executor 使用
"""
import threading
from typing import Optional
from shared.utils.logger import get_logger
logger = get_logger(__name__)
_lock = threading.Lock()
_instances: dict = {}
_LOADERS = {
"side_action_designer": ("moldinsight.core.side_action_designer", "SideActionDesigner"),
"cavity_layout_optimizer": ("moldinsight.core.cavity_layout_optimizer", "CavityLayoutOptimizer"),
"mold_system_designer": ("moldinsight.core.mold_system_designer", "MoldSystemDesigner"),
"mold_cam_designer": ("moldinsight.core.mold_cam", "MoldCAMDesigner"),
"collision_detector": ("moldinsight.core.mold_machining", "CollisionDetector"),
"toolpath_optimizer": ("moldinsight.core.mold_machining", "ToolpathOptimizer"),
"edm_designer": ("moldinsight.core.mold_machining", "EDMElectrodeDesigner"),
"machining_simulator": ("moldinsight.core.mold_machining", "MachiningSimulator"),
}
def get_core_module(key: str):
if key in _instances:
return _instances[key]
if key not in _LOADERS:
return None
with _lock:
if key in _instances:
return _instances[key]
module_path, class_name = _LOADERS[key]
try:
module = __import__(module_path, fromlist=[class_name])
instance = getattr(module, class_name)()
except Exception as e:
logger.warning(f"核心模块 {key} 加载失败: {e}")
return None
_instances[key] = instance
return instance
+51
View File
@@ -0,0 +1,51 @@
# api/cost_router.py
"""成本估算接口(批次 3 自 advanced_router 拆分,D1)。"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from shared.services.auth_service import get_current_active_user
from shared.database.database import get_db_session
from shared.models.identity import User
from moldinsight.services.task_query_service import TaskQueryService
router = APIRouter()
class CostEstimateRequest(BaseModel):
task_id: str
@router.post("/cost-estimate")
async def estimate_cost(
body: CostEstimateRequest,
current_user: User = Depends(get_current_active_user),
db_session: AsyncSession = Depends(get_db_session),
):
"""模具成本估算:优先使用 LLM,未启用时降级为规则式估算"""
# 归属校验统一走 TaskQueryService(与 /api/status 共用,含 404/403 语义)
await TaskQueryService.ensure_task_access(db_session, body.task_id, current_user.id)
# 统一走任务视图:进行中读 Redis,完成态由 PG+RustFS 组装(Redis 大对象已瘦身)
task_data = await TaskQueryService.get_task_view(db_session, body.task_id)
if not task_data:
raise HTTPException(404, "任务不存在")
analysis_result = task_data.get("analysis_result")
if not analysis_result:
raise HTTPException(400, "该任务尚未完成分析")
detailed_context = {
"candidate_schemes": task_data.get("candidate_schemes", []),
"geometry_data": task_data.get("geometry_data", {}),
"metadata": {"selected_material": task_data.get("material")},
}
# 优先使用 LLM
from moldinsight.services.llm_service import llm_service
result = await llm_service.estimate_cost(analysis_result, detailed_context)
if result is not None:
result["source"] = "ai"
return {"status": "success", "data": result}
# LLM 未启用或失败,降级为规则估算
from moldinsight.services.cost_estimate_service import estimate_cost_by_rules
rules_result = estimate_cost_by_rules(analysis_result, detailed_context)
return {"status": "success", "data": rules_result}
+1 -1
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends
from shared.services.auth_service import get_current_active_user
from shared.services.redis_task_manager import redis_task_manager
from shared.models.database import User
from shared.models.identity import User
router = APIRouter()
+181
View File
@@ -0,0 +1,181 @@
# api/design_router.py
"""模具结构设计类接口(批次 3 自 advanced_router 拆分,D1)。
- 请求体一律 Pydantic 模型(原 request.json() 手动解析退役,校验失败统一 422)
- 纯 Python 设计计算统一经 asyncio.to_thread 投放线程池,不阻塞事件循环;
OCC 相关的倒扣检测仍走 processing_service.run_occ 的单线程 executor
(PythonOCC 非线程安全)
"""
import asyncio
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from shared.services.auth_service import get_current_active_user
from shared.database.database import get_db_session
from shared.models.identity import User
from moldinsight.services.processing_service import processing_service
from moldinsight.services.task_query_service import TaskQueryService
from moldinsight.api.core_modules import get_core_module
router = APIRouter()
# ---- 请求模型 ----
class BBox3D(BaseModel):
dimensions: List[float] = Field(default_factory=lambda: [100.0, 100.0, 50.0])
min: Optional[List[float]] = None
max: Optional[List[float]] = None
class MoldSize(BaseModel):
length: float = 300.0
width: float = 300.0
height: float = 200.0
class OptimizeLayoutRequest(BaseModel):
product_bbox: BBox3D = Field(default_factory=BBox3D)
cavity_count: int = Field(default=1, ge=1, le=64)
mold_base_size: Optional[BBox3D] = None
layout_type: str = "auto"
class CoolingDesignRequest(BaseModel):
mold_size: MoldSize = Field(default_factory=MoldSize)
product_bbox: BBox3D = Field(default_factory=BBox3D)
material: str = "ABS"
cavity_count: int = Field(default=1, ge=1, le=64)
cycle_time_target: Optional[float] = None
class GatingDesignRequest(BaseModel):
product_bbox: BBox3D = Field(default_factory=BBox3D)
material: str = "ABS"
cavity_count: int = Field(default=1, ge=1, le=64)
gate_type: str = "auto"
layout_positions: Optional[List[List[float]]] = None
class MoldSystemDesignRequest(BaseModel):
mold_size: MoldSize = Field(default_factory=MoldSize)
product_bbox: BBox3D = Field(default_factory=BBox3D)
material: str = "ABS"
cavity_count: int = Field(default=1, ge=1, le=64)
gate_type: str = "auto"
cycle_time_target: Optional[float] = None
layout_positions: Optional[List[List[float]]] = None
class UndercutDetectRequest(BaseModel):
task_id: str
parting_direction: List[float] = Field(default_factory=lambda: [0.0, 0.0, 1.0])
mold_size: MoldSize = Field(default_factory=MoldSize)
@router.post("/optimize-layout")
async def optimize_cavity_layout(
body: OptimizeLayoutRequest,
current_user: User = Depends(get_current_active_user),
):
optimizer = get_core_module("cavity_layout_optimizer")
if not optimizer:
raise HTTPException(503, "服务不可用:核心模块未加载")
# 纯 Python 布局优化,投放线程池避免阻塞事件循环
result = await asyncio.to_thread(
optimizer.optimize_layout,
product_bbox=body.product_bbox.model_dump(exclude_none=True),
cavity_count=body.cavity_count,
mold_base_size=body.mold_base_size.model_dump(exclude_none=True) if body.mold_base_size else None,
layout_type=body.layout_type,
)
return {"status": "success", "data": result}
@router.post("/design-cooling")
async def design_cooling_system(
body: CoolingDesignRequest,
current_user: User = Depends(get_current_active_user),
):
from moldinsight.core.mold_system_designer import CoolingSystemDesigner
designer = CoolingSystemDesigner()
result = await asyncio.to_thread(
designer.design_cooling_system,
mold_size=body.mold_size.model_dump(),
product_bbox=body.product_bbox.model_dump(exclude_none=True),
material=body.material,
cavity_count=body.cavity_count,
cycle_time_target=body.cycle_time_target,
)
return {"status": "success", "data": result}
@router.post("/design-gating")
async def design_gating_system(
body: GatingDesignRequest,
current_user: User = Depends(get_current_active_user),
):
from moldinsight.core.mold_system_designer import GatingSystemDesigner
designer = GatingSystemDesigner()
result = await asyncio.to_thread(
designer.design_gating_system,
product_bbox=body.product_bbox.model_dump(exclude_none=True),
material=body.material,
cavity_count=body.cavity_count,
gate_type=body.gate_type,
layout_positions=body.layout_positions,
)
return {"status": "success", "data": result}
@router.post("/design-mold-system")
async def design_complete_mold_system(
body: MoldSystemDesignRequest,
current_user: User = Depends(get_current_active_user),
):
ds = get_core_module("mold_system_designer")
if not ds:
raise HTTPException(503, "服务不可用:核心模块未加载")
result = await asyncio.to_thread(
ds.design_complete_system,
mold_size=body.mold_size.model_dump(),
product_bbox=body.product_bbox.model_dump(exclude_none=True),
material=body.material,
cavity_count=body.cavity_count,
gate_type=body.gate_type,
cycle_time_target=body.cycle_time_target,
layout_positions=body.layout_positions,
)
return {"status": "success", "data": result}
@router.post("/detect-undercuts")
async def detect_undercuts(
body: UndercutDetectRequest,
current_user: User = Depends(get_current_active_user),
db_session: AsyncSession = Depends(get_db_session),
):
# 归属校验统一走 TaskQueryService(与 /api/status 共用,含 404/403 语义)
await TaskQueryService.ensure_task_access(db_session, body.task_id, current_user.id)
sd = get_core_module("side_action_designer")
if not sd:
raise HTTPException(503, "服务不可用:核心模块未加载")
# 从持久化 STP 原件重建几何(此前传 shape=None 会被兜底吞掉,永远返回"无倒扣")
from moldinsight.services.shape_loader import get_shape_loader
shape = await get_shape_loader().load_shape_for_task(db_session, body.task_id)
if shape is None:
raise HTTPException(410, "任务几何不可用:无法从存储重建 STP 形状,请重新上传分析")
# OCC 操作必须走单线程 executor(PythonOCC 非线程安全)
result = await processing_service.run_occ(
sd.analyze_and_design,
shape,
body.parting_direction,
body.mold_size.model_dump(),
)
return {"status": "success", "data": result}
+302
View File
@@ -0,0 +1,302 @@
# api/export_router.py
"""导出类接口(批次 3 自 advanced_router 拆分,D1)。
导出产物清单(export_artifacts)的合并/校验辅助函数自原文件平移,
行为不变;任务归属校验直接调用 TaskQueryService.ensure_task_access。
"""
from datetime import datetime
from pathlib import Path
from typing import List, Optional
import os
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from shared.services.auth_service import get_current_active_user
from shared.services.redis_task_manager import redis_task_manager
from shared.database.database import get_db_session
from shared.models.identity import User
from moldinsight.services.processing_service import processing_service
from moldinsight.services.task_query_service import TaskQueryService
from moldinsight.services.task_storage_service import TaskStorageService
from moldinsight.core.cad_exporter import CADExporter
from shared.utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter()
cad_exporter = CADExporter()
class ExportMoldRequest(BaseModel):
task_id: str
scheme_id: Optional[str] = None
formats: List[str] = Field(default_factory=lambda: ["step", "stl"])
components: List[str] = Field(default_factory=lambda: ["cavity", "core"])
# ---- 导出产物清单辅助(自原 advanced_router 平移) ----
def _get_export_artifacts(task_data: dict) -> dict:
if not isinstance(task_data, dict):
return {}
direct = task_data.get("export_artifacts")
if isinstance(direct, dict):
return direct
parameters = task_data.get("parameters")
if isinstance(parameters, dict) and isinstance(parameters.get("export_artifacts"), dict):
return parameters.get("export_artifacts")
return {}
def _expand_components(components):
requested = components or ["cavity", "core"]
if "all" in requested:
return ["cavity", "core", "parting_surface"]
return list(dict.fromkeys(requested))
def _augment_export_files(task_id: str, files):
items = []
for file in files or []:
item = dict(file)
relative_path = item.get("relative_path")
if not relative_path and item.get("filepath"):
relative_path = cad_exporter.get_relative_path(item["filepath"])
if relative_path:
relative_path = str(relative_path).replace("\\", "/").strip("/")
item["relative_path"] = relative_path
item["download_path"] = f"/api/export-download/{quote(relative_path, safe='/')}?task_id={task_id}"
items.append(item)
return items
def _merge_export_artifacts(existing: dict, export_result: dict) -> dict:
merged = dict(existing or {})
schemes = dict(merged.get("schemes") or {})
scheme_id = export_result.get("scheme_id") or "default"
previous = dict(schemes.get(scheme_id) or {})
file_map = {}
for file in previous.get("files", []):
file_map[(file.get("component"), file.get("format"))] = file
for file in export_result.get("files", []):
file_map[(file.get("component"), file.get("format"))] = file
schemes[scheme_id] = {
"base_filename": export_result.get("base_filename") or previous.get("base_filename"),
"generated_at": datetime.now().isoformat(),
"files": sorted(
file_map.values(),
key=lambda item: (item.get("component", ""), item.get("format", "")),
),
"errors": export_result.get("errors", []),
"total_files": len(file_map),
"total_errors": len(export_result.get("errors", [])),
}
merged["version"] = 1
merged["task_id"] = export_result.get("task_id") or merged.get("task_id")
merged["generated_at"] = merged.get("generated_at") or datetime.now().isoformat()
merged["schemes"] = schemes
return merged
def _select_persisted_files(task_id: str, task_data: dict, scheme_id: str, formats, components):
artifacts = _get_export_artifacts(task_data)
scheme_data = (artifacts.get("schemes") or {}).get(scheme_id)
if not scheme_data:
return None
component_list = _expand_components(components)
format_list = list(dict.fromkeys(formats or ["step", "stl"]))
expected = {(component, fmt) for component in component_list for fmt in format_list}
available = []
available_keys = set()
for file in scheme_data.get("files", []):
component = file.get("component")
fmt = file.get("format")
if component not in component_list or fmt not in format_list:
continue
relative_path = str(file.get("relative_path") or "").replace("\\", "/").strip("/")
if not relative_path:
continue
full_path = os.path.join(cad_exporter.output_dir, relative_path.replace("/", os.sep))
if not os.path.exists(full_path):
continue
available.append(file)
available_keys.add((component, fmt))
if expected and not expected.issubset(available_keys):
return None
return _augment_export_files(task_id, available)
# ---- 端点 ----
@router.post("/export-mold")
async def export_mold_results(
body: ExportMoldRequest,
current_user: User = Depends(get_current_active_user),
db_session: AsyncSession = Depends(get_db_session),
):
task_id = body.task_id
formats = body.formats
components = body.components
# 归属校验统一走 TaskQueryService(与 /api/status 共用,含 404/403 语义)
await TaskQueryService.ensure_task_access(db_session, task_id, current_user.id)
task_data = await TaskQueryService.get_task_view(db_session, task_id)
if not task_data:
raise HTTPException(404, "任务不存在")
resolved_scheme_id = body.scheme_id or task_data.get("best_scheme_id") or "default"
persisted_files = _select_persisted_files(
task_id=task_id,
task_data=task_data,
scheme_id=resolved_scheme_id,
formats=formats,
components=components,
)
if persisted_files:
return {
"status": "success",
"data": {
"base_filename": Path(task_data.get("filename", f"mold_{task_id}")).stem,
"task_id": task_id,
"scheme_id": resolved_scheme_id,
"files": persisted_files,
"errors": [],
"total_files": len(persisted_files),
"total_errors": 0,
"source": "persisted",
},
}
cavity_shapes = processing_service.get_export_shapes(
task_id,
resolved_scheme_id,
)
filename = task_data.get("filename", f"mold_{task_id}")
if not cavity_shapes:
# 内存 shape 缓存失效(如服务重启):从持久化的单组件 STEP
# 现场转换缺失格式,用户无需重新分析
artifacts = _get_export_artifacts(task_data)
scheme_data = (artifacts.get("schemes") or {}).get(resolved_scheme_id)
if scheme_data:
base_filename = scheme_data.get("base_filename") or Path(filename).stem
regenerated = await processing_service.regenerate_export_from_persisted(
task_id=task_id,
scheme_id=resolved_scheme_id,
formats=formats,
components=_expand_components(components),
base_filename=base_filename,
scheme_files=scheme_data.get("files", []),
)
if regenerated:
regenerated["files"] = _augment_export_files(
task_id, regenerated.get("files", [])
)
# 合并进持久化 manifest,后续请求直接命中持久化路径
merged_artifacts = _merge_export_artifacts(artifacts, regenerated)
await TaskStorageService().update_task_parameters(
db_session,
task_id,
{"export_artifacts": merged_artifacts},
)
# D9:存储方法已不再自行 commit,请求侧显式提交
await db_session.commit()
await redis_task_manager.update_task(
task_id, {"export_artifacts": merged_artifacts}
)
TaskQueryService.invalidate_task_view(task_id)
return {"status": "success", "data": regenerated}
raise HTTPException(
409,
"导出缓存已失效或任务尚未完成,请重新分析后再导出以保证方案一致性",
)
base_filename = Path(filename).stem
result = cad_exporter.export_mold_results(
cavity_data=cavity_shapes,
base_filename=base_filename,
formats=formats,
components=components,
task_id=task_id,
scheme_id=resolved_scheme_id,
)
result["files"] = _augment_export_files(task_id, result.get("files", []))
result["source"] = "generated"
merged_artifacts = _merge_export_artifacts(_get_export_artifacts(task_data), result)
await TaskStorageService().update_task_parameters(
db_session,
task_id,
{"export_artifacts": merged_artifacts},
)
# D9:存储方法已不再自行 commit,请求侧显式提交
await db_session.commit()
await redis_task_manager.update_task(task_id, {"export_artifacts": merged_artifacts})
TaskQueryService.invalidate_task_view(task_id) # parameters 已变更,缓存视图失效
return {"status": "success", "data": result}
@router.get("/export-download/{filepath:path}")
async def download_export_file(
filepath: str,
task_id: str,
current_user: User = Depends(get_current_active_user),
db_session: AsyncSession = Depends(get_db_session),
):
if not task_id:
raise HTTPException(400, "缺少 task_id")
# 归属校验统一走 TaskQueryService(与 /api/status 共用,含 404/403 语义)
await TaskQueryService.ensure_task_access(db_session, task_id, current_user.id)
task_data = await TaskQueryService.get_task_view(db_session, task_id)
if not task_data:
raise HTTPException(404, "任务不存在")
allowed_paths = set()
artifacts = _get_export_artifacts(task_data)
for scheme in (artifacts.get("schemes") or {}).values():
for file in scheme.get("files", []):
relative_path = str(file.get("relative_path") or "").replace("\\", "/").strip("/")
if relative_path:
allowed_paths.add(relative_path)
normalized_path = str(filepath or "").replace("\\", "/").strip("/")
if normalized_path not in allowed_paths:
raise HTTPException(403, "该文件不在任务允许下载清单中")
full_path = os.path.join(cad_exporter.output_dir, normalized_path.replace("/", os.sep))
if not os.path.exists(full_path):
raise HTTPException(404, "文件不存在")
if not os.path.abspath(full_path).startswith(os.path.abspath(cad_exporter.output_dir)):
raise HTTPException(403, "禁止访问")
media_types = {
".step": "application/step", ".stp": "application/step",
".iges": "application/iges", ".igs": "application/iges",
".stl": "model/stl", ".brep": "application/octet-stream",
}
ext = Path(full_path).suffix.lower()
media_type = media_types.get(ext, "application/octet-stream")
return FileResponse(full_path, media_type=media_type, filename=os.path.basename(full_path))
@router.get("/export-recommendations")
async def get_export_recommendations(
target: str = "ug",
current_user: User = Depends(get_current_active_user),
):
result = cad_exporter.get_export_recommendations(target)
return {"status": "success", "data": result}
+18 -5
View File
@@ -1,7 +1,11 @@
# api/v1/health_router.py
import asyncio
from fastapi import APIRouter
from shared.services.redis_task_manager import redis_task_manager
from moldinsight.api.route_registry import route_load_status
from moldinsight.core.occ_availability import is_pythonocc_available
router = APIRouter()
@@ -9,10 +13,19 @@ router = APIRouter()
@router.get("/health")
@router.post("/health")
async def health():
task_count = await redis_task_manager.get_task_count()
# 首次调用会触发 PythonOCC 导入(可能耗时数秒),投放线程池避免阻塞事件循环
pythonocc_available = await asyncio.to_thread(is_pythonocc_available)
failed_routes = route_load_status["failed"]
return {
"status": "healthy",
"pythonocc": True,
"total_tasks": task_count,
"redis_connected": redis_task_manager.is_connected
# 有业务路由装载失败即 degraded:进程活着但功能残缺,监控必须可感知
"status": "degraded" if failed_routes else "healthy",
# 真实探测 PythonOCC(此前硬编码 True,与上传预检的诚实化同源)
"pythonocc": pythonocc_available,
"total_tasks": await redis_task_manager.get_task_count(),
"redis_connected": redis_task_manager.is_connected,
"routes": {
"loaded": [m["label"] for m in route_load_status["loaded"]],
"failed": failed_routes,
"disabled": [m["label"] for m in route_load_status["disabled"]],
},
}
+4 -4
View File
@@ -2,10 +2,10 @@
from fastapi import APIRouter, Depends
import urllib.parse
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
from moldinsight.services.file_history_service import FileHistoryService
from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user
from shared.models.database import User
from shared.models.identity import User
from shared.utils.logger import get_logger
from sqlalchemy.ext.asyncio import AsyncSession
@@ -21,7 +21,7 @@ async def get_file_history(
current_user: User = Depends(get_current_active_user),
):
"""获取当前用户按文件名分组的文件历史记录(支持多上传)"""
storage_service = StorageIntegrationService()
storage_service = FileHistoryService()
file_groups = await storage_service.get_all_file_groups(
db_session, user_id=current_user.id
)
@@ -42,7 +42,7 @@ async def get_file_records(
"""获取当前用户指定文件名的所有上传记录(支持多上传历史)"""
decoded_filename = urllib.parse.unquote(filename)
storage_service = StorageIntegrationService()
storage_service = FileHistoryService()
file_records = await storage_service.get_file_history_by_filename(
db_session,
decoded_filename,
+167
View File
@@ -0,0 +1,167 @@
# api/machining_router.py
"""CAM / 加工仿真类接口(批次 3 自 advanced_router 拆分,D1)。
加工计算为纯 Python 重计算(非 OCC),统一经 asyncio.to_thread
投放线程池执行,不阻塞事件循环。
"""
import asyncio
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from shared.services.auth_service import get_current_active_user
from shared.models.identity import User
from moldinsight.api.core_modules import get_core_module
from moldinsight.api.design_router import BBox3D
router = APIRouter()
# ---- 请求模型 ----
class ToolSpec(BaseModel):
diameter: float = 10.0
flute_length: float = 30.0
shank_diameter: float = 10.0
def _cam_cavity_bbox() -> BBox3D:
return BBox3D(dimensions=[100.0, 100.0, 50.0], min=[-50.0, -50.0, -25.0], max=[50.0, 50.0, 25.0])
def _cam_stock_bbox() -> BBox3D:
return BBox3D(dimensions=[150.0, 150.0, 100.0], min=[-75.0, -75.0, -50.0], max=[75.0, 75.0, 50.0])
class CamDesignRequest(BaseModel):
cavity_bbox: BBox3D = Field(default_factory=_cam_cavity_bbox)
stock_bbox: BBox3D = Field(default_factory=_cam_stock_bbox)
mold_steel: str = "P20"
surface_quality: str = "standard"
controller: str = "fanuc"
class CollisionCheckRequest(BaseModel):
toolpath_points: List[List[float]] = Field(
default_factory=lambda: [[0, 0, 50], [10, 10, -5], [20, 20, -10]]
)
tool: ToolSpec = Field(default_factory=ToolSpec)
stock_bbox: BBox3D = Field(default_factory=_cam_cavity_bbox)
clamp_positions: Optional[List[List[float]]] = None
class ToolpathOptimizeRequest(BaseModel):
toolpath_points: List[List[float]] = Field(
default_factory=lambda: [[0, 0, 50], [10, 10, -5], [20, 20, -10]]
)
cutting_params: Dict[str, Any] = Field(default_factory=lambda: {"feed_rate_mm_min": 500})
stock_bbox: Optional[BBox3D] = None
class ElectrodeDesignRequest(BaseModel):
undercut_regions: List[Dict[str, Any]] = Field(
default_factory=lambda: [{"center": [0, 0, 0], "area": 100, "type": "undercut"}]
)
cavity_bbox: BBox3D = Field(default_factory=BBox3D)
material: str = "copper"
spark_gap: float = 0.05
overburn: float = 0.1
class MachiningSimulateRequest(BaseModel):
operations: List[Dict[str, Any]] = Field(
default_factory=lambda: [{"strategy": "z_level_roughing", "levels": [{"z": -5}]}]
)
stock_bbox: BBox3D = Field(default_factory=_cam_cavity_bbox)
resolution: float = 2.0
@router.post("/design-cam")
async def design_mold_cam(
body: CamDesignRequest,
current_user: User = Depends(get_current_active_user),
):
cam = get_core_module("mold_cam_designer")
if not cam:
raise HTTPException(503, "服务不可用:核心模块未加载")
result = await asyncio.to_thread(
cam.design_mold_cam,
cavity_bbox=body.cavity_bbox.model_dump(exclude_none=True),
stock_bbox=body.stock_bbox.model_dump(exclude_none=True),
mold_steel=body.mold_steel,
surface_quality=body.surface_quality,
controller=body.controller,
)
return {"status": "success", "data": result}
@router.post("/check-collision")
async def check_toolpath_collision(
body: CollisionCheckRequest,
current_user: User = Depends(get_current_active_user),
):
cd = get_core_module("collision_detector")
if not cd:
raise HTTPException(503, "服务不可用:核心模块未加载")
result = await asyncio.to_thread(
cd.check_toolpath_safety,
body.toolpath_points,
body.tool.model_dump(),
body.stock_bbox.model_dump(exclude_none=True),
body.clamp_positions,
)
return {"status": "success", "data": result}
@router.post("/optimize-toolpath")
async def optimize_toolpath(
body: ToolpathOptimizeRequest,
current_user: User = Depends(get_current_active_user),
):
to = get_core_module("toolpath_optimizer")
if not to:
raise HTTPException(503, "服务不可用:核心模块未加载")
result = await asyncio.to_thread(
to.optimize_toolpath,
body.toolpath_points,
body.cutting_params,
body.stock_bbox.model_dump(exclude_none=True) if body.stock_bbox else None,
)
return {"status": "success", "data": result}
@router.post("/design-electrodes")
async def design_edm_electrodes(
body: ElectrodeDesignRequest,
current_user: User = Depends(get_current_active_user),
):
ed = get_core_module("edm_designer")
if not ed:
raise HTTPException(503, "服务不可用:核心模块未加载")
result = await asyncio.to_thread(
ed.design_electrodes,
body.undercut_regions,
body.cavity_bbox.model_dump(exclude_none=True),
body.material,
body.spark_gap,
body.overburn,
)
return {"status": "success", "data": result}
@router.post("/simulate-machining")
async def simulate_machining(
body: MachiningSimulateRequest,
current_user: User = Depends(get_current_active_user),
):
ms = get_core_module("machining_simulator")
if not ms:
raise HTTPException(503, "服务不可用:核心模块未加载")
result = await asyncio.to_thread(
ms.simulate_machining,
body.operations,
body.stock_bbox.model_dump(exclude_none=True),
body.resolution,
)
return {"status": "success", "data": result}
+18
View File
@@ -0,0 +1,18 @@
"""路由装载注册表。
moldinsight/api/__init__.py 的 _safe_include 将装载结果登记于此,
由 /api/health 对外呈现——路由装载失败不再只是 WARNING 日志(此前
业务路由加载失败会被静默跳过,进程照常 healthy,功能残缺不可感知)。
本模块保持零依赖,供聚合入口与 health_router 双向引用而不产生循环导入。
"""
from typing import Dict, List
route_load_status: Dict[str, List[Dict[str, str]]] = {
# 装载成功:{"label", "module"}
"loaded": [],
# 装载失败:{"label", "module", "error"}——存在条目时 /api/health 返回 degraded
"failed": [],
# 有意不注册(如 DEBUG 关闭时的调试路由):{"label", "module"}
"disabled": [],
}
+1 -1
View File
@@ -7,7 +7,7 @@ from moldinsight.services.task_query_service import TaskQueryService
from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user
from shared.utils.logger import get_logger
from shared.models.database import User
from shared.models.identity import User
logger = get_logger(__name__)
+8 -18
View File
@@ -4,34 +4,24 @@ import uuid
from datetime import datetime
from shared.models.schemas import ProcessingStatus, create_task_info
from shared.config.settings import settings
from shared.utils.file_handler import FileHandler
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
from moldinsight.services.task_storage_service import TaskStorageService
from moldinsight.services.task_dispatcher import dispatch_processing
from shared.services.redis_task_manager import redis_task_manager
from shared.database.database import get_db_session
from shared.utils.logger import get_logger
from sqlalchemy.ext.asyncio import AsyncSession
from shared.services.auth_service import get_current_active_user
from shared.models.database import User
from shared.models.identity import User
from moldinsight.core.occ_availability import is_pythonocc_available
logger = get_logger(__name__)
router = APIRouter()
file_handler = FileHandler()
def _occ_available() -> bool:
"""真实检测 PythonOCC 可用性(惰性导入,缺失时不影响本路由加载)。
此前该字段硬编码 True,响应不诚实;几何处理依赖 OCC,
不可用时任务会在处理阶段以明确错误失败。
"""
try:
import OCC.Core.STEPControl # noqa: F401
return True
except Exception:
return False
# D14:上传限制接 settings(MAX_FILE_SIZE 此前为死配置,文件处理器硬编码 50MB)
file_handler = FileHandler(upload_dir=settings.UPLOAD_DIR, max_file_size=settings.MAX_FILE_SIZE)
@router.post("/upload")
@@ -72,7 +62,7 @@ async def upload_stp(
raise HTTPException(400, str(exc)) from exc
logger.info(f"[UPLOAD] 文件已保存: {file_path} ({file_size} bytes), task_id={task_id}")
storage_service = StorageIntegrationService()
storage_service = TaskStorageService()
stp_file = await storage_service.save_stp_file(
session=db_session,
@@ -114,7 +104,7 @@ async def upload_stp(
"file_info": {
"filename": file.filename,
"size": file_size,
"pythonocc_available": _occ_available(),
"pythonocc_available": is_pythonocc_available(),
"database_file_id": stp_file.id,
"sha256": file_meta["sha256"],
},