批次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
+20
View File
@@ -0,0 +1,20 @@
"""D2:铝价数据必须显式声明模拟来源。
数据为模拟走势(aluminum_price_service 模块 docstring 自述),
此前接口不声明来源、前端硬编码"数据来源: 上海期货交易所",属虚假来源声明。
"""
def test_current_price_declares_simulated_source():
from moldinsight.services.aluminum_price_service import get_aluminum_current_price
data = get_aluminum_current_price()
assert data["source"] == "simulated"
def test_history_items_declare_simulated_source():
from moldinsight.services.aluminum_price_service import get_aluminum_price_history
history = get_aluminum_price_history(days=30)
assert len(history) > 0
assert all(item["source"] == "simulated" for item in history)
+176
View File
@@ -0,0 +1,176 @@
"""D11:/html/{filename} 报告代理路由。
解析链:RustFS 报告键(新产物裸文件)→ HTMLFile 遗留记录(JSON 包装)
→ 节点本地 html_output(存量兜底)→ 404;路径穿越一律 404。
"""
import json
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from moldinsight.api import html_report_router
from moldinsight.storage.rustfs_storage import rustfs_manager
@pytest.fixture
def app():
application = FastAPI()
application.include_router(html_report_router.router)
return application
@pytest.fixture
async def client(app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest.fixture(autouse=True)
def _isolate_external_sources(monkeypatch, tmp_path):
"""默认隔离:RustFS 未连接、本地兜底目录指向空临时目录,由各测试按需打开。"""
monkeypatch.setattr(rustfs_manager, "is_connected", False)
monkeypatch.setattr(html_report_router, "LOCAL_HTML_DIR", tmp_path / "html_output")
# ── 链路 1:RustFS 报告键(新产物) ──────────────────────────────
async def test_report_served_from_rustfs_report_key(client, monkeypatch):
async def fake_download_report(filename):
assert filename == "mold_demo_20260918.html"
return b"<html>fresh</html>"
monkeypatch.setattr(rustfs_manager, "is_connected", True)
monkeypatch.setattr(rustfs_manager, "download_report_artifact", fake_download_report)
resp = await client.get("/html/mold_demo_20260918.html")
assert resp.status_code == 200
assert resp.content == b"<html>fresh</html>"
assert "text/html" in resp.headers["content-type"]
# ── 链路 2:HTMLFile 遗留记录(html/{hash}.json JSON 包装) ──────
class _FakeScalars:
def __init__(self, record):
self._record = record
def first(self):
return self._record
class _FakeResult:
def __init__(self, record):
self._record = record
def scalars(self):
return _FakeScalars(self._record)
class _FakeSession:
def __init__(self, record):
self._record = record
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def execute(self, stmt):
return _FakeResult(self._record)
class _FakeDBManager:
def __init__(self, record):
self._record = record
def session(self):
return _FakeSession(self._record)
async def test_falls_back_to_legacy_json_wrapper(client, monkeypatch):
async def fake_download_report(filename):
raise RuntimeError("报告键未命中")
async def fake_download_file(file_type, object_key):
assert file_type == "html_files"
assert object_key == "html/abc123.json"
return json.dumps(
{"content": "<html>legacy</html>", "filename": "mold_old.html"}
).encode("utf-8")
record = SimpleNamespace(object_key="html/abc123.json", filename="mold_old.html")
monkeypatch.setattr(rustfs_manager, "is_connected", True)
monkeypatch.setattr(rustfs_manager, "download_report_artifact", fake_download_report)
monkeypatch.setattr(rustfs_manager, "download_file", fake_download_file)
monkeypatch.setattr(html_report_router, "db_manager", _FakeDBManager(record))
resp = await client.get("/html/mold_old.html")
assert resp.status_code == 200
assert resp.content == b"<html>legacy</html>"
async def test_legacy_record_with_report_key_returns_raw(client, monkeypatch):
"""新格式记录(键在报告前缀下)必须按裸文件返回,不能当 JSON 包装解析。"""
async def fake_download_report(filename):
raise RuntimeError("报告键直取瞬时失败")
async def fake_download_file(file_type, object_key):
assert object_key == "html/reports/mold_new.html"
return b"<html>raw-by-report-key</html>"
record = SimpleNamespace(object_key="html/reports/mold_new.html", filename="mold_new.html")
monkeypatch.setattr(rustfs_manager, "is_connected", True)
monkeypatch.setattr(rustfs_manager, "download_report_artifact", fake_download_report)
monkeypatch.setattr(rustfs_manager, "download_file", fake_download_file)
monkeypatch.setattr(html_report_router, "db_manager", _FakeDBManager(record))
resp = await client.get("/html/mold_new.html")
assert resp.status_code == 200
assert resp.content == b"<html>raw-by-report-key</html>"
# ── 链路 3:节点本地 html_output(存量兜底) ─────────────────────
async def test_falls_back_to_local_dir(client, tmp_path):
local_dir = tmp_path / "html_output"
local_dir.mkdir()
(local_dir / "mold_local.html").write_text("<html>local</html>", encoding="utf-8")
resp = await client.get("/html/mold_local.html")
assert resp.status_code == 200
assert resp.content == b"<html>local</html>"
async def test_json_media_type_from_extension(client, tmp_path):
local_dir = tmp_path / "html_output"
local_dir.mkdir()
(local_dir / "mold_a_data.json").write_bytes(b"{}")
resp = await client.get("/html/mold_a_data.json")
assert resp.status_code == 200
assert "application/json" in resp.headers["content-type"]
# ── 未命中与防护 ────────────────────────────────────────────────
async def test_404_when_all_sources_miss(client):
resp = await client.get("/html/missing.html")
assert resp.status_code == 404
async def test_path_traversal_rejected(client):
resp = await client.get("/html/%2e%2e/secret.txt")
assert resp.status_code == 404
async def test_hidden_filename_rejected(client):
resp = await client.get("/html/%2e%2eetc%2fpasswd")
assert resp.status_code == 404
+116
View File
@@ -0,0 +1,116 @@
"""OCC 方案 B(常驻进程池)契约测试,见 docs/topics/performance/OCC_THROUGHPUT.md。
覆盖:spawn 子进程 + 管道往返、操作错误回传、超时换新补位、真实 OCC 解析
(盒体 STP 端到端)。OCC(pythonocc)仅 conda 环境提供,无 OCC 环境自动跳过。
"""
import os
from pathlib import Path
import pytest
pytest.importorskip("OCC", reason="需要 pythonocc 运行 OCC 进程池测试")
from moldinsight.services.occ_process_pool import OccProcessPool
@pytest.fixture(scope="module", autouse=True)
def _child_pythonpath():
"""spawn 子进程不继承 pytest 的 sys.path,需经 PYTHONPATH 传递 src 目录。"""
src_dir = str(Path(__file__).resolve().parent.parent / "src")
prev = os.environ.get("PYTHONPATH", "")
os.environ["PYTHONPATH"] = src_dir if not prev else f"{src_dir}{os.pathsep}{prev}"
yield
if prev:
os.environ["PYTHONPATH"] = prev
else:
os.environ.pop("PYTHONPATH", None)
@pytest.fixture
async def pool():
_pool = OccProcessPool()
try:
yield _pool
finally:
await _pool.shutdown()
async def test_ping_roundtrip(pool):
result = await pool.run("ping", {})
assert result == {"pong": True}
async def test_unknown_op_raises(pool):
with pytest.raises(RuntimeError, match="未知 OCC 操作"):
await pool.run("no_such_op", {})
async def test_child_error_surfaces(pool):
# parse_stp 指向不存在的文件:子进程内抛错,父进程应收到 RuntimeError
with pytest.raises(RuntimeError):
await pool.run("parse_stp", {"stp_path": "D:/no/such/file.stp"})
async def test_timeout_replaces_worker(pool):
# 子进程挂 15s,父进程 1s 超时 → 换新补位后池仍可用
with pytest.raises(Exception, match=".*"):
await pool.run("sleep", {"seconds": 15}, timeout=1)
# 新 worker 上 ping 正常
result = await pool.run("ping", {})
assert result == {"pong": True}
def _write_box_stp(path: Path) -> Path:
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
from OCC.Core.IFSelect import IFSelect_RetDone
from OCC.Core.STEPControl import STEPControl_AsIs, STEPControl_Writer
from OCC.Core.gp import gp_Pnt
box = BRepPrimAPI_MakeBox(gp_Pnt(0, 0, 0), 100.0, 60.0, 40.0).Shape()
writer = STEPControl_Writer()
writer.Transfer(box, STEPControl_AsIs)
assert writer.Write(str(path)) == IFSelect_RetDone
return path
async def test_parse_stp_end_to_end(pool, tmp_path):
stp_path = _write_box_stp(tmp_path / "box.stp")
geometry = await pool.run("parse_stp", {"stp_path": str(stp_path)})
assert geometry["volume"] == pytest.approx(100 * 60 * 40, rel=0.01)
assert "bounding_box" in geometry
async def test_generate_cavity_persists_export_steps(pool, tmp_path):
"""最复杂的迁移点:分模 + 方案形状 STEP 导出全部在子进程内完成,
TopoDS 不跨进程传输,export_manifest 携带落盘文件清单。"""
from moldinsight.services.material_service import MaterialService
stp_path = _write_box_stp(tmp_path / "box.stp")
export_dir = tmp_path / "exports"
result = await pool.run(
"generate_cavity",
{
"stp_path": str(stp_path),
"task_id": "test-task",
"material": dict(MaterialService.get_material("ABS")),
"is_foam_material": False,
"process_params": None,
"export_out_dir": str(export_dir),
},
timeout=120,
)
plan_result = result["plan_result"]
schemes = plan_result["candidate_schemes"]
assert 1 <= len(schemes) <= 3
assert plan_result["best_scheme_id"] == schemes[0]["scheme_id"]
# TopoDS 形状不得跨进程(子进程已 pop 掉 _export_shapes)
assert "_export_shapes" not in plan_result
manifest = result["export_manifest"]
assert manifest is not None
assert manifest["schemes"]
best = manifest["schemes"][plan_result["best_scheme_id"]]
assert best["total_files"] >= 1
# 落盘文件真实存在于子进程写入的 export_out_dir
assert any((export_dir / f.get("relative_path", "")).exists() for f in best["files"])