Compare commits
12 Commits
65b33609d6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cf65ceed1c | |||
| f87cd8a68e | |||
| eb0a4005e1 | |||
| 73de137779 | |||
| aaaac95b53 | |||
| 4a5dc61a02 | |||
| 021bf311c1 | |||
| f1d6a78f8a | |||
| 9e52c95400 | |||
| 0c69d4c347 | |||
| 62fd22cb31 | |||
| d8d4deffd1 |
@@ -31,15 +31,16 @@ POINTCLOUD_SAMPLE_COUNT=10000
|
||||
MESH_QUALITY=high
|
||||
PARALLEL_PROCESSING=true
|
||||
|
||||
# 数据库配置
|
||||
DB_HOST=szcjw
|
||||
# 数据库配置——以部署机实测可达 IP 为准(之前 192.168.3.10 → 192.168.0.11),
|
||||
# 改 IP 后必须重启 backend 才生效
|
||||
DB_HOST=192.168.0.11
|
||||
DB_PORT=5432
|
||||
DB_NAME=moldinsight
|
||||
DB_USER=moldinsight
|
||||
DB_PASSWORD=Qqs1996
|
||||
|
||||
# RustFS 对象存储配置 (S3v4 API)
|
||||
RUSTFS_ENDPOINT=http://szcjw:8010
|
||||
RUSTFS_ENDPOINT=http://192.168.0.11:8010
|
||||
RUSTFS_ACCESS_KEY=1RlKXw7v3DAsFr4fLckt
|
||||
RUSTFS_SECRET_KEY=KjWCHXZOh7GAtkLq0eQgNpMSmE6zw8Ddyiou21bB
|
||||
RUSTFS_TIMEOUT=30
|
||||
@@ -50,8 +51,8 @@ SECRET_KEY=vGLxbDGj4I3LnWZYQqRrchlVBSWpM73IZ8fT7ldwpsXpDYB82ghkr7sRkO7D-BiR
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||
|
||||
# Redis配置
|
||||
REDIS_HOST=szcjw
|
||||
# Redis配置(与 DB 同 IP)
|
||||
REDIS_HOST=192.168.0.11
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=Qqs1996
|
||||
REDIS_DB=0
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 启动命令端到端可用。)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -70,12 +70,12 @@ def upgrade() -> None:
|
||||
['stp_file_id', 'scheme_axis', 'feedback_status'],
|
||||
unique=False,
|
||||
)
|
||||
# GIN 索引(PG only):fingerprint JSON 字段 jsonb_path_query 类查询
|
||||
if op.get_bind().dialect.name == 'postgresql':
|
||||
op.execute(
|
||||
"CREATE INDEX ix_experience_feedback_fingerprint_gin "
|
||||
"ON experience_feedback USING GIN (fingerprint)"
|
||||
)
|
||||
# 注意:此处原本计划给 fingerprint 建 GIN 索引("jsonb_path_query 类查询"),
|
||||
# 但该列是 sa.JSON() → PG 的 json 类型,而 GIN 只支持 jsonb(json 无默认
|
||||
# 操作符类,CREATE INDEX 直接报 UndefinedObject)。且代码侧并无 JSON 包含
|
||||
# 查询——聚合过滤在 Python 侧进行,DB 侧走上方 (stp_file_id, scheme_axis,
|
||||
# feedback_status) 复合索引。故不建此索引;未来若真需要 JSON 检索,应先把
|
||||
# 列迁为 jsonb 再建 GIN。首次对生产库执行时曾因此报错回滚(2026-09-26)。
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -223,7 +223,9 @@ class ExperienceFeedbackService:
|
||||
role_code = "user"
|
||||
|
||||
# 6. 写 ExperienceFeedback
|
||||
now = datetime.now(timezone.utc)
|
||||
# expires_at 列是 naive TIMESTAMP(sa.DateTime()),asyncpg 拒绝 aware datetime
|
||||
# (DataError → 提交反馈 500);存库/比较统一 naive UTC(同 auth last_login 修复)
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
new_ttl = now + timedelta(days=FEEDBACK_TTL_DAYS)
|
||||
feedback = ExperienceFeedback(
|
||||
processing_task_id=processing_task.id,
|
||||
@@ -274,7 +276,8 @@ class ExperienceFeedbackService:
|
||||
排除 expires_at < now() 的过期反馈;按 material_family + is_foam 锚定;
|
||||
按 scheme_axis 聚合(adopted/rejected/adjust 计数 + 加权 confidence)。
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
# 同上:expires_at 是 naive TIMESTAMP,SQL 参数也不能传 aware datetime
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
is_foam_str = "true" if is_foam else "false"
|
||||
mat_lower = (material_name or "").lower()
|
||||
if "al" in mat_lower and "si" in mat_lower:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -142,7 +142,11 @@ async def authenticate_user(db_session: AsyncSession, username: str, password: s
|
||||
if not verify_password(password, user.hashed_password):
|
||||
return None
|
||||
|
||||
user.last_login = datetime.now(timezone.utc)
|
||||
# last_login 列是 TIMESTAMP WITHOUT TIME ZONE(sa.DateTime()),asyncpg 拒绝写入
|
||||
# 带 tzinfo 的 datetime(DataError → 登录 500):批次5 弃用清理把 utcnow() 换成
|
||||
# aware 时间后,任何一次成功登录都会在 commit 处炸掉。存库统一 naive UTC,
|
||||
# JWT exp(上方 create_access_token)不受影响,仍用 aware。
|
||||
user.last_login = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
await db_session.commit()
|
||||
|
||||
return user
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user