批次4后续专项完成:D11清偿 + OCC方案B实施 + 部署参数 + D2诚实标注 + CI门禁

① D11 HTML 报告 RustFS 单源化(TECH_DEBT P2 清偿):可视化产物写任务临时目录后
   裸传报告键 html/reports/{filename}(文件名寻址),/html StaticFiles 挂载删除,
   新增 html_report_router 根路径代理(报告键→遗留 JSON 包装→本地卷兜底→404,
   防穿越);URL 形状 /html/{filename} 不变,持久化引用零迁移;celery 摘除
   html_data 卷,镜像不再烤入陈旧报告;顺带删除 get_stp_file_with_data 死数据块
② OCC 方案 B(D10 清偿):run_occ(op_name, payload) 契约 + 常驻工作进程池
   (occ_process_pool + occ_worker 操作注册表),超时/崩溃 terminate 换新补位、
   任务级超时 recover 整体重建,残留线程泄漏根治;TopoDS 不跨进程(generate_cavity
   分模 + 方案 STEP 持久化全在子进程内,返回 export_manifest);删除内存形状缓存链、
   CADExporter.export_mold_results、shape_loader(→ stp_materializer)
③ OCC 方案 A 部署参数:CELERY_CONCURRENCY / CELERY_MAX_TASKS_PER_CHILD 进
   Dockerfile.celery + compose + .env.example
④ D2 诚实标注:铝价响应带 source: "simulated",前端按来源渲染标注(原硬编码
   "上海期货交易所"属虚假声明),死代码 getAluminumPrice 删除
⑤ CI 门禁:.gitea/workflows/ci.yml 三 job(pytest / 前端构建含 vue-tsc /
   openapi 漂移检测)

