Compare commits

...

21 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
cjw 021bf311c1 fix(migrations): 删掉 fingerprint 的 GIN 索引——json 类型无 GIN 操作符类,建表必炸
fingerprint 列是 sa.JSON() → PG json 类型,GIN 只支持 jsonb,
CREATE INDEX ... USING GIN (fingerprint) 直接报 UndefinedObject。
且代码侧并无 JSON 包含查询(聚合过滤在 Python 侧,DB 走
stp_axis_status 复合索引),索引本身无用。

首次对生产库执行此迁移时因此报错、整个 upgrade 事务回滚
(2026-09-26)。已在本机修复后重新 upgrade head 成功。
未来若需 JSON 检索:先把列迁为 jsonb 再建 GIN。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 22:57:54 +08:00
cjw f1d6a78f8a fix(auth): 登录 500——asyncpg 拒绝 aware datetime 写入 naive TIMESTAMP 列
批次5(64dc85b)datetime 弃用清零把 utcnow() 换成 aware 的
now(timezone.utc),而 users.last_login 列是 TIMESTAMP WITHOUT TIME
ZONE(sa.DateTime()),asyncpg 编码时抛 DataError → 未捕获 →
Starlette 纯文本 500。症状:密码错误正常 401,密码正确反而 500
(错误密码在 commit 前已 return)。

已在生产库实测确认(事务回滚零写入):aware 写入 REJECTED、
naive ACCEPTED、过去 1 小时 last_login 零记录。

- auth_service: last_login 改存 naive UTC(.replace(tzinfo=None)),
  JWT exp 不受影响仍用 aware
- experience_feedback_service: expires_at 同为 naive 列,提交/查询
  两处 aware now 一并修掉(线上尚无此表,属前瞻性修复)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 22:29:17 +08:00
cjw 9e52c95400 X 2026-09-26 21:40:48 +08:00
cjw 0c69d4c347 x 2026-09-26 21:34:22 +08:00
cjw 62fd22cb31 x 2026-09-26 21:23:04 +08:00
cjw d8d4deffd1 fix(deploy): .env 容器内连宿主基础设施走 172.17.0.1(bridge 网关)
原 .env 用宿主机 hostname 'szcjw' 作为 DB_HOST/REDIS_HOST/RUSTFS_ENDPOINT
主机名,容器内无此 hostname → DNS 失败 → 后端启动后任何 DB 操作报
ConnectionRefusedError: [Errno 111](上传 /api/upload 时触发 500)。

修法:全部改 172.17.0.1(Linux Docker bridge 网关固定值)。
部署机若网关不同(vpn/podman 等),用
  docker network inspect bridge --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
查实际值后替换。

Co-Authored-By: Claude Code <noreply@anthropic.com>
EOF
2026-09-26 21:22:00 +08:00
cjw 65b33609d6 fix(deploy): frontend nginx client_max_body_size 1m→100M(修 /api/upload 413)
上传 STP 文件被 nginx 默认 1m 限制挡住,返回 413 Content Too Large。
与 .env MAX_FILE_SIZE=104857600(100MB)对齐;同步把 proxy_read/send_timeout
提到 300s,避免大文件反代中途断流。

部署机还需在外层 nginx(gemj.cn:443 那台)也加:
  client_max_body_size 100M;
否则外层 nginx 会先于容器返回 413。
2026-09-26 21:18:16 +08:00
cjw 3615902e8d fix(deploy): .env 显式写 BACKEND_PORT=(空值),backend 不再随机分配宿主机端口 2026-09-26 21:14:19 +08:00
cjw f0110df164 🔧 build(deploy): .env / .env.example 补 FRONTEND_PORT=10003 + 端口段重写
部署机实测:docker compose up -d --build 报
WARN[0000] The "FRONTEND_PORT" variable is not set. Defaulting to a blank string.
根因:之前所有 compose 端口相关 commit 只改 .env.example,未同步 .env;
服务器 .env 缺失 FRONTEND_PORT 与 BACKEND_PORT,前端容器端口因此暴露到
docker 自动分配的 32769,backend 暴露到 32770。

修复:
- .env 端口段补全:FRONTEND_PORT=10003 + BACKEND_PORT(注释留空)
- .env.example 端口段重写:与 .env 对齐,强化 DEPLOYMENT.md §1.2 选择 A
  约定(前端独占宿主端口、backend 不暴露)
