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"],
},