优化
This commit is contained in:
+3
-4
@@ -7,9 +7,8 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
@app.task(bind=True, max_retries=1, default_retry_delay=60)
|
||||
def process_stp_task(self, task_id: str, file_path: str, stp_file_id: int,
|
||||
process_params: dict):
|
||||
"""Celery 任务:异步处理 STP 文件生成模具型腔"""
|
||||
def process_stp_task(self, task_id: str, stp_file_id: int, process_params: dict):
|
||||
"""Celery 任务:异步处理 STP 文件生成模具型腔(源文件按 stp_file_id 从 RustFS 获取)"""
|
||||
import asyncio
|
||||
|
||||
async def _run():
|
||||
@@ -34,7 +33,7 @@ def process_stp_task(self, task_id: str, file_path: str, stp_file_id: int,
|
||||
await db_manager.connect(role="celery")
|
||||
|
||||
await processing_service.process_file_with_storage(
|
||||
task_id, file_path, stp_file_id, process_params
|
||||
task_id, stp_file_id, process_params
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -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 已变更,缓存视图失效
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, "任务不存在")
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,9 +23,11 @@ class Settings:
|
||||
self.MESH_QUALITY = os.getenv("MESH_QUALITY", "high")
|
||||
self.PARALLEL_PROCESSING = os.getenv("PARALLEL_PROCESSING", "true").lower() == "true"
|
||||
|
||||
self.RUSTFS_ENDPOINT = os.getenv("RUSTFS_ENDPOINT") or os.getenv("MINIO_ENDPOINT") or "http://localhost:8080"
|
||||
self.RUSTFS_ACCESS_KEY = os.getenv("RUSTFS_ACCESS_KEY") or os.getenv("MINIO_ACCESS_KEY") or "your-access-key"
|
||||
self.RUSTFS_SECRET_KEY = os.getenv("RUSTFS_SECRET_KEY") or os.getenv("MINIO_SECRET_KEY") or "your-secret-key"
|
||||
# RUSTFS_* 不给代码兜底默认值(含 MINIO_* 兼容别名):
|
||||
# 缺失时由 rustfs_storage.connect 抛出明确配置错误,而不是拿占位口令连库
|
||||
self.RUSTFS_ENDPOINT = os.getenv("RUSTFS_ENDPOINT") or os.getenv("MINIO_ENDPOINT")
|
||||
self.RUSTFS_ACCESS_KEY = os.getenv("RUSTFS_ACCESS_KEY") or os.getenv("MINIO_ACCESS_KEY")
|
||||
self.RUSTFS_SECRET_KEY = os.getenv("RUSTFS_SECRET_KEY") or os.getenv("MINIO_SECRET_KEY")
|
||||
self.RUSTFS_TIMEOUT = int(os.getenv("RUSTFS_TIMEOUT", "30"))
|
||||
self.RUSTFS_PRESIGNED_URL_EXPIRES = int(os.getenv("RUSTFS_PRESIGNED_URL_EXPIRES", "3600"))
|
||||
|
||||
@@ -36,6 +38,10 @@ class Settings:
|
||||
self.DB_USER = os.getenv("DB_USER")
|
||||
self.DB_PASSWORD = os.getenv("DB_PASSWORD")
|
||||
|
||||
# 启动时是否自动执行 alembic 迁移(D12):多副本同时启动会并发迁移,
|
||||
# 生产多副本应设 false,改由部署流程单点执行 alembic CLI 或本模块 __main__
|
||||
self.AUTO_MIGRATE = os.getenv("AUTO_MIGRATE", "true").lower() == "true"
|
||||
|
||||
self.SECRET_KEY = os.getenv("SECRET_KEY")
|
||||
self.ALGORITHM = os.getenv("ALGORITHM", "HS256")
|
||||
self.ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "1440"))
|
||||
|
||||
@@ -118,6 +118,13 @@ async def init_roles(session, perm_map):
|
||||
|
||||
async def create_admin_user(session):
|
||||
"""创建默认管理员"""
|
||||
# compose 不再给 ADMIN_PASSWORD 弱默认(D14):缺失时显式失败,
|
||||
# 而不是静默创建空口令管理员
|
||||
if not settings.ADMIN_PASSWORD:
|
||||
raise RuntimeError(
|
||||
"ADMIN_PASSWORD 未配置:请在 .env 中设置管理员初始密码后重启"
|
||||
)
|
||||
|
||||
result = await session.execute(select(User).where(User.username == settings.ADMIN_USERNAME))
|
||||
existing_admin = result.scalar_one_or_none()
|
||||
|
||||
@@ -150,7 +157,13 @@ async def init_database(keep_connected: bool = True):
|
||||
"""初始化数据库"""
|
||||
try:
|
||||
await db_manager.connect()
|
||||
await _run_alembic_migrations()
|
||||
if settings.AUTO_MIGRATE:
|
||||
await _run_alembic_migrations()
|
||||
else:
|
||||
logger.info(
|
||||
"AUTO_MIGRATE=false:跳过启动期 alembic 迁移,"
|
||||
"schema 由部署流程单点执行(alembic CLI 或 python -m shared.database.init_db)"
|
||||
)
|
||||
|
||||
async with db_manager.session() as session:
|
||||
perm_map = await init_permissions(session)
|
||||
|
||||
@@ -282,6 +282,10 @@ class ProcessingTask(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
task_id = Column(String(36), unique=True, index=True, nullable=False)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 批量上传聚合 ID(批次 2:批量元数据入库——PG 为单一事实源,
|
||||
# 同批任务经此列聚合查询,不再依赖 Redis/进程内存存批量元数据)
|
||||
batch_id = Column(String(36), nullable=True, index=True)
|
||||
|
||||
# 任务类型和状态
|
||||
task_type = Column(String(50), default="stp_parsing") # stp_parsing, geometry_analysis, mold_generation
|
||||
|
||||
@@ -212,10 +212,15 @@ async def create_user(
|
||||
if existing_email.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="邮箱已存在")
|
||||
|
||||
try:
|
||||
hashed_password = get_password_hash(user_data.password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
user = User(
|
||||
username=user_data.username,
|
||||
email=user_data.email,
|
||||
hashed_password=get_password_hash(user_data.password),
|
||||
hashed_password=hashed_password,
|
||||
full_name=user_data.full_name,
|
||||
is_active=True
|
||||
)
|
||||
@@ -313,7 +318,10 @@ async def reset_user_password(
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
user.hashed_password = get_password_hash(new_password)
|
||||
try:
|
||||
user.hashed_password = get_password_hash(new_password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
await db_session.commit()
|
||||
|
||||
logger.info(f"管理员 {current_user.username} 重置了用户 {user.username} 的密码")
|
||||
|
||||
@@ -20,13 +20,28 @@ pwd_context = bcrypt
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
||||
|
||||
|
||||
def _require_secret_key() -> str:
|
||||
"""SECRET_KEY 惰性校验:未配置时给出明确错误,而不是让 jwt.encode/decode 报晦涩 TypeError。"""
|
||||
if not settings.SECRET_KEY:
|
||||
raise RuntimeError("SECRET_KEY 未配置:请在 .env 中设置后重启服务(认证功能不可用)")
|
||||
return settings.SECRET_KEY
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
|
||||
# 比较侧按 bcrypt 语义截断到 72 字节:兼容历史上被截断存储的口令,
|
||||
# 且避免 checkpw 对超长输入直接抛 ValueError(登录会变 500);
|
||||
# 新口令的超长拒绝在 get_password_hash 中完成
|
||||
password_bytes = plain_password.encode('utf-8')[:72]
|
||||
try:
|
||||
return pwd_context.checkpw(password_bytes, hashed_password.encode('utf-8'))
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
# bcrypt 算法上限 72 字节:超长密码必须显式拒绝,静默截断会改变有效密码
|
||||
if len(password.encode('utf-8')) > 72:
|
||||
password = password[:72]
|
||||
raise ValueError("密码长度超过 72 字节限制,请使用更短的密码")
|
||||
return pwd_context.hashpw(password.encode('utf-8'), pwd_context.gensalt()).decode('utf-8')
|
||||
|
||||
|
||||
@@ -37,7 +52,7 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
encoded_jwt = jwt.encode(to_encode, _require_secret_key(), algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
@@ -49,7 +64,7 @@ async def get_current_user(
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
payload = jwt.decode(token, _require_secret_key(), algorithms=[settings.ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
logger.warning(f"[AUTH] Token 中缺少 sub 字段")
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
# services/redis_task_manager.py
|
||||
"""Redis 任务管理器 - 替代内存字典,支持 TTL 自动清理。
|
||||
"""Redis 任务管理器 - 任务状态热缓存(D7:不再有进程内存回退)。
|
||||
|
||||
存储格式:Redis Hash(field -> JSON 字符串)。
|
||||
- update_task 走 HSET 字段级原子更新,消除旧 get->merge->set 三步竞态
|
||||
(后台处理流程与导出端点并发写同一任务时丢更新);
|
||||
- 进度 tick 只重写变化字段,不再全量重写整个任务 blob;
|
||||
- 兼容读旧 string 格式(升级前写入的在途任务),新写入一律 Hash。
|
||||
- 兼容读旧 string 格式(升级前写入的在途任务),新写入一律 Hash;
|
||||
- **PG 是任务状态单一事实源**:Redis 不可用时本管理器不再降级进程内 dict
|
||||
(多副本下各进程内存互相不可见,造成同一任务不同副本读到不同状态),
|
||||
而是 no-op / 返回 None——状态查询路径(TaskQueryService)自然落到 PG。
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -101,24 +104,6 @@ class RedisTaskManager:
|
||||
raise RuntimeError("Redis 未连接,无法直接访问 redis_client")
|
||||
return self._redis
|
||||
|
||||
# ---- 内存回退 ----
|
||||
_fallback_tasks: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def _fallback_set(self, task_id: str, data: Dict[str, Any]):
|
||||
self._fallback_tasks[task_id] = data
|
||||
|
||||
def _fallback_get(self, task_id: str) -> Optional[Dict[str, Any]]:
|
||||
return self._fallback_tasks.get(task_id)
|
||||
|
||||
def _fallback_delete(self, task_id: str):
|
||||
self._fallback_tasks.pop(task_id, None)
|
||||
|
||||
def _fallback_all(self) -> Dict[str, Dict[str, Any]]:
|
||||
return dict(self._fallback_tasks)
|
||||
|
||||
def _fallback_count(self) -> int:
|
||||
return len(self._fallback_tasks)
|
||||
|
||||
# ---- 内部工具 ----
|
||||
|
||||
def _key(self, task_id: str) -> str:
|
||||
@@ -161,143 +146,116 @@ class RedisTaskManager:
|
||||
# ---- 公共接口 ----
|
||||
|
||||
async def set_task(self, task_id: str, data: Dict[str, Any], ttl: Optional[int] = None):
|
||||
"""整包写入任务数据(Hash,覆盖旧值,含旧 string 格式清理)"""
|
||||
"""整包写入任务数据(Hash,覆盖旧值,含旧 string 格式清理)。
|
||||
|
||||
Redis 不可用时 no-op:任务状态事实源在 PG,缓存缺失不影响正确性。
|
||||
"""
|
||||
if not self.is_connected:
|
||||
return
|
||||
|
||||
effective_ttl = ttl or self._ttl
|
||||
mapping = self._dump_mapping(data)
|
||||
|
||||
if self.is_connected:
|
||||
try:
|
||||
key = self._key(task_id)
|
||||
# DEL 先清掉可能存在的旧 string/Hash,保证覆盖语义
|
||||
pipe = self._redis.pipeline()
|
||||
pipe.delete(key)
|
||||
pipe.hset(key, mapping=mapping)
|
||||
pipe.expire(key, effective_ttl)
|
||||
await pipe.execute()
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 写入失败,回退到内存: {e}")
|
||||
|
||||
self._fallback_set(task_id, self._make_serializable(data))
|
||||
try:
|
||||
key = self._key(task_id)
|
||||
# DEL 先清掉可能存在的旧 string/Hash,保证覆盖语义
|
||||
pipe = self._redis.pipeline()
|
||||
pipe.delete(key)
|
||||
pipe.hset(key, mapping=mapping)
|
||||
pipe.expire(key, effective_ttl)
|
||||
await pipe.execute()
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 写入失败(任务状态以 PG 为准): task={task_id}, {e}")
|
||||
|
||||
async def get_task(self, task_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取任务数据(Hash / 旧 string 兼容)"""
|
||||
if self.is_connected:
|
||||
try:
|
||||
return await self._load_any(self._key(task_id))
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 读取失败,回退到内存: {e}")
|
||||
"""获取任务数据(Hash / 旧 string 兼容)。
|
||||
|
||||
return self._fallback_get(task_id)
|
||||
Redis 不可用 / 未命中返回 None,调用方落到 PG 路径。
|
||||
"""
|
||||
if not self.is_connected:
|
||||
return None
|
||||
try:
|
||||
return await self._load_any(self._key(task_id))
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 读取失败(任务状态以 PG 为准): task={task_id}, {e}")
|
||||
return None
|
||||
|
||||
async def update_task(self, task_id: str, updates: Dict[str, Any]):
|
||||
"""字段级原子更新(HSET),无读改写竞态。
|
||||
|
||||
兼容旧 string 格式:先迁移为 Hash 再更新。
|
||||
Redis 不可用时 no-op(状态事实源在 PG)。
|
||||
"""
|
||||
mapping = self._dump_mapping(updates)
|
||||
|
||||
if self.is_connected:
|
||||
try:
|
||||
key = self._key(task_id)
|
||||
key_type = await self._redis.type(key)
|
||||
|
||||
if key_type == "none":
|
||||
logger.warning(f"任务 {task_id} 不存在,无法更新")
|
||||
return
|
||||
|
||||
if key_type == "string":
|
||||
# 旧格式迁移:string -> Hash
|
||||
legacy = await self._redis.get(key)
|
||||
try:
|
||||
base = json.loads(legacy) if legacy else {}
|
||||
except json.JSONDecodeError:
|
||||
base = {}
|
||||
base.update(mapping)
|
||||
pipe = self._redis.pipeline()
|
||||
pipe.delete(key)
|
||||
pipe.hset(key, mapping=self._dump_mapping(base))
|
||||
pipe.expire(key, self._ttl)
|
||||
await pipe.execute()
|
||||
return
|
||||
|
||||
await self._redis.hset(key, mapping=mapping)
|
||||
await self._redis.expire(key, self._ttl)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 更新失败,回退到内存: {e}")
|
||||
|
||||
# 内存回退保持读改写语义(单进程内存无并发竞态)
|
||||
current = self._fallback_get(task_id)
|
||||
if current is None:
|
||||
logger.warning(f"任务 {task_id} 不存在,无法更新")
|
||||
if not self.is_connected:
|
||||
return
|
||||
|
||||
current.update(self._make_serializable(updates))
|
||||
self._fallback_set(task_id, current)
|
||||
mapping = self._dump_mapping(updates)
|
||||
try:
|
||||
key = self._key(task_id)
|
||||
key_type = await self._redis.type(key)
|
||||
|
||||
if key_type == "none":
|
||||
logger.warning(f"任务 {task_id} 不存在,无法更新")
|
||||
return
|
||||
|
||||
if key_type == "string":
|
||||
# 旧格式迁移:string -> Hash
|
||||
legacy = await self._redis.get(key)
|
||||
try:
|
||||
base = json.loads(legacy) if legacy else {}
|
||||
except json.JSONDecodeError:
|
||||
base = {}
|
||||
base.update(mapping)
|
||||
pipe = self._redis.pipeline()
|
||||
pipe.delete(key)
|
||||
pipe.hset(key, mapping=self._dump_mapping(base))
|
||||
pipe.expire(key, self._ttl)
|
||||
await pipe.execute()
|
||||
return
|
||||
|
||||
await self._redis.hset(key, mapping=mapping)
|
||||
await self._redis.expire(key, self._ttl)
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 更新失败(任务状态以 PG 为准): task={task_id}, {e}")
|
||||
|
||||
async def delete_task(self, task_id: str):
|
||||
"""删除任务(DEL 对 Hash/string 均有效)"""
|
||||
if self.is_connected:
|
||||
try:
|
||||
await self._redis.delete(self._key(task_id))
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 删除失败,回退到内存: {e}")
|
||||
|
||||
self._fallback_delete(task_id)
|
||||
"""删除任务(DEL 对 Hash/string 均有效);Redis 不可用时 no-op"""
|
||||
if not self.is_connected:
|
||||
return
|
||||
try:
|
||||
await self._redis.delete(self._key(task_id))
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 删除失败: task={task_id}, {e}")
|
||||
|
||||
async def get_all_tasks(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""获取所有任务"""
|
||||
if self.is_connected:
|
||||
try:
|
||||
pattern = f"{self._prefix}*"
|
||||
result = {}
|
||||
async for key in self._redis.scan_iter(match=pattern):
|
||||
task_id = key.replace(self._prefix, "")
|
||||
task = await self._load_any(key)
|
||||
if task:
|
||||
result[task_id] = task
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 扫描失败,回退到内存: {e}")
|
||||
|
||||
return self._fallback_all()
|
||||
"""获取所有任务;Redis 不可用时返回空 dict(调用方需容忍)"""
|
||||
if not self.is_connected:
|
||||
return {}
|
||||
try:
|
||||
pattern = f"{self._prefix}*"
|
||||
result = {}
|
||||
async for key in self._redis.scan_iter(match=pattern):
|
||||
task_id = key.replace(self._prefix, "")
|
||||
task = await self._load_any(key)
|
||||
if task:
|
||||
result[task_id] = task
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 扫描失败: {e}")
|
||||
return {}
|
||||
|
||||
async def get_task_count(self) -> int:
|
||||
"""获取任务总数"""
|
||||
if self.is_connected:
|
||||
try:
|
||||
pattern = f"{self._prefix}*"
|
||||
count = 0
|
||||
async for _ in self._redis.scan_iter(match=pattern):
|
||||
count += 1
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 计数失败,回退到内存: {e}")
|
||||
|
||||
return self._fallback_count()
|
||||
|
||||
async def cleanup_old_tasks(self, max_age_seconds: int = 86400 * 7):
|
||||
"""清理过期任务(Redis 由 TTL 自动管理,内存回退需手动清理)"""
|
||||
now = datetime.now()
|
||||
to_delete = []
|
||||
|
||||
for task_id, task in self._fallback_tasks.items():
|
||||
completed_at = task.get("completed_at")
|
||||
if completed_at:
|
||||
try:
|
||||
completed_dt = datetime.fromisoformat(completed_at)
|
||||
if (now - completed_dt).total_seconds() > max_age_seconds:
|
||||
to_delete.append(task_id)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
for task_id in to_delete:
|
||||
del self._fallback_tasks[task_id]
|
||||
|
||||
if to_delete:
|
||||
logger.info(f"清理了 {len(to_delete)} 个过期内存任务")
|
||||
"""获取任务总数;Redis 不可用时返回 0(调用方需容忍)"""
|
||||
if not self.is_connected:
|
||||
return 0
|
||||
try:
|
||||
pattern = f"{self._prefix}*"
|
||||
count = 0
|
||||
async for _ in self._redis.scan_iter(match=pattern):
|
||||
count += 1
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 计数失败: {e}")
|
||||
return 0
|
||||
|
||||
# ---- 工具方法 ----
|
||||
|
||||
|
||||
Reference in New Issue
Block a user