- 移除冗余的 HOST/PORT(uvicorn compose 命令硬编码 8000/8001,
  直跑场景由 --host/--port 控制;env 注入未读取,删除减少心智负担)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 21:11:03 +08:00
cjw d7f92f1816 📝 docs(deploy): DEPLOYMENT §1.2 写入端口约定——选择 A 为默认
约定:unified 模式下前端独占宿主端口,backend 不暴露宿主端口,
浏览器始终只面对一个源,由前端 Nginx 同域反代到 backend,彻底
消除 CORS。BACKEND_PORT 留空 = 不暴露,仅 docker 网络内可达。

§1.2 补全:.env 最小集(FRONTEND_PORT=10003)、端口链路示意、
何时选 B(临时调试 / 压测 / k8s 健康检查,不建议常规生产用,会
引入 CORS 与攻击面问题)、缺配置 fail-fast 的硬约束说明,
并指向 .env.example 与 PORT_CONFIG.md 详细配置。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 21:03:49 +08:00
cjw a889671bb5 🔧 build(deploy): 取消宿主端口映射的默认值——.env 必须显式配置
三个 compose 文件的 ports 映射去掉 ${VAR:-默认值} 兜底:

- docker-compose.yml: frontend ${FRONTEND_PORT:-10003} → ${FRONTEND_PORT}
- docker-compose.moldinsight.yml: moldinsight ${MOLDINSIGHT_PORT:-10003} → ${MOLDINSIGHT_PORT}
- docker-compose.inventory.yml: inventory ${INVENTORY_PORT:-10004} → ${INVENTORY_PORT}

端口必须由 .env 显式配置,否则 compose 启动期 fail-fast(避免悄悄用了
某个端口导致宿主端口冲突或调试时困惑)。

