Compare commits

...

6 Commits

Author SHA1 Message Date
cjw cf65ceed1c fix(moldinsight): 方案预览契约断裂——工艺参数面板全 N/A + 备选方案预览无法切换
多方案重构后 cavity_data 与可视化端字段契约断裂,两处收口:

- 契约桥接:CalculationService.attach_scheme_info_contract 把方案级
  key_info 内嵌回 cavity_data(mold_cavities.cavity_key_info / cavity_count /
  manufacturing_info 展示字段),multi_scheme_planner._build_scheme 生成后
  立即接线——3D 预览"关键工艺参数"面板此前 10 项 8 项恒 N/A,
  前端型腔数恒回退 1 腔
- 方案级预览恢复:_attach_scheme_previews 改为每方案生成完整预览
  (HTML + 摘要/数据 JSON 直传 RustFS)并写 scheme["html_file"],
  前端切方案即切 iframe(此前仅最优方案一份,备选永远显示推荐预览);
  删除零消费的 summary_file 死产物;任务级 HTML 复用推荐方案不再重复生成
- 前端 3D 预览标题栏加"当前:方案名"标识

已知取舍:报告键存储随方案数线性增长(记 TECH_DEBT D11 追记);
历史任务不回填,需重新分析。接口面零变化,openapi 不重导出。

测试:193 passed, 13 skipped(净增契约桥接回归测试 1 项,OCC-free);
vue-tsc 通过;本地无 pythonocc,planner 运行时路径待 conda 环境补跑。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-27 00:46:16 +08:00
cjw f87cd8a68e fix(deploy): celery worker --pool=solo——prefork 守护进程禁止 spawn OCC 子进程
Celery prefork 的 worker 子进程是 daemonic,multiprocessing 硬性禁止
守护进程再生子进程:OccProcessPool(spawn 拉起 OCC 常驻工作进程)在
celery worker 内首次 run 即报 "daemonic processes are not allowed to
have children",解析任务全部 failed。该池在 FastAPI 主进程内无此限制
——celery 路径的分析从未真正工作过。

改用 --pool=solo:任务在 celery 主进程(非守护)内执行,允许 spawn;
天然单任务串行,与 processing_service / redis_task_manager / db_manager
等模块级单例的 asyncio.Lock(不可跨循环并发)匹配。吞吐扩展走横向
加容器副本。CELERY_CONCURRENCY / MAX_TASKS_PER_CHILD 随之移除。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-27 00:01:05 +08:00
cjw eb0a4005e1 fix(db): celery 角色改用 NullPool——连接跨事件循环复用致任务全挂
celery_tasks 经 asyncio.run 每任务新建事件循环,而 asyncpg 连接绑定
创建它的循环。db_manager 引擎只连一次(is_connected 短路),QueuePool
把上一循环的连接缓存着流入新循环:任务报
"got Future attached to a different loop" 全部 failed,连接销毁时再报
"Event loop is closed"(ForkPoolWorker 日志连续复现)。

celery_tasks 对 redis 已有同因处理(每任务 reconnect()),DB 侧漏了。
改法:connect(role="celery") 用 NullPool——连接不缓存,每次 checkout
在当前循环新建、用完即关。web 入口(单循环 uvicorn)QueuePool 不变。

已实测:同进程三个连续 asyncio.run 循环共享引擎,全部查询成功。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 23:43:54 +08:00
cjw 73de137779 fix(processing): process_file_core 缺 timeout_seconds 参数——解析必挂 NameError
process_file_core 内 4 处引用 timeout_seconds(run_occ / 网格 / 型腔 /
设计分析的步骤超时),但该变量定义在外层 process_file_with_storage
(按文件大小计算超时处)。拆方法重构时漏传,解析第一步
run_occ("parse_stp") 即 NameError,任务标 failed,页面报
"name 'timeout_seconds' is not defined"。

修复:核心方法显式接收 timeout_seconds 参数(默认 300 兜底),
调用方传入。pyflakes 全仓库 F821 扫描确认无同类未定义名。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 23:27:32 +08:00
cjw aaaac95b53 fix(init_db): await 优先级漏括号——启动迁移自诞生起从未执行成功(事故根因)
has_alembic = await conn.execute(...).scalar()  因 await 优先级低于
属性访问,实际解析为 await (conn.execute(...).scalar())——在协程对象
上调 .scalar() 必抛 AttributeError('coroutine' object has no attribute
'scalar')。启动迁移每次容器启动都在第一行炸掉,再被 except 吞成一行
日志(4a5dc61 已改为 logger.exception)。两行均改为
(await conn.execute(...)).scalar()。

已于生产库实测:主库路径(has_alembic=True→upgrade no-op)与临时
历史库路径(stamp+upgrade)全部跑通,临时库已删除。