接口变更三件套随批完成(openapi 76→77 paths + gen:api + 前端构建通过;方案 B
接口面零变化)。测试基线 143 passed, 0 skipped(新增 16 项)。文档六处同步。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-18 17:22:01 +08:00
parent 0e6b3b1811
commit e728dcd226
36 changed files with 1474 additions and 565 deletions
+2 -1
View File
@@ -4,7 +4,8 @@
- 装载失败返回 None 且不缓存失败(与原实现一致,端点统一 503「服务不可用」)
- 实例缓存:设计/加工模块为纯 Python 计算(构造后无 self 突变,方法仅读入参),
可安全地被 asyncio.to_thread 并发调用;OCC 相关的 side_action_designer
必须经 processing_service.run_occ 的单线程 executor 使用
经 processing_service.run_occ 的常驻 OCC 进程池使用(方案 B,见
docs/topics/performance/OCC_THROUGHPUT.md)
"""
import threading
from typing import Optional
+17 -13
View File
@@ -3,8 +3,8 @@
- 请求体一律 Pydantic 模型(原 request.json() 手动解析退役,校验失败统一 422)
- 纯 Python 设计计算统一经 asyncio.to_thread 投放线程池,不阻塞事件循环;
OCC 相关的倒扣检测仍走 processing_service.run_occ 的单线程 executor
(PythonOCC 非线程安全)
OCC 相关的倒扣检测经 processing_service.run_occ 的常驻 OCC 进程池
(PythonOCC 非线程安全,进程内串行;见 OCC_THROUGHPUT.md 方案 B)
"""
import asyncio
from typing import List, Optional
@@ -165,17 +165,21 @@ async def detect_undercuts(
if not sd:
raise HTTPException(503, "服务不可用:核心模块未加载")
# 从持久化 STP 原件重建几何(此前传 shape=None 会被兜底吞掉,永远返回"无倒扣")
from moldinsight.services.shape_loader import get_shape_loader
shape = await get_shape_loader().load_shape_for_task(db_session, body.task_id)
if shape is None:
# 方案 B:从持久化 STP 原件落盘,倒扣分析在常驻 OCC 子进程内完成(形状不跨进程)
from moldinsight.services.stp_materializer import get_stp_materializer
stp_path = await get_stp_materializer().materialize_stp_for_task(db_session, body.task_id)
if stp_path is None:
raise HTTPException(410, "任务几何不可用:无法从存储重建 STP 形状,请重新上传分析")
# OCC 操作必须走单线程 executor(PythonOCC 非线程安全)
result = await processing_service.run_occ(
sd.analyze_and_design,
shape,
body.parting_direction,
body.mold_size.model_dump(),
)
try:
result = await processing_service.run_occ(
"detect_undercuts",
{
"stp_path": str(stp_path),
"parting_direction": body.parting_direction,
"mold_size": body.mold_size.model_dump(),
},
)
finally:
stp_path.unlink(missing_ok=True)
return {"status": "success", "data": result}
+35 -66
View File
@@ -178,76 +178,45 @@ async def export_mold_results(
},
}
cavity_shapes = processing_service.get_export_shapes(
task_id,
resolved_scheme_id,
)
# 方案 B 后主进程不再持有内存形状缓存(get_export_shapes 已删除):
# 未命中持久化清单时,从持久化单组件 STEP 现场转换缺失格式,用户无需重新分析
filename = task_data.get("filename", f"mold_{task_id}")
if not cavity_shapes:
# 内存 shape 缓存失效(如服务重启):从持久化的单组件 STEP
# 现场转换缺失格式,用户无需重新分析
artifacts = _get_export_artifacts(task_data)
scheme_data = (artifacts.get("schemes") or {}).get(resolved_scheme_id)
if scheme_data:
base_filename = scheme_data.get("base_filename") or Path(filename).stem
regenerated = await processing_service.regenerate_export_from_persisted(
task_id=task_id,
scheme_id=resolved_scheme_id,
formats=formats,
components=_expand_components(components),
base_filename=base_filename,
scheme_files=scheme_data.get("files", []),
)
if regenerated:
regenerated["files"] = _augment_export_files(
task_id, regenerated.get("files", [])
)
# 合并进持久化 manifest,后续请求直接命中持久化路径
merged_artifacts = _merge_export_artifacts(artifacts, regenerated)
await TaskStorageService().update_task_parameters(
db_session,
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)
return {"status": "success", "data": regenerated}
raise HTTPException(
409,
"导出缓存已失效或任务尚未完成,请重新分析后再导出以保证方案一致性",
artifacts = _get_export_artifacts(task_data)
scheme_data = (artifacts.get("schemes") or {}).get(resolved_scheme_id)
if scheme_data:
base_filename = scheme_data.get("base_filename") or Path(filename).stem
regenerated = await processing_service.regenerate_export_from_persisted(
task_id=task_id,
scheme_id=resolved_scheme_id,
formats=formats,
components=_expand_components(components),
base_filename=base_filename,
scheme_files=scheme_data.get("files", []),
)
if regenerated:
regenerated["files"] = _augment_export_files(
task_id, regenerated.get("files", [])
)
# 合并进持久化 manifest,后续请求直接命中持久化路径
merged_artifacts = _merge_export_artifacts(artifacts, regenerated)
await TaskStorageService().update_task_parameters(
db_session,
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)
base_filename = Path(filename).stem
result = cad_exporter.export_mold_results(
cavity_data=cavity_shapes,
base_filename=base_filename,
formats=formats,
components=components,
task_id=task_id,
scheme_id=resolved_scheme_id,
return {"status": "success", "data": regenerated}
raise HTTPException(
409,
"导出文件不可用:任务未生成持久化导出清单,请重新分析后再导出",
)
result["files"] = _augment_export_files(task_id, result.get("files", []))
result["source"] = "generated"
merged_artifacts = _merge_export_artifacts(_get_export_artifacts(task_data), result)
await TaskStorageService().update_task_parameters(
db_session,
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 已变更,缓存视图失效
return {"status": "success", "data": result}
@router.get("/export-download/{filepath:path}")
+139
View File
@@ -0,0 +1,139 @@
# api/html_report_router.py
"""HTML 可视化报告读取代理(D11)。
报告产物唯一持久来源是 RustFS 报告键 html/reports/{filename}(文件名寻址),
本路由以 GET /html/{filename} 提供读取,替代原节点本地 html_output 的
StaticFiles 挂载——API 与 worker 容器文件系统不互通,本地盘从来不是
可依赖的读取来源。
解析顺序(逐级兜底,每次未命中记日志):
1. RustFS 报告键(新产物,裸 HTML / 裸 JSON)
2. HTMLFile 表记录(遗留 html/{hash}.json JSON 包装 {'content'})
3. 节点本地 html_output 目录(存量兜底,compose 共享卷;新产物不再写本地)
已知约束(沿用 StaticFiles 时代的既定姿态,非本次引入):本路由不做认证。
iframe 加载报告时浏览器不会携带 Authorization 头,无法套用 API 鉴权。
"""
import json
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from sqlalchemy import select
from shared.config.settings import settings
from shared.database.database import db_manager
from shared.utils.logger import get_logger
from moldinsight.models import HTMLFile
from moldinsight.storage.rustfs_storage import rustfs_manager
logger = get_logger(__name__)
router = APIRouter()
# 存量兜底目录(模块常量,测试可替换)
LOCAL_HTML_DIR = Path("html_output")
_MEDIA_TYPES = {
".html": "text/html; charset=utf-8",
".json": "application/json",
}
def include_into(app) -> None:
"""挂载到应用根路径——不能进 /api 前缀聚合:URL 形状必须保持
/html/{filename}(持久化 cavity JSON 与前端 iframe 均引用此形状)。
失败语义与 route_registry._safe_include 一致:非 DEBUG 记入
route_load_status['failed'](/api/health 呈现 degraded),DEBUG 直接抛错。
"""
try:
app.include_router(router)
except Exception as exc:
from moldinsight.api.route_registry import route_load_status
route_load_status["failed"].append(
{"label": "HTML报告", "module": __name__, "error": str(exc)}
)
logger.error(f"HTML 报告路由加载失败: {exc}")
if settings.DEBUG:
raise
def _validate_filename(filename: str) -> None:
"""防路径穿越:只允许单段文件名(报告键固定为 html/reports/ 一级平铺)。"""
if not filename or filename.startswith(".") or Path(filename).name != filename:
raise HTTPException(status_code=404, detail=f"报告不存在: {filename}")
def _media_type_for(filename: str) -> str:
return _MEDIA_TYPES.get(Path(filename).suffix.lower(), "application/octet-stream")
async def _download_from_rustfs(filename: str) -> Optional[bytes]:
"""新产物:html/reports/{filename} 裸文件直取。"""
if not rustfs_manager.is_connected:
return None
try:
return await rustfs_manager.download_report_artifact(filename)
except Exception as exc:
logger.debug(f"报告键未命中(继续遗留解析): {filename}: {exc}")
return None
async def _download_from_legacy_record(filename: str) -> Optional[bytes]:
"""遗留 HTMLFile 记录:html/{hash}.json JSON 包装 {'content'}。"""
if not rustfs_manager.is_connected:
return None
async with db_manager.session() as session:
result = await session.execute(
select(HTMLFile)
.where(HTMLFile.filename == filename)
.order_by(HTMLFile.id.desc())
)
record = result.scalars().first()
if record is None:
return None
data = await rustfs_manager.download_file("html_files", record.object_key)
if record.object_key.startswith(rustfs_manager.report_prefix + "/"):
# 新格式记录:报告键直取瞬时失败走到这里,裸文件原样返回
return data
wrapper = json.loads(data.decode("utf-8"))
content = wrapper.get("content")
if content is None:
raise ValueError(f"遗留报告对象缺少 content 字段: {record.object_key}")
return content.encode("utf-8")
def _download_from_local(filename: str) -> Optional[bytes]:
"""存量兜底:旧 worker 写入共享卷 html_output 的历史产物。"""
path = LOCAL_HTML_DIR / filename
if path.is_file():
return path.read_bytes()
return None
@router.get("/html/{filename:path}", summary="读取 HTML 可视化报告")
async def get_html_report(filename: str) -> Response:
_validate_filename(filename)
data = await _download_from_rustfs(filename)
source = "rustfs"
if data is None:
try:
data = await _download_from_legacy_record(filename)
except Exception as exc:
logger.debug(f"遗留记录解析失败(继续本地兜底): {filename}: {exc}")
source = "rustfs-legacy"
if data is None:
data = _download_from_local(filename)
source = "local-fallback"
if data is None:
raise HTTPException(status_code=404, detail=f"报告不存在: {filename}")
if source != "rustfs":
# 存量链路命中留痕,便于评估遗留对象与本地卷的清理时机
logger.info(f"报告经 {source} 链路命中: {filename}")
return Response(content=data, media_type=_media_type_for(filename))