注:DB_PORT:5432 / REDIS_PORT:6379 等应用行为默认值保留(与"宿主机
端口"不同,属基础设施默认端口,不影响端口暴露策略)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 21:00:59 +08:00
cjw aaf887507f 🐛 fix(deploy): backend 宿主机端口受 .env BACKEND_PORT 控制
上一版 backend 写死 expose: ["8000"](不暴露宿主),但这违背"全局服
务的最终映射到宿主机只能受 .env 中的端口配置决定"的原则——调试场景下
.env 没办法把 backend 暴露到宿主。

修正:backend ports 改为 "${BACKEND_PORT:-}:8000",由 .env 决定:
- BACKEND_PORT 留空/未设 = 不暴露宿主端口(仅经前端 /api 反代)
- BACKEND_PORT=10003 = 暴露宿主 10003(调试 / 压测用)

.env.example 同步加注释说明。

验证:模拟 .env 两种场景下,端口映射完全由 BACKEND_PORT 决定。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 20:58:10 +08:00
cjw b934b737e8 🐛 fix(deploy): 修 unified backend 与 frontend 抢占宿主 10003 冲突
上一版把 frontend 与 backend 都映射 10003:8000,会触发 docker 启动时
"bind: address already in use"。修正:

- docker-compose.yml backend ports 改为 expose: ["8000"]——不映射宿主机,
  仅在 docker 网络 gemold_network 内被 frontend 经 backend:8000 反代访问
- .env.example 删除 BACKEND_PORT 字段(不再需要)
- PORT_CONFIG / DEPLOY_PORT 端口映射表/示例同步:unified backend 不暴露
  宿主机端口,统一经前端 /api 反代

验证:yaml 渲染后无任何 service 对抢宿主端口(unified 仅 frontend 暴露
$FRONTEND_PORT=10003;moldinsight-only 仅 moldinsight 暴露 10003;
inventory-only 仅 inventory 暴露 10004)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 20:56:20 +08:00
cjw cf465d28e2 🔧 build(deploy): frontend 容器内端口 80→8000——避免占用宿主 80
- deploy/Dockerfile.frontend EXPOSE 80 → 8000
- deploy/nginx/frontend.conf listen 80 → 8000
- docker-compose.yml frontend ports "${FRONTEND_PORT:-10003}:80" → ":8000"

容器内端口统一 8000 系列(backend=8000、inventory=8001、frontend=8000),
frontend 不再抢占 80(留给外层 nginx/监控);1024+ 无需 root 权限。
宿主机端口(10003)保持不变。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 20:53:21 +08:00
cjw e65dcc2d39 🔧 build(deploy): 端口默认值统一 10003/10004——前端入口固定 10003
约定"容器内部端口无所谓,重要的是宿主机端口;前端 = 10003"。

- .env.example 端口段重写:移除冗余 HOST/PORT(uvicorn 命令硬编码,
  无人读 env)+ 移除误导注释;FRONTEND_PORT/BACKEND_PORT/MOLDINSIGHT_PORT
  默认 10003,INVENTORY_PORT 默认 10004
- docker-compose.yml frontend 默认 80→10003、backend 8000→10003,删除
  backend service 内冗余 HOST/PORT env
- docker-compose.moldinsight.yml / docker-compose.inventory.yml 默认
  端口同步
- DEPLOY_PORT §3 / PORT_CONFIG §1 §2 端口映射示例同步
- STATUS 补录

验证:yaml 渲染端口映射 unified frontend 10003→80、backend 10003→8000,
moldinsight 10003→8000,inventory 10004→8001。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-26 20:45:38 +08:00
22 changed files with 357 additions and 118 deletions
+13 -6
View File
@@ -7,7 +7,13 @@ HOST=0.0.0.0
MOLDINSIGHT_PORT=10003
# inventory API 对外端口
INVENTORY_PORT=10004
# 应用内部监听端口(通常无需修改)
# unified 模式下前端 Nginx 对外端口(浏览器入口)
FRONTEND_PORT=10003
# unified 模式 backend 是否暴露宿主端口:留空 = 不暴露(仅经前端 /api 反代)
BACKEND_PORT=
# 应用内部监听端口(通常无需修改;uvicorn 命令硬编码 8000/8001)
# PORT 与 HOST 仅在直跑 uvicorn 时生效,compose 容器内 uvicorn 不读这两个 env
HOST=0.0.0.0
PORT=10003
# ================================
@@ -25,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
@@ -44,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
+10 -15
View File
@@ -1,22 +1,17 @@
# 服务配置
HOST=0.0.0.0
# ================================
# 模块 API 对外端口
# 端口配置 — DEPLOYMENT.md §1.2 选择 A 为默认(前端独占宿主端口,backend 不暴露)
# ================================
# 前端 Nginx 对外端口
FRONTEND_PORT=80
# unified backend 对外端口
BACKEND_PORT=8000
# gemold(moldinsight)API 对外端口(独立部署时使用)
MOLDINSIGHT_PORT=8000
# inventory API 对外端口(独立部署时使用)
INVENTORY_PORT=8001
# 容器内部端口统一 8000(frontend=8000 / backend=8000 / inventory=8001);
# 服务间通过 docker 网络 gemold_network 上的服务名(如 backend:8000)互通。
# 宿主机端口由本文件强制配置,compose 无默认值兜底:
FRONTEND_PORT=10003 # 浏览器入口(必填,缺则 compose 启动期 fail-fast)
MOLDINSIGHT_PORT=10003 # moldinsight-only 独立部署(必填)
INVENTORY_PORT=10004 # inventory-only 独立部署(必填)
# unified 模式 backend 是否暴露宿主端口:留空 = 不暴露(仅经前端 /api 反代),
# 设值(如 10005)= 直接暴露(调试 / 压测用,注意 10003 已被 frontend 占用)
BACKEND_PORT=
# ================================
# 应用内部监听端口(通常无需修改;compose 内已固定为 8000/8001)
PORT=8000
DEBUG=false
# 日志配置
+2 -1
View File
@@ -7,7 +7,8 @@ COPY frontend/ ./
RUN npm run build
FROM nginx:1.27-alpine
# 注意:容器内 nginx listen 改为 8000(避免占用宿主 80;1024+ 无 root 限制)
COPY deploy/nginx/frontend.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/frontend/dist /usr/share/nginx/html
EXPOSE 80
EXPOSE 8000
+8 -1
View File
@@ -3,9 +3,16 @@ upstream gemold_backend_upstream {
}
server {
listen 80;
listen 8000;
server_name _;
# 与 .env MAX_FILE_SIZE=104857600(100MB)对齐;nginx 默认 1m 会直接 413
client_max_body_size 100M;
# 大文件上传给后端足够时间(默认 60s,100MB 可能不够)
proxy_read_timeout 300s;
proxy_send_timeout 300s;
root /usr/share/nginx/html;
index index.html;
+1 -1
View File
@@ -38,7 +38,7 @@ services:
container_name: gemold_inventory
command: ["python", "-m", "uvicorn", "entrypoints.inventory:app", "--host", "0.0.0.0", "--port", "8001"]
ports:
- "${INVENTORY_PORT:-8001}:8001"
- "${INVENTORY_PORT}:8001"
environment:
<<: *inventory_env
HOST: 0.0.0.0
+10 -5
View File
@@ -57,7 +57,7 @@ services:
container_name: gemold_moldinsight
command: ["python", "-m", "uvicorn", "entrypoints.moldinsight:app", "--host", "0.0.0.0", "--port", "8000"]
ports:
- "${MOLDINSIGHT_PORT:-8000}:8000"
- "${MOLDINSIGHT_PORT}:8000"
environment:
<<: *base_env
HOST: 0.0.0.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 就绪后再启动
+16 -8
View File
@@ -61,7 +61,8 @@ services:
image: gemold-frontend:latest
container_name: gemold_frontend
ports:
- "${FRONTEND_PORT:-80}:80"
# 浏览器入口端口(无默认值:必须由 .env 中 FRONTEND_PORT 显式配置)
- "${FRONTEND_PORT}:8000"
depends_on:
- backend
restart: unless-stopped
@@ -75,12 +76,13 @@ services:
image: gemold-backend:latest
container_name: gemold_backend
command: ["python", "-m", "uvicorn", "entrypoints.unified:app", "--host", "0.0.0.0", "--port", "8000"]
# 宿主机端口由 .env 的 BACKEND_PORT 决定:空/不设则不暴露宿主机端口
# (仅经前端 /api 反代同域访问,避免与 frontend 抢占宿主 10003)。
# 调试时设 BACKEND_PORT=10003 即可独立访问。
ports:
- "${BACKEND_PORT:-8000}:8000"
- "${BACKEND_PORT:-}:8000"
environment:
<<: *base_env
HOST: 0.0.0.0
PORT: "8000"
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD 未配置:请在 .env 中设置}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@gemold.com}
@@ -107,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:
+22 -1
View File
@@ -28,7 +28,28 @@
> **模式切换唯一入口是 `-f` 文件名**。各 service 均未声明 `profiles`(compose 规则:声明了 profiles 的服务在不带 `--profile` 时不会被选中,裸 `up` 会报 `no service selected`);历史 `--profile full/moldinsight/inventory` 写法随本次拆分失效,请统一改用上表命令。
### 1.2 镜像构建
### 1.2 宿主机端口约定(默认 = 选择 A)
部署约定:**unified 模式下前端独占宿主端口,backend 不暴露宿主端口**——浏览器始终只面对一个源,由前端 Nginx 同域反代到 backend,彻底消除 CORS。
```env
# .env(unified 模式最小集)
FRONTEND_PORT=10003 # 浏览器入口;前端 Nginx 容器监听 8000,反代 /api 到 backend:8000
# BACKEND_PORT 留空或不设 → backend 仅在 docker 网络 gemold_network 内被前端反代访问
```
端口链路:
```
浏览器 → http://宿主机:10003 → frontend容器:8000 → /api/* → backend容器:8000
(宿主机 10003) (docker 网络内)
```
何时选 B(前后端都暴露宿主端口):临时直连后端调试、压测、k8s 健康检查等特殊场景。设 `BACKEND_PORT=10005`(避开 10003)后重启 compose 即可——**不建议在常规生产部署中使用**,会引入 CORS 与攻击面问题。
宿主机端口映射由 `.env` 强制配置,compose 无默认值兜底(缺配置时启动期 fail-fast)。详见 [.env.example §端口配置](../.env.example)、[docs/deployment/PORT_CONFIG.md](deployment/PORT_CONFIG.md)。
### 1.3 镜像构建
首次部署或更新代码后先构建,再 `up`:
+4
View File
@@ -4,6 +4,10 @@
> 维护规则:每完整完成一个需求,**倒序在本文顶部加一条**(日期 + 主题 + 关键事实);其余主文档(架构 / 规划 / 技术债 / 部署)维护各自的"当前有效说法",本文只记录"什么时候做到了哪一步"。维护规则出处见根目录 [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 启动命令端到端可用。)
>
> 2026-09-24(**Compose 按部署模式拆分为三个一键文件 + 文档全量同步**:① 单文件 profile 编排拆为"模式 ↔ 文件名"一一对应的三文件——[docker-compose.yml](../docker-compose.yml)(unified 默认入口:frontend + backend + moldinsight-celery,`docker compose up -d` 即起)+ [docker-compose.moldinsight.yml](../docker-compose.moldinsight.yml)(moldinsight-only:独立 API + celery)+ [docker-compose.inventory.yml](../docker-compose.inventory.yml)(inventory-only:仅 inventory,不声明任何命名卷避免空卷);② **服务不再声明 `profiles`**——compose 规则是声明了 profiles 的服务在裸 `up` 下不会被选中(拆分首版保留 profiles 导致裸 `up` / 裸 `-f` 均报 `no service selected`,部署机实测暴露后移除),模式切换唯一入口是 `-f` 文件名,历史 `--profile full/moldinsight/inventory` 写法随拆分失效(其目标服务本就已移出默认文件,兼容无意义);③ **顺手修复两个既有部署隐患**——moldinsight-only 场景 celery 的 `depends_on` 悬空(原指向被 profile 过滤掉的 `backend`,现各文件内分别指向 `backend` / `moldinsight`),以及 `gemold-moldinsight:latest` 与 `gemold-backend:latest` 双 tag 漂移(moldinsight service 的 image 统一为 `gemold-backend:latest`,与 [Dockerfile.celery](../deploy/Dockerfile.celery) 的 FROM 对齐,干净环境单跑 moldinsight-only 不再构建失败);④ `gemold_network` / `uploads_data` / `html_data` 加 `name:` 固定命名,跨文件 / 跨模式可复用;每文件内部以 YAML anchor(`x-base-env`)收敛 35+ 行重复 environment,`SECRET_KEY` / `ADMIN_PASSWORD` 的 `${VAR:?}` fail-fast 校验保留;⑤ 文档同步 11 文件:[DEPLOYMENT.md](DEPLOYMENT.md) §1.1 新增一键部署总表 + §2 三模式各附文件名与一键命令,[deployment/LINUX_SETUP.md](deployment/LINUX_SETUP.md) §6/§11 重写,[README.md](../README.md) 快速开始与 Compose 入口、[OPERATIONS.md](OPERATIONS.md) §4、[deploy/build.sh](../deploy/build.sh) / [.bat](../deploy/build.bat) 末尾提示、PORT_CONFIG / DEPLOY_PORT / STORAGE_SETUP / frontend/README 链接全部对齐(`AGENTS.md` §4.1 部署方式→DEPLOYMENT 同步规则满足);⑥ **部署机首次实测再暴露并修复两个干净机器构建必挂点**——(a) [.dockerignore](../.dockerignore) 自"重写独立dockerfile"起排除整个 `deploy/`,而 Dockerfile.frontend 要 COPY `deploy/nginx/frontend.conf`、Dockerfile.moldinsight 要 COPY `deploy/requirements-*.txt`(历史一直有旧镜像兜底未暴露;BuildKit 不支持重包含被排除目录的子文件,直接移除该行,deploy/ 仅几 KB 无上下文负担);(b) `Dockerfile.celery` `FROM gemold-backend:latest` 在 compose 并行构建下引用尚不存在的本地镜像必挂——**删除 Dockerfile.celery**,`moldinsight-celery` 改为与 API 服务**同一 build 声明 + 同一 `gemold-backend:latest` tag**(compose 去重只构建一次),celery 仅以 `command:` 覆盖启动 worker(`--concurrency` / `--max-tasks-per-child` 参数经 compose 命令与 `.env` 透传,语义不变),build.sh/.bat 移除 gemold-celery 构建步骤,OPERATIONS / OCC_THROUGHPUT / TECH_DEBT / .env.example 的 Dockerfile.celery 指向同步改写;⑦ **镜像 base 由 miniconda 切换 Miniforge**——[Dockerfile.moldinsight](../deploy/Dockerfile.moldinsight) FROM `continuumio/miniconda3:24.7.1-0` → `condaforge/miniforge3:24.7.1-2`(conda-forge 默认且唯一渠道,无 defaults 渠道与 Anaconda ToS 顾虑;与 CI 已用的 Miniforge 安装、开发机 Miniforge 同源;tag 经 Docker Hub 社区用例确认存在;conda create 步骤与 python=3.12 / pythonocc-core=7.9.0 锁定不变),[TECH_DEBT.md](TECH_DEBT.md) D13 锁定记录同步。**验证**:三文件 YAML 解析 + 结构静态校验通过(services / depends_on / 卷声明 / anchor 合并 / 网络命名 / 无 profiles 残留 / celery 与 API 服务 build 声明一致性);5 个 service 的 environment 键与拆分前逐一比对(YAML 展开合并键后 39/39、34/34、39/39、34/34、20/20)零丢失。**遗留**:部署机 `git pull` 后裸 `docker compose up -d` 端到端复验;base 镜像(miniconda3 / node / nginx)拉取依赖 docker.io 连通性,不通时需配镜像加速。
+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 命中结果取决于负载均衡,跨副本文件不共享。
+9 -7
View File
@@ -68,24 +68,26 @@
- [docker-compose.moldinsight.yml](../../docker-compose.moldinsight.yml)
- [docker-compose.inventory.yml](../../docker-compose.inventory.yml)
关键端口映射:
关键端口映射(默认 10003 / 10004,详见 [.env.example](../../.env.example)):
- `FRONTEND_PORT` → frontend Nginx 外部端口
- `BACKEND_PORT` → unified backend 外部端口
- `FRONTEND_PORT` → frontend Nginx 外部端口(浏览器入口,推荐直接访问)
- unified backend → **不暴露宿主机端口**,经前端 /api 反代同域访问(docker 网络内 `backend:8000` 互通)
- `MOLDINSIGHT_PORT` → moldinsight-only 模式宿主机端口
- `INVENTORY_PORT` → inventory-only 模式宿主机端口
- `MOLDINSIGHT_PORT` → moldinsight-only 独立部署端口
- `INVENTORY_PORT` → inventory-only 独立部署端口
示例:
```env
MOLDINSIGHT_PORT=8000
INVENTORY_PORT=8001
MOLDINSIGHT_PORT=10003
INVENTORY_PORT=10004
```
对应 compose 行为:
- moldinsight:`${MOLDINSIGHT_PORT:-8000}:8000`
- inventory:`${INVENTORY_PORT:-8001}:8001`
- moldinsight:`${MOLDINSIGHT_PORT:-10003}:8000`
- inventory:`${INVENTORY_PORT:-10004}:8001`
---
+8 -8
View File
@@ -27,10 +27,10 @@
核心环境变量:
```env
FRONTEND_PORT=80
BACKEND_PORT=8000
MOLDINSIGHT_PORT=8000
INVENTORY_PORT=8001
FRONTEND_PORT=10003 # unified 模式浏览器入口(前端 Nginx 对外)
MOLDINSIGHT_PORT=10003 # moldinsight-only 独立部署时使用
INVENTORY_PORT=10004 # inventory-only 独立部署时使用
# 注意:unified backend 不暴露宿主机端口,仅经前端 /api 反代(docker 网络内部 backend:8000 互通)
```
其余基础设施通常为:
@@ -52,10 +52,10 @@ RUSTFS_ENDPOINT=http://localhost:9000
| 变量 / 端口 | 用途 |
|---|---|
| `FRONTEND_PORT` | 前端 Nginx 宿主机暴露端口 |
| `BACKEND_PORT` | unified backend 宿主机暴露端口 |
| `MOLDINSIGHT_PORT` | moldinsight-only 独立部署端口 |
| `INVENTORY_PORT` | inventory-only 独立部署端口 |
| `FRONTEND_PORT`(默认 10003) | 前端 Nginx 宿主机暴露端口(浏览器入口) |
| unified backend | **不暴露宿主机端口**——经前端 /api 反代,docker 网络内 `backend:8000` 互通 |
| `MOLDINSIGHT_PORT`(默认 10003) | moldinsight-only 独立部署端口 |
| `INVENTORY_PORT`(默认 10004) | inventory-only 独立部署端口 |
| `DB_PORT` | PostgreSQL 端口 |
| `REDIS_PORT` | Redis 端口 |
| `9000` | MinIO/RustFS S3 兼容 API |
@@ -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:
+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:
+5 -1
View File
@@ -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
+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"