This commit is contained in:
2026-09-16 17:55:04 +08:00
parent 3f120417d1
commit 4537faf2c4
39 changed files with 986 additions and 423 deletions
+6 -18
View File
@@ -4,7 +4,6 @@ from datetime import datetime
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from shared.services.auth_service import get_current_active_user
@@ -14,7 +13,6 @@ from moldinsight.services.storage_integration_rustfs import StorageIntegrationSe
from moldinsight.services.task_query_service import TaskQueryService
from shared.database.database import get_db_session
from shared.models.database import User
from shared.models.database import ProcessingTask, STPFile
from moldinsight.core.cad_exporter import CADExporter
from shared.utils.logger import get_logger
@@ -70,22 +68,8 @@ async def _ensure_task_access(
task_id: str,
user_id: int,
):
row = await db_session.execute(
select(ProcessingTask, STPFile)
.join(STPFile, ProcessingTask.stp_file_id == STPFile.id)
.where(ProcessingTask.task_id == task_id)
)
row = row.first()
if not row:
raise HTTPException(404, "任务不存在")
_, stp_file = row
owner_id = getattr(stp_file, "user_id", None)
if owner_id != user_id:
# 无主历史数据(owner_id is None)同样拒绝:无主不等于公共
raise HTTPException(403, "无权访问该任务的导出文件")
return row
# 归属校验统一走 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:
@@ -513,6 +497,8 @@ async def export_mold_results(
task_id,
{"export_artifacts": merged_artifacts},
)
# D9:存储方法已不再自行 commit,请求侧显式提交
await db_session.commit()
await redis_task_manager.update_task(
task_id, {"export_artifacts": merged_artifacts}
)
@@ -543,6 +529,8 @@ async def export_mold_results(
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 已变更,缓存视图失效
+44 -79
View File
@@ -3,17 +3,22 @@ moldinsight/api/batch_router.py — 批量分析端点
- POST /api/batch-upload 批量上传多文件,返回 batch_id + 各 task_id
- GET /api/batch/{batch_id} 聚合查询批量任务进度
批次 2(D7):批量元数据以 PG 为单一事实源——ProcessingTask.batch_id
列聚合查询,替代此前的 Redis key + 进程内存降级存储。
"""
import uuid
from datetime import datetime
from typing import List, Dict, Any, Optional
from typing import List, Dict, Any
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
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
from shared.models.database import User, 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
@@ -27,17 +32,6 @@ router = APIRouter()
file_handler = FileHandler()
# ─── 批量元数据 Redis key 约定 ──────────────────────────────────────
_BATCH_KEY_PREFIX = "batch:"
_BATCH_TTL = 86400 # 24h
# Redis 不可用时的进程内降级存储(同进程内可查,跨进程/重启不可见)
_batch_meta_memory: Dict[str, dict] = {}
def _batch_redis_key(batch_id: str) -> str:
return f"{_BATCH_KEY_PREFIX}{batch_id}"
@router.post("/batch-upload")
async def batch_upload(
@@ -91,8 +85,11 @@ async def batch_upload(
)
await storage_service.create_processing_task(
db_session, task_id, stp_file.id, parameters=process_params,
db_session, task_id, stp_file.id,
parameters=process_params, batch_id=batch_id,
)
# D9:STPFile + ProcessingTask 原子提交,分派前置事务收口
await db_session.commit()
task_info = create_task_info(
task_id=task_id,
@@ -108,7 +105,7 @@ async def batch_upload(
await redis_task_manager.set_task(task_id, task_info)
# 调度处理
dispatch_processing(task_id, str(file_path), stp_file.id, process_params)
dispatch_processing(task_id, stp_file.id, process_params)
tasks.append({
"filename": file.filename,
@@ -129,17 +126,6 @@ async def batch_upload(
"error": str(exc),
})
# 将 batch 元数据写入 Redis;Redis 不可用时降级到进程内存储(任务状态本身有内存回退)
batch_meta = {
"batch_id": batch_id,
"user_id": current_user.id,
"created_at": str(datetime.now()),
"task_ids": [t["task_id"] for t in tasks if t.get("task_id")],
"total": len(tasks),
"params": process_params,
}
_save_batch_meta(batch_id, batch_meta)
return {
"batch_id": batch_id,
"total": len(tasks),
@@ -148,67 +134,41 @@ async def batch_upload(
}
def _save_batch_meta(batch_id: str, batch_meta: dict):
"""批量元数据持久化:优先 Redis(跨进程、带 TTL),降级进程内 dict。"""
import json as _json
if redis_task_manager.is_connected:
try:
redis_task_manager.redis_client.set(
_batch_redis_key(batch_id),
_json.dumps(batch_meta),
ex=_BATCH_TTL,
)
return
except Exception as exc:
logger.warning(f"[BATCH] batch 元数据写 Redis 失败,降级内存: {exc}")
_batch_meta_memory[batch_id] = batch_meta
async def _load_batch_meta(batch_id: str) -> Optional[dict]:
"""读取批量元数据,Redis 优先,内存兜底;不存在返回 None。"""
import json as _json
if redis_task_manager.is_connected:
try:
raw = await redis_task_manager.redis_client.get(_batch_redis_key(batch_id))
if raw:
return _json.loads(raw)
except Exception as exc:
logger.warning(f"[BATCH] batch 元数据读 Redis 失败: {exc}")
return _batch_meta_memory.get(batch_id)
@router.get("/batch/{batch_id}")
async def get_batch_status(
batch_id: str,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
"""聚合查询批量任务进度"""
batch_meta = await _load_batch_meta(batch_id)
if not batch_meta:
"""聚合查询批量任务进度(D7:以 PG 为单一事实源,按 batch_id 聚合;Redis 仅热缓存)"""
rows = (await db_session.execute(
select(ProcessingTask, STPFile)
.join(STPFile, ProcessingTask.stp_file_id == STPFile.id)
.where(ProcessingTask.batch_id == batch_id)
.options(joinedload(STPFile.html_file))
.order_by(ProcessingTask.id)
)).unique().all()
if not rows:
raise HTTPException(404, "批量任务不存在或已过期")
# 权限检查
if batch_meta.get("user_id") and batch_meta["user_id"] != current_user.id:
# 归属校验:同批任务属于同一上传用户,任一不匹配即拒绝(无主不等于公共)
if any(getattr(stp, "user_id", None) != current_user.id for _, stp in rows):
raise HTTPException(403, "无权访问该批量任务")
task_ids = batch_meta.get("task_ids", [])
task_statuses = []
completed = 0
failed = 0
processing = 0
earliest_created = None
for tid in task_ids:
task_data = await redis_task_manager.get_task(tid)
if not task_data:
task_statuses.append({"task_id": tid, "status": "unknown"})
continue
status = task_data.get("status", "unknown")
progress = task_data.get("progress", 0)
filename = task_data.get("filename", "")
error = task_data.get("error", "")
html_file = task_data.get("html_file", "")
for task, stp in rows:
status = task.status or "unknown"
if earliest_created is None or (
task.created_time and task.created_time < earliest_created
):
earliest_created = task.created_time
if status == ProcessingStatus.COMPLETED:
completed += 1
@@ -217,19 +177,24 @@ async def get_batch_status(
else:
processing += 1
html_file = ""
if stp.html_file and stp.html_file.filename:
html_file = f"/html/{stp.html_file.filename}"
task_statuses.append({
"task_id": tid,
"task_id": task.task_id,
"status": status,
"progress": progress,
"filename": filename,
"error": error,
"progress": task.progress or 0,
"current_step": task.current_step,
"filename": stp.original_filename or "",
"error": task.error_message or "",
"html_file": html_file,
})
total = len(task_ids)
total = len(rows)
return {
"batch_id": batch_id,
"created_at": batch_meta.get("created_at"),
"created_at": earliest_created.isoformat() if earliest_created else None,
"total": total,
"completed": completed,
"failed": failed,
+9 -4
View File
@@ -1,13 +1,13 @@
# api/v1/task_router.py
from fastapi import APIRouter, HTTPException, Request, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
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 ProcessingTask, STPFile
from shared.models.database import User
logger = get_logger(__name__)
@@ -16,15 +16,20 @@ router = APIRouter()
@router.get("/status/{task_id}")
@router.post("/status/{task_id}")
async def get_status(task_id: str, db_session: AsyncSession = Depends(get_db_session)):
async def get_status(
task_id: str,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
"""
获取任务状态
获取任务状态(需登录,且仅任务所有者可访问)
优先返回内存中的任务信息;
如果内存中不存在,则从 PostgreSQL + RustFS 组装一个持久化的任务视图,
结构与内存任务保持尽量一致,便于前端集中展示总结性信息。
"""
try:
await TaskQueryService.ensure_task_access(db_session, task_id, current_user.id)
task_view = await TaskQueryService.get_task_view(db_session, task_id)
if task_view is None:
raise HTTPException(404, "任务不存在")
+18 -2
View File
@@ -21,6 +21,19 @@ 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
@router.post("/upload")
async def upload_stp(
file: UploadFile = File(...),
@@ -75,6 +88,9 @@ async def upload_stp(
stp_file.id,
parameters=process_params,
)
# D9:create_processing_task 仅 flush,STPFile + 任务记录在此一并原子提交,
# 分派前置事务收口——分派出去的任务保证在 PG 中可见
await db_session.commit()
task_info = create_task_info(
task_id=task_id,
@@ -89,7 +105,7 @@ async def upload_stp(
task_info["file_hash"] = file_meta["sha256"]
await redis_task_manager.set_task(task_id, task_info)
dispatch_processing(task_id, str(file_path), stp_file.id, process_params)
dispatch_processing(task_id, stp_file.id, process_params)
return {
"task_id": task_id,
@@ -98,7 +114,7 @@ async def upload_stp(
"file_info": {
"filename": file.filename,
"size": file_size,
"pythonocc_available": True,
"pythonocc_available": _occ_available(),
"database_file_id": stp_file.id,
"sha256": file_meta["sha256"],
},
+111 -35
View File
@@ -3,14 +3,16 @@
import asyncio
import os
import shutil
import tempfile
import time
import traceback
from collections import OrderedDict
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
from typing import Optional, Dict, Any, List
from typing import Optional, Dict, Any, List, Tuple
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from moldinsight.core.stp_parser import STPParser
@@ -19,11 +21,13 @@ 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 moldinsight.storage.rustfs_storage import rustfs_manager
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.models.database import STPFile
from shared.database.database import db_manager
from shared.utils.html_generator import HTMLGenerator
from shared.utils.logger import get_logger
@@ -71,23 +75,73 @@ class ProcessingService:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(self._occ_executor, fn, *args)
async def _materialize_source_file(self, stp_file: STPFile) -> Tuple[Path, Optional[Path]]:
"""把待处理文件落到本地磁盘,返回 (本地路径, 临时目录或 None)。
RustFS 为主存储:处理方按 object_key 下载到任务专属临时目录
(文件名保留原始名——下游产物命名依赖 Path(file_path).name)。
RustFS 不可用或对象缺失时回退 STPFile.file_path 记录的节点本地路径
(依赖 compose 共享卷,属过渡方案);两者皆不可用则抛错置任务失败。
"""
original_name = Path(stp_file.original_filename or "model.stp").name or "model.stp"
if stp_file.object_key:
temp_dir = Path(tempfile.mkdtemp(prefix=f"moldinsight_{stp_file.id}_"))
try:
data = await rustfs_manager.download_file(
file_type="stp_files", object_key=stp_file.object_key
)
target = temp_dir / original_name
target.write_bytes(data)
return target, temp_dir
except Exception as exc:
shutil.rmtree(temp_dir, ignore_errors=True)
logger.warning(
f"RustFS 源文件下载失败 (object_key={stp_file.object_key}),"
f"回退节点本地路径: {exc}"
)
local = Path(stp_file.file_path) if stp_file.file_path else None
if local and local.exists():
return local, None
raise RuntimeError(
f"源文件不可用:RustFS 对象 {stp_file.object_key!r} 下载失败,"
f"且节点本地路径不存在: {stp_file.file_path!r}"
)
async def process_file_with_storage(
self,
task_id: str,
file_path: str,
stp_file_id: int,
process_params: Optional[Dict[str, Any]] = None,
):
"""处理文件的后台任务 — 使用独立数据库会话"""
"""处理文件的后台任务 — 使用独立数据库会话
分派入参只带 stp_file_id(D6):源文件由本方法按 PG 元数据中的
object_key 从 RustFS 获取,不再依赖分派方传入节点本地路径
(API 与 Celery worker 容器文件系统不互通)。
"""
# 创建独立的数据库会话,避免请求范围会话关闭
async with db_manager.session() as db_session:
temp_dir: Optional[Path] = None
try:
result = await db_session.execute(
select(STPFile).where(STPFile.id == stp_file_id)
)
stp_file = result.scalar_one_or_none()
if not stp_file:
raise RuntimeError(f"STPFile 记录不存在: stp_file_id={stp_file_id}")
source_path, temp_dir = await self._materialize_source_file(stp_file)
file_path = str(source_path)
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_bytes = source_path.stat().st_size
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)),
@@ -111,12 +165,16 @@ class ProcessingService:
except Exception as e:
logger.error(f"模具型腔生成失败: {e}")
# D9:先丢弃未提交的数据本体,失败状态单独提交,
# 避免 failed 更新把半成品 flush 数据一起带上
await db_session.rollback()
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 任务状态
# 安全更新 Redis 任务状态(Redis 仅热缓存,写失败不影响 PG 事实)
task = await redis_task_manager.get_task(task_id)
if task:
await redis_task_manager.update_task(task_id, {
@@ -124,6 +182,10 @@ class ProcessingService:
"error": str(e),
"completed_at": str(datetime.now()),
})
finally:
# 任务专属临时目录必须清理,长期运行不允许残留下载副本
if temp_dir:
shutil.rmtree(temp_dir, ignore_errors=True)
async def process_file_core(
self,
@@ -234,6 +296,10 @@ class ProcessingService:
geometry_data.get("analysis_method", "mold_cavity"),
)
# 阶段 A 提交(D9):几何 + 网格原子落库——解析后的确定成果,
# 后续型腔失败任务标 failed 时这些数据仍完整保留
await db_session.commit()
# 7. 生成HTML可视化
await self.storage_service.update_task_status(
db_session, task_id, "processing", 85, "生成可视化报告"
@@ -325,6 +391,11 @@ class ProcessingService:
),
)
# 9.65 阶段 B 提交(D9):型腔 / HTML / 特征 / 指标 / 摘要 / 验证指标
# 作为完整结果包原子落库——置 completed 前必须全部就位,
# 期间任一步失败回滚后任务标 failed,不会出现"completed 但数据残缺"
await db_session.commit()
# 9.7 FreeCAD 几何验证
stage_started = time.perf_counter()
verification_result = await self._step_verify(
@@ -347,11 +418,7 @@ class ProcessingService:
)
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, "模具型腔生成完成"
)
# 10. 完成处理——先 flush 任务参数,完成状态提交时一并原子落库(D9)
await self.storage_service.update_task_parameters(
db_session,
task_id,
@@ -364,6 +431,10 @@ class ProcessingService:
**process_params,
},
)
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, "模具型腔生成完成"
)
# 更新任务缓存状态(仅保留轻量摘要,完整数据由PG+RustFS持久化;
# 完成态视图由 TaskQueryService 从 PG+RustFS 组装,Redis 不再存
@@ -390,6 +461,9 @@ class ProcessingService:
except Exception as e:
logger.error(f"模具型腔生成失败: {e}")
# D9:先丢弃未提交的数据本体再置失败(同外层说明)
await db_session.rollback()
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)
@@ -471,29 +545,29 @@ class ProcessingService:
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
) -> Dict[str, Any]:
"""生成多方案分模结果。
D8:型腔是任务的核心产出,生成失败必须让任务 failed——
此前异常在此被吞掉置 plan_result=None 继续主流程,最终任务
completed,"完成"状态不可信。异常直接向编排层传播。
"""
if not shape:
raise RuntimeError("无有效几何 shape,无法生成模具型腔")
loop = asyncio.get_running_loop()
plan_result = await loop.run_in_executor(
self._occ_executor,
lambda: self.multi_scheme_planner.generate_plan(
shape=shape,
material=selected_material,
is_foam_material=is_foam_material,
process_params=process_params,
),
)
logger.info(
f"多方案分模完成: 生成 {len(plan_result.get('candidate_schemes', []))} 套方案"
)
return plan_result
def _cache_export_shapes(self, task_id: str, export_shapes: Dict[str, Dict[str, Any]]):
@@ -727,7 +801,8 @@ class ProcessingService:
)
session.add(metrics)
await session.commit()
# D9:flush 不 commit,随结果包(阶段 B)由编排层统一提交
await session.flush()
logger.info(f"分析指标保存成功: {metrics.id}")
async def _save_verification_metrics(self, session: AsyncSession, stp_file_id: int, verification_result: dict):
@@ -759,7 +834,8 @@ class ProcessingService:
)
session.add(metrics)
await session.commit()
# D9:flush 不 commit,随结果包(阶段 B)由编排层统一提交
await session.flush()
logger.info(f"验证指标保存成功: stp_file_id={stp_file_id}")
@@ -121,7 +121,8 @@ class StorageIntegrationService:
)
session.add(stp_file)
await session.commit()
# D9:仅 flush,与 ProcessingTask 由路由层一并原子提交(避免孤儿文件记录)
await session.flush()
await session.refresh(stp_file)
logger.info(f"STP文件保存成功 RustFS: {stp_file.id}, 批次: {batch_id}")
@@ -134,8 +135,11 @@ class StorageIntegrationService:
stp_file_id: int,
task_type: str = "stp_parsing",
parameters: Optional[Dict[str, Any]] = None,
batch_id: Optional[str] = None,
) -> ProcessingTask:
"""创建处理任务记录"""
"""创建处理任务记录(D9:仅 flush 不 commit,事务由调用方收口——
与 STPFile 记录同批提交,避免留下无任务的孤儿文件记录;batch_id 用于批量任务聚合查询)
"""
try:
task = ProcessingTask(
task_id=task_id,
@@ -144,15 +148,15 @@ class StorageIntegrationService:
status="pending",
started_time=datetime.now(),
parameters=parameters or {},
batch_id=batch_id,
)
session.add(task)
await session.commit()
await session.refresh(task)
await session.flush()
logger.info(f"处理任务创建成功: {task_id}")
return task
except Exception as e:
await session.rollback()
logger.error(f"创建处理任务失败: {e}")
@@ -167,7 +171,8 @@ class StorageIntegrationService:
current_step: Optional[str] = None,
error_message: Optional[str] = None
):
"""更新任务状态"""
"""更新任务状态(保留即时 commit:进度/状态需跨事务对外可见,
处理链路中的各阶段进度依赖它落库——D9 收口仅针对数据本体写方法)"""
try:
update_data = {
"status": status,
@@ -200,7 +205,7 @@ class StorageIntegrationService:
task_id: str,
parameters: Dict[str, Any],
):
"""合并更新任务参数,便于保存阶段耗时等元数据。"""
"""合并更新任务参数,便于保存阶段耗时等元数据。(D9:flush 不 commit,事务由调用方收口)"""
try:
task = await session.execute(
select(ProcessingTask).where(ProcessingTask.task_id == task_id)
@@ -212,14 +217,14 @@ class StorageIntegrationService:
merged = dict(task.parameters or {})
merged.update(parameters or {})
task.parameters = merged
await session.commit()
await session.flush()
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文件状态"""
"""更新STP文件状态(保留即时 commit,理由同 update_task_status)"""
try:
await session.execute(
update(STPFile)
@@ -280,7 +285,8 @@ class StorageIntegrationService:
)
session.add(geometry_data)
await session.commit()
# D9:数据本体仅 flush,与网格等同阶段数据由编排层统一 commit(原子落库)
await session.flush()
await session.refresh(geometry_data)
logger.info(f"几何数据保存成功 RustFS: {geometry_data.id}")
@@ -336,7 +342,8 @@ class StorageIntegrationService:
)
session.add(mesh_data)
await session.commit()
# D9:数据本体仅 flush,与几何数据同阶段由编排层统一 commit
await session.flush()
await session.refresh(mesh_data)
logger.info(f"网格数据保存成功 RustFS: {mesh_data.id}")
@@ -417,7 +424,8 @@ class StorageIntegrationService:
)
session.add(mold_cavity)
await session.commit()
# D9:数据本体仅 flush,型腔/HTML/特征同属结果包,由编排层统一 commit
await session.flush()
await session.refresh(mold_cavity)
logger.info(f"模具型腔数据保存成功 RustFS: {mold_cavity.id}")
@@ -466,7 +474,8 @@ class StorageIntegrationService:
)
session.add(html_file)
await session.commit()
# D9:数据本体仅 flush,型腔/HTML/特征同属结果包,由编排层统一 commit
await session.flush()
await session.refresh(html_file)
logger.info(f"HTML文件保存成功 RustFS: {html_file.id}")
@@ -504,7 +513,8 @@ class StorageIntegrationService:
)
session.add(rec_record)
await session.commit()
# D9:数据本体仅 flush,型腔/HTML/特征同属结果包,由编排层统一 commit
await session.flush()
logger.info(f"保存了 {len(features)} 个特征和 {len(recommendations)} 个建议")
async def log_user_activity(self, session: AsyncSession,
@@ -798,7 +808,8 @@ class StorageIntegrationService:
.where(STPFile.id == stp_file_id)
.values(**update_data)
)
await session.commit()
# D9:flush 不 commit,随结果包由编排层统一提交
await session.flush()
logger.info(f"STP文件分析摘要更新: ID {stp_file_id}")
except Exception as e:
+11 -6
View File
@@ -28,23 +28,28 @@ _background_tasks: set = set()
_dispatch_semaphore = asyncio.Semaphore(2)
async def _run_with_limit(task_id: str, file_path: str, stp_file_id: int, process_params: dict):
async def _run_with_limit(task_id: str, stp_file_id: int, process_params: dict):
async with _dispatch_semaphore:
from moldinsight.services.processing_service import processing_service
await processing_service.process_file_with_storage(
task_id, file_path, stp_file_id, process_params
task_id, stp_file_id, process_params
)
def dispatch_processing(task_id: str, file_path: str, stp_file_id: int, process_params: dict):
"""调度 STP 处理任务:优先 Celery(进程隔离),否则 API 进程内 asyncio 后台执行。"""
def dispatch_processing(task_id: str, stp_file_id: int, process_params: dict):
"""调度 STP 处理任务:优先 Celery(进程隔离),否则 API 进程内 asyncio 后台执行。
入参只传 stp_file_id(D6):源文件由处理方按 PG 元数据从 RustFS 获取,
不再跨进程传节点本地路径——API 与 Celery worker 容器文件系统不互通,
传路径在容器化部署下必然失败。
"""
if _use_celery:
process_stp_task.delay(task_id, file_path, stp_file_id, process_params)
process_stp_task.delay(task_id, stp_file_id, process_params)
logger.info(f"[DISPATCH] Celery 任务已调度: task_id={task_id}")
return
task = asyncio.create_task(
_run_with_limit(task_id, file_path, stp_file_id, process_params)
_run_with_limit(task_id, stp_file_id, process_params)
)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
+38 -5
View File
@@ -5,6 +5,7 @@ import time
from collections import OrderedDict
from typing import Optional, Dict, Any, List, Tuple
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
@@ -55,6 +56,30 @@ class TaskQueryService:
"""任务 parameters 被更新后调用(export-mold / cam 等),使缓存视图失效。"""
cls._view_cache.pop(task_id, None)
@staticmethod
async def ensure_task_access(
db_session: AsyncSession, task_id: str, user_id: int
) -> "Tuple[ProcessingTask, STPFile]":
"""校验任务存在且属于指定用户:不存在 404,他人/无主任务 403(无主不等于公共)。
task_router(状态查询)与 advanced_router(导出/倒扣检测等)共用,
之前只有 advanced_router 有一份私有实现,/api/status 曾因此漏鉴权。
"""
row = await db_session.execute(
select(ProcessingTask, STPFile)
.join(STPFile, ProcessingTask.stp_file_id == STPFile.id)
.where(ProcessingTask.task_id == task_id)
)
row = row.first()
if not row:
raise HTTPException(404, "任务不存在")
_, stp_file = row
if getattr(stp_file, "user_id", None) != user_id:
raise HTTPException(403, "无权访问该任务")
return row
@staticmethod
async def get_task_view(db_session: AsyncSession, task_id: str) -> Optional[Dict[str, Any]]:
"""
@@ -128,9 +153,17 @@ class TaskQueryService:
cam_preferences = processing_task.parameters.get("cam_preferences", {}) or {}
task_parameters = dict(processing_task.parameters)
# 注意:analysis_metrics 键可能存在但值为 None(storage 未上传指标时),
# .get(key, {}) 的默认值对 None 不生效,必须用 or {} 兜底
analysis_metrics = file_with_data.get("analysis_metrics") or {}
task_view = {
"task_id": processing_task.task_id,
"status": processing_task.status,
# D7:PG 是单一事实源——Redis 不可用时本视图即前端拿到的完整状态,
# 进度字段必须从 PG 补齐(Redis 路径的 task dict 也会带同名字段)
"progress": processing_task.progress or 0,
"current_step": processing_task.current_step,
"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,
@@ -154,18 +187,18 @@ class TaskQueryService:
"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"),
or 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)
"volume_utilization": analysis_metrics.get("volume_utilization", 0),
"topology_complexity": analysis_metrics.get("topology_complexity", 0),
"wall_uniformity": analysis_metrics.get("wall_uniformity", 0)
},
"analysis_summary": file_with_data.get("analysis_metrics", {}).get("analysis_summary", "分析完成")
"analysis_summary": 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,
}
+14
View File
@@ -36,6 +36,20 @@ class RustFSManager:
async def connect(self, endpoint: str, access_key: str, secret_key: str, timeout: int = 30):
"""连接到 RustFS 服务"""
# 配置缺失时给出明确错误(settings 不再给占位默认值)
missing = [
name for name, value in (
("RUSTFS_ENDPOINT", endpoint),
("RUSTFS_ACCESS_KEY", access_key),
("RUSTFS_SECRET_KEY", secret_key),
) if not value
]
if missing:
self.is_connected = False
raise ValueError(
f"RustFS 配置缺失: {', '.join(missing)}(请参照 .env.example 配置后重启)"
)
try:
# 提取端口号和主机
from urllib.parse import urlparse