Files
geMoldInsight/src/moldinsight/api/batch_router.py
T
cjw 0e6b3b1811 后端设计治理:批次 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>
2026-09-17 16:15:49 +08:00

208 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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
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.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 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()
# 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")
async def batch_upload(
files: List[UploadFile] = File(...),
material: str = Form("ABS"),
draft_angle: float = Form(2.0),
shrinkage_rate: float = Form(0.5),
parting_precision: float = Form(0.1),
cavity_match: int = Form(95),
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
"""批量上传多个 STP 文件,每个文件创建独立分析任务,用 batch_id 聚合。"""
if not files:
raise HTTPException(400, "请至少上传一个文件")
if len(files) > 20:
raise HTTPException(400, "单次批量上传最多 20 个文件")
process_params = {
"material": material,
"draft_angle": float(draft_angle),
"shrinkage_rate": float(shrinkage_rate),
"parting_precision": float(parting_precision),
"cavity_match": int(cavity_match),
}
batch_id = str(uuid.uuid4())
tasks: List[Dict[str, Any]] = []
storage_service = TaskStorageService()
for file in files:
# 文件类型检查
if not file.filename.lower().endswith(('.stp', '.step')):
tasks.append({
"filename": file.filename,
"task_id": None,
"status": "rejected",
"error": "不支持的文件类型",
})
continue
task_id = str(uuid.uuid4())
try:
file_path, file_size, file_meta = await file_handler.save_uploaded_file(file)
stp_file = await storage_service.save_stp_file(
session=db_session,
file_path=file_path,
original_filename=file_meta["safe_original_name"],
user_id=current_user.id,
)
await storage_service.create_processing_task(
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,
status=ProcessingStatus.PROCESSING,
filename=file.filename,
file_path=str(file_path),
file_size=file_size,
upload_time=str(datetime.now()),
)
task_info["material"] = material
task_info["parameters"] = process_params
task_info["batch_id"] = batch_id
await redis_task_manager.set_task(task_id, task_info)
# 调度处理
dispatch_processing(task_id, stp_file.id, process_params)
tasks.append({
"filename": file.filename,
"task_id": task_id,
"status": "processing",
"stp_file_id": stp_file.id,
})
logger.info(
f"[BATCH] batch_id={batch_id} task_id={task_id} "
f"file={file.filename} user={current_user.username}"
)
except Exception as exc:
logger.warning(f"[BATCH] 文件 {file.filename} 上传失败: {exc}")
tasks.append({
"filename": file.filename,
"task_id": task_id,
"status": "error",
"error": str(exc),
})
return {
"batch_id": batch_id,
"total": len(tasks),
"accepted": sum(1 for t in tasks if t.get("status") != "rejected"),
"tasks": tasks,
}
@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),
):
"""聚合查询批量任务进度(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 any(getattr(stp, "user_id", None) != current_user.id for _, stp in rows):
raise HTTPException(403, "无权访问该批量任务")
task_statuses = []
completed = 0
failed = 0
processing = 0
earliest_created = None
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
elif status == ProcessingStatus.FAILED:
failed += 1
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": task.task_id,
"status": status,
"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(rows)
return {
"batch_id": batch_id,
"created_at": earliest_created.isoformat() if earliest_created else None,
"total": total,
"completed": completed,
"failed": failed,
"processing": processing,
"progress_percent": round((completed + failed) / max(total, 1) * 100, 1),
"tasks": task_statuses,
}