至此 2026-09-26 事故根因链闭合:启动迁移从未生效 → prod schema 长期
落后于模型 → 新镜像上线即上传 500(缺 product_id)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 23:17:33 +08:00
cjw 4a5dc61a02 fix(init_db): 启动迁移失败不再静默——logger.exception 带 traceback + schema 覆盖校验
2026-09-26 事故复盘:AUTO_MIGRATE=true 但启动迁移在 stamp 之前就抛异常,
except 里 logger.error(f"{e}") 丢掉了 traceback,docker logs 只剩一句
"数据库初始化失败",prod 缺 stp_files.product_id 等列导致上传全挂,
无从定位。两个修复:

- 初始化异常改用 logger.exception,完整 traceback 落日志
- 新增启动期 _verify_schema_coverage:模型声明的 32 张表全部列必须真实
  存在于 DB,缺失即 error 点名(只查缺不查多,遗留列不报);迁移后自动
  执行,只读、自身异常不影响启动

已对生产库实跑验证:32 张表全部通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 23:11:30 +08:00
11 changed files with 259 additions and 64 deletions
+9 -4
View File
@@ -83,12 +83,17 @@ services:
dockerfile: deploy/Dockerfile.moldinsight
image: gemold-backend:latest
container_name: gemold_celery
command: ["sh", "-c", "cd /app/src && exec celery -A celery_app worker --concurrency=${CELERY_CONCURRENCY:-2} --max-tasks-per-child=${CELERY_MAX_TASKS_PER_CHILD:-50} --loglevel=info"]
# --pool=solo:任务在 celery 主进程内执行。prefork 的 worker 子进程是
# daemonic,multiprocessing 禁止守护进程再生子进程,OCC 常驻进程池
# (OccProcessPool spawn 拉起)在 prefork 下必报
# "daemonic processes are not allowed to have children"(2026-09-26)。
# solo 天然单任务串行,与模块级单例(asyncio.Lock 不可跨循环并发)匹配;
# 需要吞吐时横向加容器副本,而不是调并发。
command: ["sh", "-c", "cd /app/src && exec celery -A celery_app worker --pool=solo --loglevel=info"]
environment:
<<: *base_env
# Celery/OCC 吞吐调优(OCC_THROUGHPUT 方案 A)
CELERY_CONCURRENCY: ${CELERY_CONCURRENCY:-2}
CELERY_MAX_TASKS_PER_CHILD: ${CELERY_MAX_TASKS_PER_CHILD:-50}
# 旧 CELERY_CONCURRENCY / CELERY_MAX_TASKS_PER_CHILD 已随 --pool=solo 移除:
# solo 单进程串行,二者不适用(.env 里残留定义会被 compose 静默忽略)
volumes:
- uploads_data:/app/uploads
# 注:本文件无 backend service,celery 必须等 moldinsight API 就绪后再启动
+10 -4
View File
@@ -109,12 +109,18 @@ services:
# cd /app/src 是必须的:celery_app / celery_tasks 是 src/ 的子模块,
# 而其 import 用的是裸模块名(celery_app.py 内 include=["celery_tasks"]),
# 所以 worker 必须从 src/ 目录启动;不依赖 celery 版本是否支持 --workdir
command: ["sh", "-c", "cd /app/src && exec celery -A celery_app worker --concurrency=${CELERY_CONCURRENCY:-2} --max-tasks-per-child=${CELERY_MAX_TASKS_PER_CHILD:-50} --loglevel=info"]
# --pool=solo:任务在 celery 主进程内执行。prefork 的 worker 子进程是
# daemonic,multiprocessing 禁止守护进程再生子进程,OCC 常驻进程池
# (OccProcessPool spawn 拉起)在 prefork 下必报
# "daemonic processes are not allowed to have children"(2026-09-26)。
# solo 天然单任务串行,与 processing_service / redis_task_manager /
# db_manager 等模块级单例(其 asyncio.Lock 不可跨循环并发)也匹配;
# 需要吞吐时横向加容器副本,而不是调并发。
command: ["sh", "-c", "cd /app/src && exec celery -A celery_app worker --pool=solo --loglevel=info"]
environment:
<<: *base_env
# Celery/OCC 吞吐调优(OCC_THROUGHPUT 方案 A)
CELERY_CONCURRENCY: ${CELERY_CONCURRENCY:-2}
CELERY_MAX_TASKS_PER_CHILD: ${CELERY_MAX_TASKS_PER_CHILD:-50}
# 旧 CELERY_CONCURRENCY / CELERY_MAX_TASKS_PER_CHILD 已随 --pool=solo 移除:
# solo 单进程串行,二者不适用(.env 里残留定义会被 compose 静默忽略)
# uploads_data 共享卷(D6 过渡兜底):RustFS 异常时 worker 回退本地路径下载。
# D11 后 worker 不再写 HTML 产物(直传 RustFS 报告键),无需 html_data 卷
volumes:
+2
View File
@@ -4,6 +4,8 @@
> 维护规则:每完整完成一个需求,**倒序在本文顶部加一条**(日期 + 主题 + 关键事实);其余主文档(架构 / 规划 / 技术债 / 部署)维护各自的"当前有效说法",本文只记录"什么时候做到了哪一步"。维护规则出处见根目录 [AGENTS.md](../AGENTS.md)。
> 早期条目(2026-09-17 之前)已精简为锚点,完整流水见 [archive/2026-09_governance_batches.md](archive/2026-09_governance_batches.md) 与 [archive/2026-09_status_history.md](archive/2026-09_status_history.md)。
> 2026-09-27(**分模方案预览契约修复:关键工艺参数面板整片 N/A + 备选方案预览无法切换**——多方案重构后 `cavity_data` 与可视化端的字段契约断裂,两处收口。① **legacy 契约桥接**:新增 `CalculationService.attach_scheme_info_contract`([calculation_service.py](../src/moldinsight/services/calculation_service.py)),多方案规划器([multi_scheme_planner.py](../src/moldinsight/core/multi_scheme_planner.py) `_build_scheme`)生成 `cavity_data` + `key_info` 后立即把方案级 `key_info` 内嵌回 `cavity_data`(`mold_cavities.cavity_key_info` + `cavity_count` + `manufacturing_info.mold_material / mold_hardness / surface_finish / estimated_cycle_time / parting_line_length`)——3D 预览"关键工艺参数"面板([html_generator.py](../src/shared/utils/html_generator.py) `updateInfoPanel` / `updateSummaryPanels`)只拿得到 `cavity_data`,多方案重构后这些字段仅存在于 `scheme.key_info`,面板 10 项 8 项恒 N/A、前端结果页"型腔数"恒回退 1 腔(根因佐证:`_strip_heavy_geometry` 专门保留 `mold_cavities.cavity_key_info`,说明预览契约仍是 legacy 结构,多方案生成器未遵守);② **方案级预览恢复**:[processing_service.py](../src/moldinsight/services/processing_service.py) `_attach_scheme_previews` 由"仅最优方案生成完整 HTML + 其余方案只生成 summary JSON(前端零消费死产物 `summary_file`,已删除)"改为**每方案生成完整预览**(HTML + `_summary.json` + `_data.json` 三件直传 RustFS 报告键)并写 `scheme["html_file"] = /html/{name}`——前端 ResultView 预览取 `selectedScheme?.html_file`,此前后端从不写方案级 `html_file`,切方案 iframe `:key` 不变,备选方案永远显示推荐方案预览;任务级 HTML 复用推荐方案预览不再重复生成(`save_html_file` 要求本地文件存在,`_attach_scheme_previews` 返回推荐方案预览本地路径,无任何方案 `cavity_data` 时回退任务级单份生成);③ 前端 [ResultView.vue](../frontend/src/modules/moldinsight/ResultView.vue) 3D 预览标题栏加"当前:方案名" t-tag(多方案时渲染),切换有明确反馈。**已知取舍**:每方案预览复制一份网格数据 JSON,RustFS 报告键存储随方案数线性增长,换备选方案真实独立分模预览(存储吃紧时演进共享产品网格 + 方案差分,见 [TECH_DEBT.md](TECH_DEBT.md) D11 追记)。**历史任务不回填**:已入库任务缺字段与每方案预览文件,需重新分析才能看到完整参数与切换预览。**接口面零变化**(无路由/Pydantic schema 变更,`scheme.html_file` 为 `/api/status/{task_id}` 自由 dict 内新增字段,openapi.json 不触发重导出)。**测试基线**:**193 passed, 13 skipped**(净增 1:`test_calculation_service_bridges_scheme_info_contract`,OCC-free 直测契约桥接);前端 `vue-tsc -b` 通过。**说明**:本地 pip 环境无 pythonocc,`multi_scheme_planner` 运行时路径未实跑(py_compile 通过;core→services import 模式与 `aluminum_foam_mold` 引 `MaterialService` 同款,无循环依赖),OCC 全量验证待 conda 环境补跑。)
>
> 2026-09-26(**端口默认值统一 10003 / 10004**:约定"容器内部端口无所谓,重要的是映射到宿主机的端口;前端页面 = 10003"。① [.env.example](../.env.example) 端口段重写:移除冗余的 `HOST` / `PORT`(uvicorn 命令硬编码,未读取)+ 移除误导性的"应用内部监听端口"注释;新增端口段约定(`FRONTEND_PORT=10003` 浏览器入口、`BACKEND_PORT=10003` 同端口供调试直连、`MOLDINSIGHT_PORT=10003` / `INVENTORY_PORT=10004` 独立模式);② [docker-compose.yml](../docker-compose.yml) frontend 默认端口回退 `80→10003`、backend 默认 `8000→10003`、删除 backend service 内冗余的 `HOST/PORT` env(uvicorn `--host/--port` 已是单一事实源,env 无代码读);③ [docker-compose.moldinsight.yml](../docker-compose.moldinsight.yml) / [docker-compose.inventory.yml](../docker-compose.inventory.yml) `MOLDINSIGHT_PORT/INVENTORY_PORT` 默认 `8000/8001→10003/10004`;④ [docs/deployment/DEPLOY_PORT.md](../docs/deployment/DEPLOY_PORT.md) §3 / [docs/deployment/PORT_CONFIG.md](../docs/deployment/PORT_CONFIG.md) §1 §2 端口映射示例同步。**验证**:yaml 渲染后端口映射 `[unified] frontend 10003→80 / backend 10003→8000`、`[moldinsight] 10003→8000`、`[inventory] 10004→8001`,与约定一致。**遗留**:服务器 `.env` 与新版 `.env.example` 对齐(已有字段名一致,仅注释差异,不需要重设值)。
>
> 2026-09-26(**Compose 拆分部署机端到端复验:3 个收尾修复 + 1 处文档澄清**——① `--workdir` 误用修复:[docker-compose.yml](../docker-compose.yml) / [docker-compose.moldinsight.yml](../docker-compose.moldinsight.yml) 中 `moldinsight-celery` 的 `command:` 原照搬旧 Dockerfile.celery 的 `celery worker --workdir=/app/src ...`,celery 5.x 已移除 `--workdir` 选项(部署机实测报 `No such option '--workdir'`),改为 `cd /app/src && exec celery -A celery_app worker ...`——celery_app.py 内 `include=["celery_tasks"]` 为裸模块名,必须在 `src/` 下启动 worker,与是否支持 `--workdir` 解耦,跨 celery 版本稳定;② **裸 `up` 不重建已有镜像**澄清:部署机复用旧 gemold-backend 镜像起容器(旧 miniconda base + 旧代码),`docker compose up -d` 仅在本地无同名镜像时构建,DEPLOYMENT §1.2 / README / OPERATIONS §4 / LINUX_SETUP §11 同步补一句"更新代码后须 `up -d --build` 或先 `docker compose build`";③ build.sh 步骤由"四步(base→backend→celery→frontend)"修正为"三步(base→backend→frontend,celery 复用 backend 镜像)"——`Dockerfile.celery` 早已删除但 build.sh 与 README 的描述未跟改,三处文档统一收口。**遗留**:服务器 `docker compose up -d --build` 重建验证新 base + 新 celery 启动命令端到端可用。)
+1
View File
@@ -160,6 +160,7 @@
- **读侧**:`/html` StaticFiles 挂载删除,新增代理路由 [html_report_router.py](../src/moldinsight/api/html_report_router.py)(挂根路径保持 URL 形状——持久化 cavity JSON 与前端 iframe 均引用 `/html/{filename}`):RustFS 报告键直取 → 遗留 `html/{hash}.json` JSON 包装解析 → 本地卷存量兜底 → 404,防路径穿越(单段文件名校验)
- **部署**:celery 服务摘除 `html_data` 卷(不再写本地);Dockerfile.moldinsight 删除 `COPY html_output/`(构建机陈旧报告不再烤进镜像)
- **已知约束**(沿用 StaticFiles 时代既定姿态,非新引入):报告路由不做认证——iframe 无法携带 Authorization 头
- **2026-09-27 追记**:可视化产物从"每任务一套"改为**每分模方案一套**——`_attach_scheme_previews` 为每个 `candidate_schemes` 生成独立 HTML / `_summary.json` / `_data.json`([processing_service.py](../src/moldinsight/services/processing_service.py)),`scheme["html_file"]` 供前端切方案即切预览;任务级 HTML 复用推荐方案那份(`HTMLFile` 记录仍仅推荐方案一条)。**已知取舍**:网格数据 JSON 每方案复制一份,报告键存储随方案数线性增长(当前 ≤3 方案,接受);存储吃紧时再演进共享产品网格 + 仅差分型腔/模芯,暂不排期
- 回归测试:[tests/test_html_report_router.py](../tests/test_html_report_router.py)(8 例:四链路命中、新旧格式记录区分、媒体类型、404、穿越拒绝)
~~原现状 / 影响~~:可视化 HTML/摘要同时写本地与 RustFS,多副本下 /html 命中结果取决于负载均衡,跨副本文件不共享。
@@ -151,6 +151,9 @@
<div class="summary-header">
<h3>3D 预览</h3>
<t-tag theme="primary">重点区域</t-tag>
<t-tag v-if="candidateSchemes.length > 1" theme="warning" variant="light">
当前:{{ selectedScheme?.title || selectedScheme?.scheme_id }}
</t-tag>
</div>
<t-alert v-if="state.previewStatus === 'error'" theme="warning" title="预览加载失败" :message="'HTML 已生成但加载异常,请检查该链接是否可访问:' + selectedHtmlFile" style="margin-bottom: var(--space-3);" />
<iframe
@@ -11,6 +11,7 @@ from OCC.Core.TopoDS import TopoDS_Face, TopoDS_Shape, topods
from moldinsight.core.mold_generator_registry import mold_generator_registry
from moldinsight.core.parting_candidate_generator import PartingCandidateGenerator
from moldinsight.core.parting_scheme_scorer import PartingSchemeScorer
from moldinsight.services.calculation_service import CalculationService
from shared.utils.logger import get_logger
logger = get_logger(__name__)
@@ -149,6 +150,9 @@ class MultiSchemeMoldPlanner:
cavity_data = generator.generate_detailed_cavity_json(cavity_result)
key_info = generator.generate_cavity_key_info(cavity_result)
# 桥接 legacy 契约:3D 预览面板/前端只读 cavity_data 内嵌字段,
# key_info 不内嵌回去则"关键工艺参数"整片 N/A(见 attach_scheme_info_contract)
CalculationService.attach_scheme_info_contract(cavity_data, key_info)
cavity_data.setdefault("metadata", {})
cavity_data["metadata"]["scheme_id"] = candidate["scheme_id"]
cavity_data["metadata"]["scheme_method"] = candidate["method"]
@@ -364,6 +364,33 @@ class CalculationService:
cls.attach_injection_system_summaries(result, material["name"])
return result
@staticmethod
def attach_scheme_info_contract(cavity_data: Dict[str, Any], key_info: Dict[str, Any]) -> None:
"""把方案级 key_info 内嵌回 cavity_data,补齐下游依赖的 legacy 字段契约(原地修改)。
3D 预览 HTML(shared/utils/html_generator 的"关键工艺参数"面板)只拿得到 cavity_data:
- 面板读 mold_cavities.cavity_key_info.geometric_characteristics 与
manufacturing_info.mold_material / mold_hardness / surface_finish /
estimated_cycle_time / parting_line_length;
- 前端结果页"型腔数"读 mold_cavities.cavity_count。
多方案重构后这些字段只存在于 scheme.key_info,缺失会导致面板整片 N/A——在此统一桥接。
"""
mold_cavities = cavity_data.setdefault("mold_cavities", {})
mold_cavities.setdefault("cavity_count", 1)
mold_cavities["cavity_key_info"] = key_info
requirements = key_info.get("manufacturing_requirements", {})
mold_parameters = key_info.get("mold_parameters", {})
manufacturing = cavity_data.setdefault("manufacturing_info", {})
manufacturing.setdefault(
"mold_material",
requirements.get("cavity_material") or manufacturing.get("recommended_material", ""),
)
manufacturing.setdefault("mold_hardness", requirements.get("hardness", ""))
manufacturing.setdefault("surface_finish", requirements.get("surface_finish", ""))
manufacturing.setdefault("estimated_cycle_time", requirements.get("estimated_cycle_time", ""))
manufacturing.setdefault("parting_line_length", mold_parameters.get("parting_line_length", ""))
@classmethod
def attach_injection_system_summaries(
cls,
+64 -38
View File
@@ -136,7 +136,8 @@ class ProcessingService:
try:
await asyncio.wait_for(
self.process_file_core(
task_id, file_path, stp_file_id, db_session, process_params
task_id, file_path, stp_file_id, db_session, process_params,
timeout_seconds=timeout_seconds,
),
timeout_seconds,
)
@@ -178,8 +179,13 @@ class ProcessingService:
stp_file_id: int,
db_session: AsyncSession,
process_params: Optional[Dict[str, Any]] = None,
timeout_seconds: float = 300,
):
"""核心处理逻辑"""
"""核心处理逻辑
timeout_seconds:OCC 子进程各步骤的超时,由调用方按文件大小计算
(曾因重构拆方法时漏传此参数,4 处引用 NameError,解析必挂)。
"""
try:
logger.info(f"开始处理文件并生成模具型腔: {file_path}")
@@ -310,7 +316,7 @@ class ProcessingService:
try:
html_generator = HTMLGenerator(output_dir=str(html_out_dir))
detailed_cavity_json = await self._attach_scheme_previews(
detailed_cavity_json, best_html_local = await self._attach_scheme_previews(
detailed_cavity_json=detailed_cavity_json,
geometry_data=geometry_data,
stp_filename=Path(file_path).name,
@@ -323,26 +329,37 @@ class ProcessingService:
best_cavity_data = best_scheme.get("cavity_data", {}) if best_scheme else best_cavity_data
best_key_info = best_scheme.get("key_info", {}) if best_scheme else best_key_info
# 8. 保存模具型腔数据(包含方案级预览链接)
# 8. 保存模具型腔数据(包含方案级 html_file 预览链接)
await self.analysis_storage.save_mold_cavity_data(
db_session, stp_file_id, detailed_cavity_json
)
html_file_path = html_generator.generate_and_save_visualization(
geometry_data,
Path(file_path).name,
cavity_data=best_cavity_data,
pointcloud_data=pointcloud_data,
lod_data=lod_data,
)
await self._upload_report_artifacts(Path(html_file_path))
# 任务级 HTML 复用推荐方案预览(各方案已生成并上传,不再重复生成)
if best_html_local:
await self.analysis_storage.save_html_file(
db_session,
stp_file_id,
Path(best_html_local).name,
best_html_local,
)
else:
# 兜底:候选方案均无 cavity_data 时退回任务级单份生成
logger.warning("未生成任何方案级预览,回退任务级单份可视化")
html_file_path = html_generator.generate_and_save_visualization(
geometry_data,
Path(file_path).name,
cavity_data=best_cavity_data,
pointcloud_data=pointcloud_data,
lod_data=lod_data,
)
await self._upload_report_artifacts(Path(html_file_path))
await self.analysis_storage.save_html_file(
db_session,
stp_file_id,
Path(html_file_path).name,
html_file_path,
)
await self.analysis_storage.save_html_file(
db_session,
stp_file_id,
Path(html_file_path).name,
html_file_path,
)
finally:
shutil.rmtree(html_out_dir, ignore_errors=True)
stage_timings["persist_artifacts"] = round(time.perf_counter() - stage_started, 3)
@@ -437,7 +454,7 @@ class ProcessingService:
"material": requested_material,
"parameters": process_params,
"stage_timings": stage_timings,
"html_file": best_scheme.get("html_file", f"/html/{Path(html_file_path).name}") if best_scheme else f"/html/{Path(html_file_path).name}",
"html_file": (best_scheme or {}).get("html_file") or "",
"verification": verification_result,
"llm_report": llm_report,
"export_artifacts": export_artifacts,
@@ -717,40 +734,49 @@ class ProcessingService:
pointcloud_data: Optional[Dict[str, Any]] = None,
lod_data: Optional[Dict[str, Any]] = None,
html_generator: HTMLGenerator = None,
) -> Dict[str, Any]:
"""为候选分模方案生成轻量摘要链接(完整HTML仅最优方案按需生成)"""
) -> Tuple[Dict[str, Any], Optional[str]]:
"""为每个候选分模方案生成独立 3D 预览(HTML + 摘要/数据 JSON 直传 RustFS)。
前端结果页切方案即切预览,依赖方案级 scheme["html_file"]=/html/{name};
只给最优方案生成单份 HTML(旧逻辑)会让备选方案永远显示推荐方案预览。
返回 (detailed_cavity_json, 推荐方案预览的本地路径):
本地路径供 save_html_file 落 HTMLFile 记录(读侧要求本地文件存在),
任务级 html_file 直接复用推荐方案预览,不再重复生成。
"""
if html_generator is None:
raise ValueError(
"html_generator 不能为空(D11:摘要产物统一经任务临时目录上传 RustFS)"
"html_generator 不能为空(D11:可视化产物统一经任务临时目录上传 RustFS)"
)
candidate_schemes = detailed_cavity_json.get("candidate_schemes", [])
if not candidate_schemes:
return detailed_cavity_json
return detailed_cavity_json, None
best_scheme = CalculationService.get_best_scheme(detailed_cavity_json)
best_html_local: Optional[str] = None
for scheme in candidate_schemes:
cavity_data = scheme.get("cavity_data")
if not cavity_data:
continue
suffix = scheme.get("scheme_id")
base_stem = Path(stp_filename).stem.replace(" ", "_")
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
summary_name = f"mold_{base_stem}_{suffix}_{ts}_summary.json"
summary_content = html_generator.generate_3d_viewer_summary(
geometry_data, cavity_data
suffix = str(scheme.get("scheme_id") or Path(stp_filename).stem)
html_file_path = html_generator.generate_and_save_visualization(
geometry_data,
Path(stp_filename).name,
cavity_data=cavity_data,
pointcloud_data=pointcloud_data,
lod_data=lod_data,
suffix=suffix,
)
# D11:摘要 JSON 写任务临时目录后直传 RustFS 报告键,不落本地 html_output
summary_path = html_generator.save_data_file(summary_content, summary_name)
await rustfs_manager.upload_report_artifact(
summary_name, Path(summary_path).read_bytes(),
content_type="application/json",
)
scheme["summary_file"] = f"/html/{summary_name}"
await self._upload_report_artifacts(Path(html_file_path))
scheme["html_file"] = f"/html/{Path(html_file_path).name}"
if best_scheme and scheme.get("scheme_id") == best_scheme.get("scheme_id"):
best_html_local = html_file_path
best_scheme = CalculationService.get_best_scheme(detailed_cavity_json)
if best_scheme:
detailed_cavity_json["html_file"] = best_scheme.get("html_file")
return detailed_cavity_json
return detailed_cavity_json, best_html_local
async def _upload_report_artifacts(self, html_file_path: Path):
"""D11:任务临时目录中的可视化产物(.html / _summary.json / _data.json)
+32 -14
View File
@@ -53,16 +53,31 @@ class DatabaseManager:
return
try:
pool_cfg = _get_pool_config(role)
# 创建异步引擎
self.engine = create_async_engine(
database_url,
echo=settings.DEBUG,
pool_size=pool_cfg["pool_size"],
max_overflow=pool_cfg["max_overflow"],
pool_recycle=3600,
pool_pre_ping=True, # 自动检测失效连接,避免 PG 断连报错
)
if role == "celery":
# Celery 场景必须用 NullPool:celery_tasks 经 asyncio.run 每任务
# 新建事件循环,而 asyncpg 连接绑定创建它的循环——QueuePool 会把
# 上一循环的连接缓存着流入新循环,报
# "got Future attached to a different loop"(任务全挂)且销毁时
# "Event loop is closed"。NullPool 不缓存:每次 checkout 在当前
# 循环新建连接、用完即关。局域网建连 ~1ms,对分钟级分析任务可忽略。
# (与 redis_task_manager 每任务 reconnect() 是同一循环绑定问题)
from sqlalchemy.pool import NullPool
self.engine = create_async_engine(
database_url,
echo=settings.DEBUG,
poolclass=NullPool,
)
else:
pool_cfg = _get_pool_config(role)
# 创建异步引擎
self.engine = create_async_engine(
database_url,
echo=settings.DEBUG,
pool_size=pool_cfg["pool_size"],
max_overflow=pool_cfg["max_overflow"],
pool_recycle=3600,
pool_pre_ping=True, # 自动检测失效连接,避免 PG 断连报错
)
# 创建异步会话工厂
self.async_session = async_sessionmaker(
@@ -76,10 +91,13 @@ class DatabaseManager:
await conn.execute(text("SELECT 1"))
self.is_connected = True
logger.info(
"数据库连接成功 (pool_size=%d, max_overflow=%d)",
pool_cfg["pool_size"], pool_cfg["max_overflow"],
)
if role == "celery":
logger.info("数据库连接成功 (role=celery, pool=NullPool)")
else:
logger.info(
"数据库连接成功 (role=%s, pool_size=%d, max_overflow=%d)",
role, pool_cfg["pool_size"], pool_cfg["max_overflow"],
)
except Exception as e:
logger.error(f"数据库连接失败: {e}")
+61 -4
View File
@@ -35,11 +35,15 @@ async def _run_alembic_migrations() -> None:
- 既有但无 alembic_version(历史 DB):先 stamp head 基线,再 upgrade(no-op)
"""
async with db_manager.engine.begin() as conn:
has_alembic = await conn.execute(text("SELECT to_regclass('public.alembic_version')")).scalar()
# 注意括号:await 优先级低于属性访问,await conn.execute(...).scalar()
# 实际是 await (conn.execute(...).scalar())——会在协程对象上调 .scalar()
# 直接 AttributeError。必须 (await conn.execute(...)).scalar()。
# 曾因漏括号让启动迁移自诞生起一次都没跑通过(2026-09-26 事故根因)。
has_alembic = (await conn.execute(text("SELECT to_regclass('public.alembic_version')"))).scalar()
if not has_alembic:
table_count = await conn.execute(
table_count = (await conn.execute(
text("SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_name <> 'alembic_version'")
).scalar()
)).scalar()
if table_count and table_count > 0:
logger.info("检测到既有 DB 未纳入 alembic 管理,自动 stamp head 作为基线")
await asyncio.to_thread(_alembic_stamp_head)
@@ -184,6 +188,51 @@ async def create_admin_user(session):
logger.info(f"创建了管理员账户: {settings.ADMIN_USERNAME}")
async def _verify_schema_coverage() -> None:
"""启动期 schema 校验:模型声明的表/列必须真实存在于 DB。
背景(2026-09-26 事故):AUTO_MIGRATE 启动迁移曾静默失败,prod 库缺
stp_files.product_id 等列,上传全挂两天无启动日志线索。此校验保证
『迁移被跳过 / stamp 基线掩盖未应用增量』这类漂移在启动时即被点名。
只查缺、不查多:DB 里的遗留列/表(如 users.is_superuser)是历史产物,不管。
发现缺失只记 error 不抛——服务照常起,但 docker logs 必有醒目线索。
"""
import moldinsight.models # noqa: F401
import inventory.models # noqa: F401
from shared.models.base import Base
async with db_manager.engine.connect() as conn:
rows = (await conn.execute(text(
"SELECT table_name, column_name FROM information_schema.columns "
"WHERE table_schema='public'"
))).all()
db_cols: dict = {}
for tn, cn in rows:
db_cols.setdefault(tn, set()).add(cn)
missing = []
for tname, table in Base.metadata.tables.items():
present = db_cols.get(tname)
if present is None:
missing.append(f"整表缺失: {tname}")
continue
absent = {c.name for c in table.columns} - present
if absent:
missing.append(f"{tname} 缺列: {sorted(absent)}")
if missing:
logger.error(
"schema 校验失败:模型声明了 %d 处 DB 缺失(迁移链与实际 schema 不一致,"
"请人工执行 alembic upgrade head 或核对基线):%s",
len(missing), "; ".join(missing),
)
else:
logger.info(
"schema 校验通过:模型 %d 张表的全部列均存在于 DB", len(Base.metadata.tables)
)
async def init_database(keep_connected: bool = True):
"""初始化数据库"""
try:
@@ -195,6 +244,12 @@ async def init_database(keep_connected: bool = True):
"AUTO_MIGRATE=false:跳过启动期 alembic 迁移,"
"schema 由部署流程单点执行(alembic CLI 或 python -m shared.database.init_db)"
)
# 迁移后校验(只读、不抛):迁移链与实际 schema 脱节时在启动日志直接点名
try:
await _verify_schema_coverage()
except Exception:
logger.exception("schema 校验自身异常(不影响启动流程)")
async with db_manager.session() as session:
perm_map = await init_permissions(session)
@@ -223,7 +278,9 @@ async def init_database(keep_connected: bool = True):
return True
except Exception as e:
logger.error(f"数据库初始化失败: {e}")
# logger.exception 而非 error(f"{e}"):吞掉 traceback 曾让启动迁移静默
# 失败两天无从排查(2026-09-26 事故)
logger.exception(f"数据库初始化失败: {e}")
print(f"数据库初始化失败: {e}")
return False
finally:
+46
View File
@@ -78,6 +78,52 @@ def test_calculation_service_attaches_injection_system_summary():
assert "injection_system" in result
def test_calculation_service_bridges_scheme_info_contract():
"""多方案 cavity_data 必须自包含可视化面板依赖的 legacy 字段。
3D 预览"关键工艺参数"面板只拿得到 cavity_data;key_info 不内嵌回去则
面板整片 N/A、前端型腔数恒为 1(2026-09 事故根因)。
"""
key_info = {
"mold_parameters": {
"shrinkage_rate": "0.50%",
"draft_angle": "2.0°",
"parting_line_length": 620.0,
},
"geometric_characteristics": {
"product_volume": "12.00 cm³",
"product_weight": "12.60 g",
"wall_thickness_range": "1.40 - 2.60 mm",
},
"manufacturing_requirements": {
"cavity_material": "P20钢材",
"hardness": "HRC 28-32",
"surface_finish": "SPI A2",
"estimated_cycle_time": "25 秒",
},
}
cavity_data = {
"metadata": {"shrinkage_rate": 0.005, "draft_angle": 2.0},
"manufacturing_info": {"recommended_material": "P20钢材"},
"mold_cavities": {"cavity": {"vertex_count": 1}, "core": {"vertex_count": 1}},
}
CalculationService.attach_scheme_info_contract(cavity_data, key_info)
mold_cavities = cavity_data["mold_cavities"]
assert mold_cavities["cavity_count"] == 1
assert (
mold_cavities["cavity_key_info"]["geometric_characteristics"]["product_volume"]
== "12.00 cm³"
)
manufacturing = cavity_data["manufacturing_info"]
assert manufacturing["mold_material"] == "P20钢材"
assert manufacturing["mold_hardness"] == "HRC 28-32"
assert manufacturing["surface_finish"] == "SPI A2"
assert manufacturing["estimated_cycle_time"] == "25 秒"
assert manufacturing["parting_line_length"] == 620.0
def test_material_service_falls_back_to_abs_for_unknown_material():
material = MaterialService.get_material("UNKNOWN")
assert material["name"] == "ABS"