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
+3 -4
View File
@@ -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:
+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
+9 -3
View File
@@ -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"))
+14 -1
View File
@@ -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)
+4
View File
@@ -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
+10 -2
View File
@@ -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} 的密码")
+19 -4
View File
@@ -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 字段")
+96 -138
View File
@@ -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
# ---- 工具方法 ----