Compare commits
2 Commits
4537faf2c4
...
e728dcd226
| Author | SHA1 | Date | |
|---|---|---|---|
| e728dcd226 | |||
| 0e6b3b1811 |
@@ -88,3 +88,8 @@ LLM_API_KEY=sk-your-api-key
|
||||
LLM_MODEL=gpt-4o-mini
|
||||
LLM_TIMEOUT=60
|
||||
LLM_MAX_TOKENS=2000
|
||||
|
||||
# Celery/OCC 吞吐调优(可选,默认值见 deploy/Dockerfile.celery;
|
||||
# concurrency 即 OCC 并行分析数,见 docs/topics/performance/OCC_THROUGHPUT.md)
|
||||
# CELERY_CONCURRENCY=2
|
||||
# CELERY_MAX_TASKS_PER_CHILD=50
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# CI 门禁(Gitea Actions,语法与 GitHub Actions 兼容)
|
||||
#
|
||||
# 三个 job:
|
||||
# 1. backend-tests —— pytest 全量(sqlite+aiosqlite,无外部服务依赖;
|
||||
# OCC 契约测试在无 pythonocc 的 pip 环境自动 skip)
|
||||
# 2. frontend-build —— npm ci + npm run build(含 vue-tsc -b 类型检查,D15 已修复)
|
||||
# 3. openapi-drift —— 用 OCC 环境重导出 openapi.json 与仓库版本比对,
|
||||
# 防止接口变更三件套(AGENTS §2)被遗漏导致前后端漂移
|
||||
#
|
||||
# 运行前提:Gitea 实例启用 Actions 且注册了 runner;
|
||||
# `ubuntu-latest` label 需映射到带 node/git 的镜像(gitea runner 默认映射满足)。
|
||||
# 已验证事实:pytest 与 openapi 导出均不依赖 .env(settings 惰性校验)。
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
backend-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install dependencies
|
||||
# requirements.txt 自带阿里云 pip 镜像配置
|
||||
run: pip install -r requirements.txt
|
||||
- name: Run tests
|
||||
run: python -m pytest tests/ -q
|
||||
|
||||
frontend-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
- name: Type check and build
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
|
||||
openapi-drift:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Miniforge and pythonocc
|
||||
# pythonocc-core 仅经 conda-forge 提供(与 deploy/Dockerfile.moldinsight 同版本约束)
|
||||
# 默认走 TUNA 镜像(Gitea 服务器多为国内网络);海外环境可换回官方源
|
||||
run: |
|
||||
wget -q https://mirrors.tuna.tsinghua.edu.cn/github-release/conda-forge/miniforge/LatestRelease/Miniforge3-Linux-x86_64.sh -O miniforge.sh
|
||||
bash miniforge.sh -b -p "$HOME/miniforge"
|
||||
"$HOME/miniforge/bin/conda" create -n ci -c https://mirrors.tuna.tsinghua.edu.cn/conda-forge -y python=3.12 pythonocc-core=7.9.0
|
||||
- name: Install python dependencies
|
||||
run: "$HOME/miniforge/envs/ci/bin/pip" install -r requirements.txt
|
||||
- name: Export openapi.json and compare with committed version
|
||||
run: |
|
||||
"$HOME/miniforge/envs/ci/bin/python" -c "import sys; sys.path.insert(0, 'src'); from entrypoints.unified import app; import json; print(json.dumps(app.openapi(), ensure_ascii=False, indent=2))" > /tmp/openapi.json
|
||||
git diff --exit-code --no-index /tmp/openapi.json openapi.json || {
|
||||
echo "::error::openapi.json 与代码不一致——请按 docs/API_CONTRACT.md §4 重导出并执行 npm run gen:api"
|
||||
exit 1
|
||||
}
|
||||
@@ -40,15 +40,21 @@ src/
|
||||
unified.py # 双模块统一入口:/api 挂 moldinsight + inventory,当前推荐后端
|
||||
moldinsight/ # 【模具分析模块】
|
||||
api/
|
||||
__init__.py # router 聚合:_safe_include 按序挂子 router,失败仅 WARNING 跳过;debug_router 仅 settings.DEBUG 挂载
|
||||
health_router.py # /api/health 模块健康检查
|
||||
__init__.py # router 聚合:ROUTE_MODULES 清单 + _safe_include 挂载,失败登记 route_registry(/api/health 呈现 degraded,DEBUG 下 fail fast);debug_router 仅 settings.DEBUG 挂载
|
||||
route_registry.py # 路由装载注册表(loaded / failed / disabled,health_router 引用)
|
||||
health_router.py # /api/health 模块健康检查(含真实 pythonocc 探测与路由装载状态)
|
||||
upload_router.py # /api/upload STEP/STP 上传
|
||||
batch_router.py # /api/batch-upload 批量上传与分析
|
||||
task_router.py # /api/status/{task_id} 任务状态查询
|
||||
history_router.py # /api/history 分析历史与结果文件
|
||||
cam_router.py # /api/cam/plan CAM 加工方案
|
||||
aluminum_price_routes.py # /api/aluminum-price/* 铝价(当前为模拟/参考数据,见 TECH_DEBT D2)
|
||||
advanced_router.py # 导出 / 成本估算 / 设计分析等高级接口(技术债 D1:待拆分 + 请求模型化)
|
||||
cam_router.py # /api/cam/plan CAM 加工方案(Pydantic 请求模型 + to_thread)
|
||||
design_router.py # 设计类接口:/optimize-layout /design-* /detect-undercuts(原 advanced_router,D1 拆分)
|
||||
cost_router.py # /cost-estimate 成本估算(原 advanced_router)
|
||||
machining_router.py # 加工类接口:/design-cam /check-collision /optimize-toolpath /design-electrodes /simulate-machining
|
||||
export_router.py # 导出类接口:/export-mold /export-download /export-recommendations
|
||||
core_modules.py # 核心计算模块惰性装载器(设计/加工路由共用,装载失败 503)
|
||||
aluminum_price_routes.py # /api/aluminum-price/* 铝价(模拟数据,响应带 source: "simulated",见 TECH_DEBT D2)
|
||||
html_report_router.py # GET /html/{filename} 报告代理(根路径挂载:RustFS 报告键 → 遗留 JSON 包装 → 本地卷兜底;include_into 由入口调用)
|
||||
debug_router.py # /api/debug/tasks 全量任务 dump(仅 DEBUG 模式注册,仍需登录)
|
||||
core/ # 几何与方案核心算法(OCC 重依赖区)
|
||||
stp_parser.py # STEP/STP 解析
|
||||
@@ -66,20 +72,25 @@ src/
|
||||
cavity_layout_optimizer.py # 型腔布局优化
|
||||
mold_machining.py / mold_cam.py # 加工与 CAM
|
||||
mold_quality_inspector.py # 质量检查
|
||||
cad_exporter.py # CAD 导出
|
||||
cad_exporter.py # CAD 导出(export_mold_results 随方案 B 已删;export_step 等供 OCC 子进程持久化/转换)
|
||||
occ_worker.py # OCC 常驻工作进程入口:操作注册表(parse_stp/generate_mesh/generate_cavity/analyze_mold_design/detect_undercuts/convert_component_step/ping/sleep/warmup)+ worker_main 消息循环
|
||||
services/ # 业务服务层
|
||||
task_dispatcher.py # 后台任务统一分派(勿绕过它 fire-and-forget)
|
||||
task_query_service.py # 任务状态查询聚合
|
||||
processing_service.py # 分析处理编排
|
||||
processing_service.py # 分析处理编排(run_occ 经常驻 OCC 进程池调度,见 occ_process_pool.py / OCC_THROUGHPUT.md)
|
||||
occ_process_pool.py # OCC 常驻工作进程池(方案 B):超时/崩溃 terminate 换新补位;任务级超时 recover 整体重建
|
||||
calculation_service.py # 计算服务
|
||||
cost_estimate_service.py # 成本估算
|
||||
cam_bundle_service.py # CAM 结果打包
|
||||
verification_service.py # FreeCAD 验证(可选)
|
||||
shape_loader.py # shape 加载(含 OCC 超时后 executor 重建逻辑)
|
||||
stp_materializer.py # 按 task_id 把 STP 原件落盘临时文件(OCC 解析在子进程内,形状不跨进程)
|
||||
material_service.py # 物料价格服务
|
||||
aluminum_price_service.py # 铝价服务(模拟数据)
|
||||
llm_service.py # LLM 增强分析(可选,OpenAI 兼容)
|
||||
storage_integration_rustfs.py # RustFS 存储集成
|
||||
task_storage_service.py # STP 文件与处理任务生命周期存储(D9:数据写 flush-only,状态更新即时 commit)
|
||||
analysis_storage_service.py # 分析结果数据存储(几何/网格/型腔/HTML/特征)与任务数据视图组装
|
||||
file_history_service.py # 按文件名聚合的上传历史查询视图
|
||||
models/ # moldinsight 域 ORM(stp_analysis.py:stp_files 及各阶段产物 + processing_tasks,共 9 表)
|
||||
storage/
|
||||
rustfs_storage.py # RustFS/MinIO 客户端封装
|
||||
init_storage.py # 存储初始化
|
||||
@@ -87,20 +98,22 @@ src/
|
||||
api/ # 每域一个 routes 文件:product / supplier / customer / warehouse / inventory / stock_movement / purchase_order / sales_order / purchase_demand / finance / dashboard / material
|
||||
schemas/ # 每域一个 Pydantic schema 文件(与 api 一一对应)
|
||||
services/ # 领域服务:inventory / purchase_order / sales_order / finance / purchase_demand / stock_movement
|
||||
models/ # inventory 域 ORM(catalog / warehouse / trading / finance 四文件,共 15 表)
|
||||
utils.py
|
||||
shared/ # 【共享平台层:只放真正跨模块复用的基础能力,勿堆业务】
|
||||
app_factory.py # create_app:request_id 日志中间件 / auth_router / /health / SPA fallback / /html mount
|
||||
app_factory.py # create_app:request_id 日志中间件 / auth_router / /health / SPA fallback / connect_rustfs 开关(D11 后 /html 由 moldinsight 代理路由提供,不再挂本地 StaticFiles)
|
||||
config/settings.py # Settings 单例:dotenv + os.getenv;DB_*/SECRET_KEY 惰性校验无默认
|
||||
database/database.py # async engine / session / get_db_session
|
||||
database/init_db.py # 建表与管理员种子
|
||||
models/database.py # 全量 ORM(identity + moldinsight + inventory 三类同居一处——当前最强耦合点,见 ARCHITECTURE §6)
|
||||
models/base.py # 唯一 ORM Base + 模型归属约定(跨模块只许裸 FK,禁跨模块 relationship)
|
||||
models/identity.py # 身份与权限 ORM:User/Role/Permission/UserRole/RolePermission/UserActivity/SystemLog
|
||||
models/schemas.py # 共享 Pydantic 模型
|
||||
services/auth_routes.py # /api/auth/* 认证用户角色权限路由
|
||||
services/auth_service.py # JWT 签发校验 + get_current_active_user 依赖
|
||||
services/redis_task_manager.py # Redis 任务状态(Hash 字段级原子更新,兼容旧 string)
|
||||
utils/logger.py # 结构化日志(json/text)+ request_id
|
||||
utils/file_handler.py # 上传文件处理
|
||||
utils/html_generator.py # /html 静态分析报告生成
|
||||
utils/html_generator.py # 可视化报告生成(HTML/摘要/数据 JSON;产物写任务临时目录,由 moldinsight 上传 RustFS 报告键)
|
||||
celery_app.py # Celery app(Redis broker,task_acks_late)
|
||||
celery_tasks.py # moldinsight 异步分析任务
|
||||
frontend/ # Vue 3 独立工程:src/modules 按域组织(moldinsight/inventory/users/login/home);src/types/api.ts 为 openapi 生成物,勿手改
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
FROM gemold-moldinsight:latest
|
||||
# Celery Worker 与 unified 后端共用同一运行时镜像(自包含,批次 1 起)。
|
||||
# 此前 FROM gemold-moldinsight:latest 与 compose/build.sh 构建的
|
||||
# gemold-backend:latest 不一致,干净环境下 celery 镜像构建必然失败。
|
||||
FROM gemold-backend:latest
|
||||
|
||||
CMD ["celery", "-A", "celery_app", "worker", "--workdir=/app/src", "--concurrency=2", "--loglevel=info"]
|
||||
# OCC 并行度伸缩(OCC_THROUGHPUT 方案 A,见 docs/topics/performance/OCC_THROUGHPUT.md):
|
||||
# 每个 prefork 子进程各持一个串行 OCC 通道,concurrency 即并行分析数
|
||||
# (调大时预算好每子进程内存与 PG 连接数);max-tasks-per-child 让子进程
|
||||
# 定期重启,兜底回收 OCC 超时后滞留的线程。可在 compose/.env 覆盖。
|
||||
ENV CELERY_CONCURRENCY=2 \
|
||||
CELERY_MAX_TASKS_PER_CHILD=50
|
||||
|
||||
# sh -c + exec:既支持环境变量替换,又让 celery exec 接管 PID 1 正确接收 SIGTERM
|
||||
CMD ["sh", "-c", "exec celery -A celery_app worker --workdir=/app/src --concurrency=${CELERY_CONCURRENCY:-2} --max-tasks-per-child=${CELERY_MAX_TASKS_PER_CHILD:-50} --loglevel=info"]
|
||||
|
||||
@@ -34,7 +34,6 @@ COPY migrations/ /app/migrations/
|
||||
COPY alembic.ini /app/alembic.ini
|
||||
|
||||
COPY uploads/ /app/uploads/
|
||||
COPY html_output/ /app/html_output/
|
||||
|
||||
ENV PYTHONPATH=/app/src
|
||||
|
||||
|
||||
+8
-4
@@ -65,8 +65,8 @@ services:
|
||||
LLM_TIMEOUT: ${LLM_TIMEOUT:-60}
|
||||
LLM_MAX_TOKENS: ${LLM_MAX_TOKENS:-2000}
|
||||
AUTO_MIGRATE: ${AUTO_MIGRATE:-true}
|
||||
# 共享卷过渡兜底(D6/D11):主链路已改走 RustFS,本地卷仅为
|
||||
# RustFS 异常时的本地路径回退与 HTML 产物互通保留,后续批次移除
|
||||
# 共享卷过渡兜底(D6/D11):主链路已改走 RustFS。uploads 供 RustFS 异常时
|
||||
# 本地路径回退;html_output 仅作 /html 报告代理的存量兜底读(新产物不落本地)
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
- html_data:/app/html_output
|
||||
@@ -115,10 +115,13 @@ services:
|
||||
LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini}
|
||||
LLM_TIMEOUT: ${LLM_TIMEOUT:-60}
|
||||
LLM_MAX_TOKENS: ${LLM_MAX_TOKENS:-2000}
|
||||
# 与 backend 共享本地卷(过渡兜底,见 D6/D11):worker 下载回退与 HTML 产物写读
|
||||
# Celery/OCC 吞吐调优(OCC_THROUGHPUT 方案 A,默认值在 Dockerfile.celery)
|
||||
CELERY_CONCURRENCY: ${CELERY_CONCURRENCY:-2}
|
||||
CELERY_MAX_TASKS_PER_CHILD: ${CELERY_MAX_TASKS_PER_CHILD:-50}
|
||||
# uploads_data 共享卷(D6 过渡兜底):RustFS 异常时 worker 回退本地路径下载。
|
||||
# D11 后 worker 不再写 HTML 产物(直传 RustFS 报告键),无需 html_data 卷
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
- html_data:/app/html_output
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
@@ -177,6 +180,7 @@ services:
|
||||
LLM_TIMEOUT: ${LLM_TIMEOUT:-60}
|
||||
LLM_MAX_TOKENS: ${LLM_MAX_TOKENS:-2000}
|
||||
AUTO_MIGRATE: ${AUTO_MIGRATE:-true}
|
||||
# html_output 仅作 /html 报告代理的存量兜底读(D11,新产物不落本地)
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
- html_data:/app/html_output
|
||||
|
||||
+12
-8
@@ -15,7 +15,7 @@
|
||||
| moldinsight-only | `/api/*`(moldinsight)+ `/api/auth/*` + `/health` |
|
||||
| inventory-only | `/api/*`(inventory)+ `/api/auth/*` + `/health` |
|
||||
|
||||
- moldinsight 路由在 [moldinsight/api/\_\_init\_\_.py](../src/moldinsight/api/__init__.py) 经 `_safe_include` 聚合(子 router 加载失败仅 WARNING 跳过;`debug_router` 仅 `DEBUG=true` 注册)。
|
||||
- moldinsight 路由在 [moldinsight/api/\_\_init\_\_.py](../src/moldinsight/api/__init__.py) 按 `ROUTE_MODULES` 清单经 `_safe_include` 聚合(装载失败登记 [route_registry.py](../src/moldinsight/api/route_registry.py),`/api/health` 呈现 `degraded` 并列出失败模块;`DEBUG=true` 下失败直接抛错;`debug_router` 仅 `DEBUG=true` 注册)。
|
||||
- inventory 路由在 [inventory/api/\_\_init\_\_.py](../src/inventory/api/__init__.py) 按域静态聚合。
|
||||
- 认证路由来自 [shared/services/auth_routes.py](../src/shared/services/auth_routes.py),由 [app_factory](../src/shared/app_factory.py) 挂载,三种形态共用。
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
- **鉴权**:JWT Bearer(`Authorization: Bearer <token>`)。登录:`POST /api/auth/login`(表单)/ `POST /api/auth/login/json`(JSON);受保护路由通过 FastAPI 依赖 `get_current_active_user` 注入当前用户([shared/services/auth_service.py](../src/shared/services/auth_service.py))。`SECRET_KEY` 跨进程必须一致。
|
||||
- **响应形态**:现状**无统一信封包装**——各端点直接返回业务 JSON;schema 以 `openapi.json` 的 components 为准。新增接口不建议另起信封风格,保持与所在模块一致。
|
||||
- **错误**:FastAPI 标准 `HTTPException` 状态码语义;业务校验优先 Pydantic 请求模型自动 422。
|
||||
- **业务路由前缀**:全部业务端点在 `/api` 下;顶层仅 `/health`(探活)、`/login`、`/users`(历史遗留入口,前端主链路用 `/api/auth/*`)。
|
||||
- **业务路由前缀**:全部业务端点在 `/api` 下;顶层仅 `/health`(探活)、`/html/{filename}`(可视化报告代理)、`/login`、`/users`(历史遗留入口,前端主链路用 `/api/auth/*`)。
|
||||
|
||||
## 3. 端点总览(按域分组)
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|---|---|---|
|
||||
| 登录 | `/api/auth/login`、`/api/auth/login/json`、`/api/auth/logout` | auth_routes.py |
|
||||
| 当前用户 | `/api/auth/me` | auth_routes.py |
|
||||
| 用户管理 | `/api/auth/users`、`/api/auth/users/{user_id}`、`/api/auth/users/{user_id}/reset-password` | auth_routes.py |
|
||||
| 用户管理 | `/api/auth/users`、`/api/auth/users/{user_id}`、`/api/auth/users/{user_id}/reset-password`(管理员;JSON body `{ new_password }`,最短 6 位) | auth_routes.py |
|
||||
| 角色权限 | `/api/auth/roles`、`/api/auth/roles/{role_id}`、`/api/auth/roles/{role_id}/permissions`、`/api/auth/permissions`、`/api/auth/permissions/{permission_id}` | auth_routes.py |
|
||||
|
||||
### 3.2 moldinsight(模具分析)
|
||||
@@ -47,11 +47,15 @@
|
||||
| 批量分析 | `/api/batch-upload`、`/api/batch/{batch_id}`(聚合状态以 PG 为准;响应含 `current_step`;他人批次 403、不存在 404) | batch_router.py |
|
||||
| 任务状态 | `/api/status/{task_id}`(需登录;仅任务所有者可访问,他人/无主任务 403,不存在 404) | task_router.py |
|
||||
| 历史结果 | `/api/history`、`/api/history/{filename}` | history_router.py |
|
||||
| CAM | `/api/cam/plan` | cam_router.py |
|
||||
| HTML 报告 | `/html/{filename}`(根路径,非 `/api` 前缀;D11 代理:RustFS 报告键 `html/reports/{filename}` 直取 → 遗留 JSON 包装对象 → 本地 `html_output/` 存量兜底 → 404。已知约束:不做认证——iframe 无法携带 Authorization 头,沿用 StaticFiles 时代既定姿态) | html_report_router.py |
|
||||
| CAM | `/api/cam/plan`(Pydantic 请求模型;未提供的偏好回落任务持久化偏好再回落默认) | cam_router.py |
|
||||
| 设计 | `/api/optimize-layout`、`/api/design-cooling`、`/api/design-gating`、`/api/design-mold-system`、`/api/detect-undercuts` | design_router.py |
|
||||
| 成本估算 | `/api/cost-estimate` | cost_router.py |
|
||||
| 加工 | `/api/design-cam`、`/api/check-collision`、`/api/optimize-toolpath`、`/api/design-electrodes`、`/api/simulate-machining` | machining_router.py |
|
||||
| 导出 | `/api/export-mold`、`/api/export-download/{filepath}`、`/api/export-recommendations` | export_router.py |
|
||||
| 铝价(模拟数据) | `/api/aluminum-price/current`、`/api/aluminum-price/history` | aluminum_price_routes.py |
|
||||
| 健康检查 | `/api/health` | health_router.py |
|
||||
| 健康检查 | `/api/health`(有路由装载失败时 `status: degraded` 并列出失败清单;`pythonocc` 为真实探测) | health_router.py |
|
||||
| 调试(仅 DEBUG) | `/api/debug/tasks` | debug_router.py |
|
||||
| 导出/估算/设计等高级接口 | 见 `openapi.json` 对应路径 | advanced_router.py(技术债 D1:待拆分) |
|
||||
|
||||
### 3.3 inventory(进销存)
|
||||
|
||||
@@ -93,12 +97,12 @@
|
||||
|
||||
- `frontend/src/types/api.ts` 是**生成物,禁止手改**;前端代码类型引用它。
|
||||
- 三步缺一即前后端契约漂移(硬约束,见 [AGENTS.md](../AGENTS.md) §2)。
|
||||
- **当前已知滞后**:checked-in `openapi.json`(2026-07-27)落后当前代码(实际 76 paths vs 文件内 70),下次接口变更时按上述流程重导出。
|
||||
- 当前 `openapi.json` 于 2026-09-17 随批次 3 重导出(76 paths),前端 `src/types/api.ts` 同步再生。
|
||||
|
||||
## 5. 契约变更规则
|
||||
|
||||
- 新增接口先定模块归属(moldinsight / inventory / shared auth),再写路由;返回结构、路径、鉴权发生变化时,同步更新本文相应表格。
|
||||
- 路由文件过大按职责拆分(现状债务:`advanced_router` 待拆分,见 [TECH_DEBT.md](TECH_DEBT.md) D1)。
|
||||
- 路由文件过大按职责拆分(参照批次 3 的 design / cost / machining / export 拆分先例;新增路由须登记 [moldinsight/api/\_\_init\_\_.py](../src/moldinsight/api/__init__.py) 的 `ROUTE_MODULES`)。
|
||||
- 破坏性变更(删字段 / 改语义)需在 [STATUS.md](STATUS.md) 日志条目中记录,并确认前端同仓同步修改。
|
||||
|
||||
## 6. 前端消费约定
|
||||
|
||||
+14
-3
@@ -159,6 +159,8 @@ geMoldInsight/
|
||||
典型桥接关系示例:
|
||||
- `STPFile.product_id -> Product.id`
|
||||
|
||||
桥接只允许**裸 FK 列**(字符串表名),**不允许跨模块 ORM relationship**——单模块部署下另一模块的模型类可能未注册,跨模块 relationship 会让 mapper 配置直接失败(2026-09-17 批次 4 起为硬规则,原三条跨模块 relationship 均无使用方,已删除;对象化查询由使用方显式 select)。
|
||||
|
||||
### 5.2 模块边界优先于“临时方便”
|
||||
|
||||
新增逻辑时,应优先放入对应业务模块,而不是继续堆进 `shared`。
|
||||
@@ -178,9 +180,16 @@ geMoldInsight/
|
||||
|
||||
虽然模块化已经成型,但仍有几个关键耦合点需要持续关注:
|
||||
|
||||
### 6.1 共享 ORM 模型
|
||||
### 6.1 共享 ORM 模型 —— 已按模块拆分(2026-09-17,批次 4)
|
||||
|
||||
当前 [src/shared/models/database.py](../src/shared/models/database.py) 同时承载 identity、moldinsight、inventory 三类模型,是当前最强耦合点之一。
|
||||
历史上的 `shared/models/database.py`(31 个模型类三类同居)已拆除,现为按归属分置:
|
||||
|
||||
- [src/shared/models/base.py](../src/shared/models/base.py):唯一 `Base` + 归属约定与全量注册点说明
|
||||
- [src/shared/models/identity.py](../src/shared/models/identity.py):用户/角色/权限/审计(平台层,所有部署形态共用)
|
||||
- [src/moldinsight/models/](../src/moldinsight/models/):STEP 分析域 9 表(stp_files 及各阶段产物、processing_tasks)
|
||||
- [src/inventory/models/](../src/inventory/models/):进销存 15 表(catalog / warehouse / trading / finance 四域文件)
|
||||
|
||||
跨模块只允许裸 FK(规则见 §5.1);全量模型注册点收敛为 `migrations/env.py` 与 `tests/conftest.py`;归属边界由 [tests/test_model_ownership.py](../tests/test_model_ownership.py) 锁定(含单模块独立 mapper 配置与旧模块无 facade 断言)。
|
||||
|
||||
### 6.2 app factory 组合职责偏重
|
||||
|
||||
@@ -199,8 +208,10 @@ geMoldInsight/
|
||||
- 存储方向:
|
||||
- [topics/storage/RUSTFS_STORAGE.md](topics/storage/RUSTFS_STORAGE.md)
|
||||
- [topics/storage/STORAGE_SETUP.md](topics/storage/STORAGE_SETUP.md)
|
||||
- 性能方向:
|
||||
- [topics/performance/OCC_THROUGHPUT.md](topics/performance/OCC_THROUGHPUT.md)(OCC 吞吐与隔离方案设计,TECH_DEBT D10 归属)
|
||||
|
||||
AI、性能、铝泡沫等更偏历史设计/规划性质的专题材料已迁入 [archive/README.md](archive/README.md)。
|
||||
AI、铝泡沫等更偏历史设计/规划性质的专题材料已迁入 [archive/README.md](archive/README.md)。
|
||||
|
||||
阶段性任务清单、迁移计划、历史总结等文档会逐步迁入 [archive/README.md](archive/README.md)。
|
||||
|
||||
|
||||
+6
-2
@@ -16,7 +16,8 @@
|
||||
- `SECRET_KEY`:JWT 签名密钥,**无默认**;生产必须 ≥32 字符强随机。
|
||||
- `ADMIN_PASSWORD`:初始管理员密码,**无默认**;首次建库前必须设置。
|
||||
- `RUSTFS_*`:对象存储(兼容 `MINIO_*` 别名写法);本地开发缺省值仅为占位,连不上会在用到存储的链路报错。
|
||||
- `REDIS_*`:默认 `localhost:6379` 无密码(本地开发语义),生产必须显式覆盖。
|
||||
- `REDIS_*`:默认 `localhost:6379` 无密码(本地开发语义),生产必须显式覆盖;连接串唯一拼装点为 `Settings.redis_url`(Celery broker/backend 复用)。
|
||||
- `MAX_FILE_SIZE`:上传文件大小上限(字节),默认 `104857600`(100MB);此前为死配置(处理器硬编码 50MB),2026-09-17 起真实生效,收紧上限需同步调整该值。
|
||||
- `CORS_ORIGINS`:逗号分隔白名单;不设默认放行 `*`,**生产必须显式设置**。
|
||||
- `LOG_FORMAT`:`json`(生产默认,结构化)/ `text`(开发人可读);`LOG_LEVEL`:DEBUG/INFO/WARNING/ERROR。
|
||||
- `DEBUG`:`true` 时额外注册 `/api/debug/*` 调试路由(仍需登录),**生产必须为 false**。
|
||||
@@ -48,6 +49,9 @@ Celery worker(moldinsight 异步分析链路;本地从 `src` 目录跑,与
|
||||
cd src && celery -A celery_app worker --concurrency=2 --loglevel=info
|
||||
```
|
||||
|
||||
- `--concurrency=N` 即 OCC 并行分析数:每个 prefork 子进程持一个常驻 OCC 工作进程(方案 B,见 [topics/performance/OCC_THROUGHPUT.md](topics/performance/OCC_THROUGHPUT.md))——每个 OCC 工作进程是独立的 Python + OCC 运行时,**N 增大时按「worker 子进程 + OCC 子进程」双份预算内存**,并预留 PG 连接数(按 celery 角色池随子进程倍增)。
|
||||
- `--max-tasks-per-child=M`(如 50):worker 子进程定期重启,连带回收其 OCC 子进程(进程级兜底,方案 A)。
|
||||
|
||||
前端:
|
||||
|
||||
```bash
|
||||
@@ -74,7 +78,7 @@ docker compose --profile inventory up -d # inventory 单模块栈
|
||||
- **生产环境必须显式设置**:`SECRET_KEY`、`ADMIN_PASSWORD`、`DB_*`、`CORS_ORIGINS`、`RUSTFS_*`、`REDIS_PASSWORD`、`DEBUG=false`、`LOG_FORMAT=json`。
|
||||
- **单数据库**:moldinsight 与 inventory 共享同一 PostgreSQL(刻意设计,不拆库)。
|
||||
- **后台任务一律走 `task_dispatcher`** 与 Celery,不要在路由里 fire-and-forget。
|
||||
- **uploads/ 与 html_output/ 为运行时产物目录**,不提交、不作为配置源头。
|
||||
- **uploads/ 与 html_output/ 为运行时产物目录**,不提交、不作为配置源头。`html_output/` 自 D11 起仅作 `/html` 报告代理的**存量兜底读**(新产物直传 RustFS 报告键 `html/reports/`,worker 不再写本地卷)。
|
||||
- `scripts/` 下的一次性脚本执行前先确认目标环境(多为不可逆数据迁移)。
|
||||
|
||||
## 6. 排障指针
|
||||
|
||||
+8
-8
@@ -45,7 +45,7 @@ geMoldInsight 已从历史单体逐步演进为“双业务模块 + 共享平台
|
||||
|
||||
重点方向:
|
||||
|
||||
- `advanced_router` 拆分与请求模型规范化
|
||||
- ~~`advanced_router` 拆分与请求模型规范化~~(2026-09-17 批次 3 完成)
|
||||
- 模具分析链路的结构继续收口
|
||||
- OCC 依赖场景下的契约测试/集成测试继续补齐
|
||||
|
||||
@@ -88,19 +88,19 @@ geMoldInsight 已从历史单体逐步演进为“双业务模块 + 共享平台
|
||||
|
||||
### P1:moldinsight API 结构整理
|
||||
|
||||
- 拆分 `advanced_router`
|
||||
- 为高频接口引入 Pydantic 请求模型
|
||||
- 继续减少 `request.json()` 风格手动解析
|
||||
- ~~拆分 `advanced_router`~~(2026-09-17 批次 3 完成)
|
||||
- ~~为高频接口引入 Pydantic 请求模型~~(2026-09-17 批次 3 完成)
|
||||
- 继续减少 `request.json()` 风格手动解析(存量端点已清零,新增接口守此约定)
|
||||
|
||||
### P2:shared/platform 边界继续收敛
|
||||
|
||||
- 梳理共享 ORM 与业务模型的归属
|
||||
- ~~梳理共享 ORM 与业务模型的归属~~(2026-09-17 批次 4 完成:ORM 已按模块拆分,跨模块只许裸 FK)
|
||||
- 继续减少 shared 直接承担业务组合逻辑
|
||||
- 为后续平台层命名与目录调整准备条件
|
||||
|
||||
### P3:专项能力继续规范化
|
||||
|
||||
- 铝价模拟数据增加显式 `source: "simulated"`
|
||||
- ~~铝价模拟数据增加显式 `source: "simulated"`~~(2026-09-18 完成:后端响应带 `source` 字段,前端按来源渲染标注,不再硬编码交易所名)
|
||||
- 补专题文档的定位/边界说明
|
||||
- 清理历史 checklist / tasks / report 文档的展示层级
|
||||
|
||||
@@ -117,11 +117,11 @@ geMoldInsight 已从历史单体逐步演进为“双业务模块 + 共享平台
|
||||
| 批次 1 | 部署正确性(1–2 天) | 主链路改走 RustFS(分派入参`file_path` → `stp_file_id`,worker 按 object_key 下载解析);compose 共享卷兜底(过渡);alembic 移出 startup(`AUTO_MIGRATE` 开关);OCC 镜像引入方式修正 + 依赖锁文件 | D6、D12、D13 |
|
||||
| 批次 2 | 任务一致性模型(2–4 天) | PG 为单一事实源、Redis 仅热缓存;去掉多进程内存回退;批量元数据入库;型腔失败标 failed;持久化事务边界收口 | D7、D8、D9、D11 |
|
||||
| 批次 3 | API 与代码结构(3–5 天) | `_safe_include` 失败显式化(/health 暴露缺失路由);advanced_router 拆分 + Pydantic 请求模型;async 重计算统一 executor;StorageIntegrationService 拆分;配置治理 | D1、D14 |
|
||||
| 批次 4 | 架构演进(5 天+) | 共享 ORM 按模块拆分;OCC 吞吐方案设计先行;文档 / 契约同步 | D3、D10 |
|
||||
| 批次 4 | 架构演进(5 天+) | ~~共享 ORM 按模块拆分;OCC 吞吐方案设计先行;文档 / 契约同步~~(2026-09-17 完成) | D3、D10 |
|
||||
|
||||
**执行顺序建议**:批次 0 与批次 1 的 D6(RustFS 主链路)先行——前者是确认的安全漏洞,后者是部署根本性缺陷,两者互不依赖、改动可控。其余按批次顺序推进,每批完成同步 STATUS / TECH_DEBT / API_CONTRACT。
|
||||
|
||||
> 进度:批次 0 / 1 / 2 已于 2026-09-16 完成(D13 的 pip 全量锁文件为批次 1 遗留项,随下次镜像构建补齐;D11 留待后续批次,正确性已由批次 1 共享卷兜底);完成明细见 [STATUS.md](STATUS.md) 与 [TECH_DEBT.md](TECH_DEBT.md) §2.5–2.6。
|
||||
> 进度:批次 0 / 1 / 2 已于 2026-09-16 完成、批次 3 / 4 已于 2026-09-17 完成,§3.1 批次计划**全部执行完毕**;批次 4 后续专项于 2026-09-18 完成——D11(HTML 报告 RustFS 单源 + `/html` 代理路由)与 **OCC 方案 B(`run_occ` 契约进程化 + 常驻进程池 kill-on-timeout)已清偿**(部署参数方案 A 一并落地,见 [TECH_DEBT.md](TECH_DEBT.md) D10 与 [topics/performance/OCC_THROUGHPUT.md](topics/performance/OCC_THROUGHPUT.md))。遗留:D13 的 pip 全量锁文件随下次镜像构建补齐。完成明细见 [STATUS.md](STATUS.md) 与 [TECH_DEBT.md](TECH_DEBT.md) §2.5–2.8。后续优先项回到 §3 P2 / P3 与主线方向。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
> 文档定位:**唯一的「现在到哪了」**。README / AGENTS / 各主文档只链接到这里,不复制状态内容。
|
||||
> 维护规则:每完整完成一个需求,**倒序在本文顶部加一条**(日期 + 主题 + 关键事实);其余主文档(架构 / 规划 / 技术债 / 部署)维护各自的"当前有效说法",本文只记录"什么时候做到了哪一步"。维护规则出处见根目录 [AGENTS.md](../AGENTS.md)。
|
||||
|
||||
> 2026-09-18(**批次 4 后续专项五项完成:D11 清偿 + 部署参数 + D2 诚实标注 + CI 门禁 + OCC 方案 B 实施**:① **D11 清偿**(TECH_DEBT P2)——可视化报告 RustFS 单源化:写侧 HTMLGenerator 每任务写临时目录,`.html`/`_summary.json`/`_data.json` 三件统一裸传 RustFS 报告键 `html/reports/{filename}`(文件名寻址),`/html` StaticFiles 本地挂载删除,新增 [html_report_router.py](../src/moldinsight/api/html_report_router.py) 根路径代理(报告键直取 → 遗留 `html/{hash}.json` JSON 包装解析 → 本地卷存量兜底 → 404;URL 形状 `/html/{filename}` 不变,持久化 cavity JSON 与前端 iframe 引用零迁移);celery 服务摘除 `html_data` 卷,Dockerfile.moldinsight 删除 `COPY html_output/`(构建机陈旧报告不再进镜像);已知约束:报告路由不做认证(iframe 无法携带 Authorization 头,沿用 StaticFiles 时代既定姿态,文档已声明);顺带删除 `get_stp_file_with_data` 的死数据块(`html_content` 组装零消费方,此前每次完成任务查询白下载数 MB 正文)。② **OCC 方案 A 参数落地**——`CELERY_CONCURRENCY`/`CELERY_MAX_TASKS_PER_CHILD` 进 [Dockerfile.celery](../deploy/Dockerfile.celery) ENV + compose 透传 + `.env.example`。③ **D2 清偿**——铝价响应带 `source: "simulated"`,[HomeView.vue](../frontend/src/modules/home/HomeView.vue) 按来源渲染"模拟数据 · 参考走势"标注(原硬编码"上海期货交易所"属虚假声明),死代码 `getAluminumPrice` 删除。④ **CI 门禁**——[.gitea/workflows/ci.yml](.gitea/workflows/ci.yml) 三 job:pytest 全量 / 前端构建(含 vue-tsc)/ openapi 漂移检测(conda pythonocc 环境重导出比对;已实测 pytest 与导出均不依赖 .env)。⑤ **OCC 方案 B 实施**(TECH_DEBT D10 清偿)——`run_occ(fn, *args)` → `run_occ(op_name, payload)`,执行器由线程池替换为常驻工作进程池 [occ_process_pool.py](../src/moldinsight/services/occ_process_pool.py) + 操作注册表 [occ_worker.py](../src/moldinsight/core/occ_worker.py):超时/崩溃 terminate 换新补位、任务级超时 recover 整体重建,**残留线程泄漏根治**(进程边界回收 C++ 栈);TopoDS 形状不跨进程(`generate_cavity` 分模 + 方案 STEP 持久化导出全在子进程内,返回 export_manifest);调用点全量迁移(解析/网格/型腔/分析/倒扣/STEP 转换),删除内存形状缓存链(`_cache_export_shapes`/`get_export_shapes`/`_persist_step_exports`)、`CADExporter.export_mold_results`(零调用方)、shape_loader(→ [stp_materializer.py](../src/moldinsight/services/stp_materializer.py));回归测试 [test_occ_process_pool.py](../tests/test_occ_process_pool.py)(OCC-gated,6 例含真实盒体 STP 解析/分模端到端)。**接口变更三件套随批完成**:openapi.json 重导出(76→77 paths,新增 `/html/{filename}`)+ 前端 `gen:api` 再生 + 前端构建通过(方案 B 接口面零变化,无路由/schema 变更)。**测试基线**:**143 passed, 0 skipped**(D11 8 项 + 铝价 2 项 + OCC 进程池 6 项;基线 129 中原 2 个 skip 已随本地环境补齐 celery/alembic 转为执行)。**下一步**:回到 §3 主线 P2/P3 长期方向——D3 剩余收敛(app_factory 参数收敛、identity/platform 语义)、inventory 服务下沉、D13 pip 锁文件随下次镜像构建补齐,见 [ROADMAP.md](ROADMAP.md) §3。)
|
||||
|
||||
> 2026-09-17(**批次 4(架构演进)完成,§3.1 治理批次全部执行完毕**:① D3 主体清偿——891 行的旧 `shared/models/database.py`(31 模型类三类同居,已删除)按归属拆为 [shared/models/base.py](../src/shared/models/base.py)(唯一 Base)+ [shared/models/identity.py](../src/shared/models/identity.py)(身份权限 7 表)+ [moldinsight/models/](../src/moldinsight/models/)(分析域 9 表)+ [inventory/models/](../src/inventory/models/)(进销存 15 表,catalog/warehouse/trading/finance 四文件);**三条跨模块 ORM relationship(`User.stp_files` / `STPFile.user` / `STPFile.product`)经全仓核实零使用,直接删除**——跨模块桥接收敛为裸 FK 硬规则([ARCHITECTURE.md](ARCHITECTURE.md) §5.1),单模块部署 mapper 可独立配置;约 45 处 import 全量改写(含 migrations/env.py 全量注册、scripts/ 两个一次性脚本),旧模块物理删除无兼容 facade;零调用方死方法 `db_manager.create_tables` 一并删除(拆分后会静默建残缺 schema);② D10 治理——`_reset_occ_executor` 补 `cancel_futures=True`(旧实现下"慢恢复"的旧线程会继续消化旧队列,与新 executor **并发操作非线程安全的 OCC**,属数据竞争而非单纯泄漏);吞吐方案设计先行定稿 [topics/performance/OCC_THROUGHPUT.md](topics/performance/OCC_THROUGHPUT.md)(短期 A:celery `--concurrency` 伸缩 + `--max-tasks-per-child` 兜底,启动参数已记 [OPERATIONS.md](OPERATIONS.md) §3;中期 B:`run_occ` 契约进程化 + kill-on-timeout,待独立排期);③ 新增 [tests/test_model_ownership.py](../tests/test_model_ownership.py) 锁定归属边界(31 表全量注册 / 单模块独立 mapper 配置 / 旧模块无 facade);④ 顺手清偿 D15——[vite.config.ts](../frontend/vite.config.ts) 删除未用的 `mode` 参数,`vue-tsc -b` 恢复通过,前端生产构建链路解除阻断。**接口面零变化**(无路由与 schema 变更,openapi.json 不触发重导出)。**测试基线**:**125 passed, 2 skipped**(基线 122 + 新增归属测试 3 项)。**下一步**:治理批次收尾后回到主线方向;遗留项 D11(HTML RustFS 单源)、D13(pip 锁文件)、OCC 方案 B 独立批次。)
|
||||
|
||||
> 2026-09-17(**批次 3(API 与代码结构)完成**:① D1 清偿——592 行 advanced_router 拆为 [design_router](../src/moldinsight/api/design_router.py) / [cost_router](../src/moldinsight/api/cost_router.py) / [machining_router](../src/moldinsight/api/machining_router.py) / [export_router](../src/moldinsight/api/export_router.py) 四个子路由(端点路径不变),请求体全量 Pydantic 模型化(`request.json()` 手动解析退役,校验统一 422);② 路由装载失败显式化:`ROUTE_MODULES` 清单 + [route_registry](../src/moldinsight/api/route_registry.py),失败经 `/api/health` 呈现 `degraded` 并列出清单(`pythonocc` 改真实探测),DEBUG 下 fail fast——此前失败仅 WARNING 后静默跳过,进程带病启动不可感知;③ 纯 Python 重计算端点(设计/加工/CAM 打包)统一 `asyncio.to_thread` 投放线程池,不再阻塞事件循环(OCC 仍走单线程 executor,D10 留批次 4);④ `StorageIntegrationService`(867 行)按职责拆为 [task_storage](../src/moldinsight/services/task_storage_service.py) / [analysis_storage](../src/moldinsight/services/analysis_storage_service.py) / [file_history](../src/moldinsight/services/file_history_service.py) 三服务,无调用方死代码 `log_user_activity` 删除;⑤ D14 收尾清偿——`MAX_FILE_SIZE` 接线生效(默认上限 50MB→100MB,以 .env 为准)、celery_app 复用 `Settings.redis_url`(连接串唯一拼装点)。**连带修复**:管理员重置密码改 JSON body `{ new_password }`(原裸 str 参数被解析为 query param,前端两个调用点均发 body,功能端到端断裂)+ [UsersView.vue](../frontend/src/modules/users/UsersView.vue) 同步;Dockerfile.celery 的 FROM 对齐 `gemold-backend:latest`(此前引用不存在的 tag,干净环境 celery 镜像必构建失败)。**接口变更三件套随批完成**:openapi.json 重导出(76 paths)+ 前端 `gen:api` 再生。**连带发现**:`npm run build` 因 vite.config.ts 既有 TS6133 失败(与本项目改动无关,登记 D15)。**测试基线**:**122 passed, 2 skipped**(新增 4 个测试文件共 17 项:[test_advanced_split_contract](../tests/test_advanced_split_contract.py) / [test_route_load_status](../tests/test_route_load_status.py) / [test_config_governance](../tests/test_config_governance.py) / [test_auth_password_reset](../tests/test_auth_password_reset.py);skips 为 alembic / celery 缺失环境)。**下一步**:批次 4(架构演进:共享 ORM 拆分、OCC 吞吐方案,见 [ROADMAP.md](ROADMAP.md) §3.1)。)
|
||||
|
||||
> 2026-09-16(**批次 2(任务一致性模型)完成**:① D7 清偿——Redis 进程内存回退**彻底删除**(写 no-op / 读 None,查询路径自然落 PG),PG 为任务状态单一事实源;批量元数据入库:`processing_tasks` 新增 `batch_id` 列(迁移 `a3f8c2d91e47`,**升级后首次启动自动执行**),`GET /api/batch/{batch_id}` 改为 PG 聚合查询 + `STPFile.user_id` 归属校验,删除 Redis batch key 与内存 dict 双通道;`TaskQueryService` PG 视图与 batch 聚合响应补 `progress` / `current_step`(Redis 不可用时前端仍能看到进度);② D8 清偿——型腔分模失败不再吞异常,任务标 failed 并带明确错误(已提交的几何/网格保留);③ D9 清偿——数据本体写方法只 flush,编排层分阶段原子收口(阶段 A 几何+网格、阶段 B 型腔+HTML+特征+指标+验证、完成时参数随状态一并提交),失败先 rollback 再置 failed;进度/状态更新保留即时 commit(长任务进度可见性);upload/batch/advanced 调用方补显式 commit,STPFile + ProcessingTask 原子落库消除孤儿文件记录。D11 未动(共享卷已兜正确性,留后续批次)。**测试基线**:**105 passed, 1 skipped**(新增 [tests/test_batch_status_pg.py](../tests/test_batch_status_pg.py) 4 项 + [tests/test_redis_no_fallback.py](../tests/test_redis_no_fallback.py) 3 项)。**下一步**:批次 3(API 与代码结构:`_safe_include` 失败显式化、advanced_router 拆分 + Pydantic 请求模型、配置治理,见 [ROADMAP.md](ROADMAP.md) §3.1)。)
|
||||
|
||||
> 2026-09-16(**批次 1(部署正确性)完成**:① D6 清偿——分派入参 `file_path` → `stp_file_id`,处理方按 PG 元数据从 RustFS 下载源文件到任务专属临时目录(RustFS 异常时回退节点本地路径),compose 增 `uploads_data` / `html_data` 共享卷过渡兜底;② D12 清偿——新增 `AUTO_MIGRATE` 开关(默认 true 保持单机行为;多副本设 false 改部署流程单点迁移),迁移脚本与 alembic.ini 补进镜像。**连带发现并修复**:迁移目录 `alembic/` 与 alembic 包重名,应用内 `import alembic` 被遮蔽——启动期自动迁移自引入 alembic 起**从未真正生效**(异常被 init_database 吞掉只打日志),且镜像原本未打包迁移脚本;目录已改名 `migrations/`(alembic.ini + 4 处文档引用同步);③ D13 主体——Dockerfile.moldinsight 改为 conda 运行时原生执行(不再跨镜像拷贝 site-packages),基础镜像 tag 锁定;pip 全量锁文件遗留,随下次镜像构建 `pip freeze` 生成;④ compose 关键项去弱默认:`SECRET_KEY` / `ADMIN_PASSWORD` 改 `${VAR:?}` 强制显式配置(与 OPERATIONS「无默认」声明对齐),`create_admin_user` 对空口令显式报错。**测试基线**:**98 passed, 1 skipped**(新增 [tests/test_deployment_config.py](../tests/test_deployment_config.py);alembic 缺失环境 skip)。**遗留**:D13 pip 锁文件;既有问题待查——Dockerfile.celery `FROM gemold-moldinsight:latest`,而 build.sh 只构建 `gemold-backend` tag,干净机器上 build.sh 的 celery 步骤会失败。**下一步**:批次 2(任务一致性模型,见 [ROADMAP.md](ROADMAP.md) §3.1)。)
|
||||
|
||||
+63
-59
@@ -55,29 +55,35 @@
|
||||
- 持久化事务边界收口:数据本体分阶段原子提交、失败先回滚再置 failed(原 D9)
|
||||
- D11(HTML 双写双读)本批未动:正确性已由共享卷兜底,RustFS 单一来源留待后续批次
|
||||
|
||||
### 2.7 API 与代码结构(2026-09-17,批次 3)
|
||||
- `advanced_router` 按职责拆为 design / cost / machining / export 四个子路由,端点路径不变,请求体全量 Pydantic 化(原 D1)
|
||||
- 路由装载失败显式化:`ROUTE_MODULES` 清单 + route_registry,失败经 `/api/health` 呈现 degraded(含真实 pythonocc 探测),DEBUG 下 fail fast
|
||||
- 纯 Python 重计算端点(设计/加工/CAM 打包)统一 `asyncio.to_thread` 投放线程池,不再阻塞事件循环;OCC 操作仍走单线程 executor(D10 不变,批次 4)
|
||||
- `StorageIntegrationService`(867 行)按职责拆为 TaskStorage / AnalysisStorage / FileHistory 三服务;无调用方的 `log_user_activity` 死代码删除
|
||||
- 配置治理收尾:`MAX_FILE_SIZE` 接线生效、celery_app 复用 `Settings.redis_url`(原 D14)
|
||||
- 连带修复:管理员重置密码改 JSON body(原裸 str 参数被解析为 query param,前端发 body 必 422,功能端到端断裂);Dockerfile.celery 的 FROM tag 与 compose/build.sh 实际构建的 `gemold-backend:latest` 对齐(此前干净环境 celery 镜像必构建失败)
|
||||
- 接口变更三件套随批完成:openapi.json 重导出(76 paths)+ 前端 `gen:api`
|
||||
|
||||
### 2.8 架构演进(2026-09-17,批次 4)
|
||||
- 共享 ORM 按模块拆分(原 D3 主体):891 行 `shared/models/database.py`(31 模型类三类同居)拆为 `shared/models/base.py`(唯一 Base + 归属约定)/ `shared/models/identity.py`(7 表)/ `moldinsight/models/`(9 表)/ `inventory/models/`(catalog/warehouse/trading/finance 15 表);**三条跨模块 ORM relationship(`User.stp_files`、`STPFile.user`、`STPFile.product`)经全仓核实均无使用方,直接删除**——跨模块桥接收敛为裸 FK 硬规则(ARCHITECTURE §5.1),单模块部署不再依赖另一侧模型注册;约 45 处 import 全量改写,无兼容 facade;全量注册点收敛为 migrations/env.py 与 tests/conftest.py;零调用方的死方法 `db_manager.create_tables` 一并删除(拆分后会静默建残缺 schema)
|
||||
- OCC 泄漏治理 + 吞吐方案设计先行(原 D10):`_reset_occ_executor` 补 `cancel_futures=True`——不止卫生问题:旧实现下"慢恢复"的旧线程会继续消化旧队列,与新 executor **并发操作非线程安全的 OCC**(数据竞争);吞吐路线定稿于 [topics/performance/OCC_THROUGHPUT.md](topics/performance/OCC_THROUGHPUT.md)(短期 A:celery prefork 伸缩 + max-tasks-per-child 兜底;中期 B:run_occ 接口进程化 + kill-on-timeout 根治)
|
||||
- 归属边界回归测试:[tests/test_model_ownership.py](../tests/test_model_ownership.py)(31 表全量注册、单模块独立 mapper 配置、旧模块无 facade)
|
||||
|
||||
详细历史过程保留在原始技术债文档中,后续将转入归档。
|
||||
|
||||
---
|
||||
|
||||
## 3. 当前活跃技术债
|
||||
|
||||
### D1. `advanced_router` 过大,职责混杂
|
||||
### D1. `advanced_router` 过大,职责混杂 —— 已清偿(2026-09-17,批次 3)
|
||||
|
||||
现状:
|
||||
- 导出、估算、设计/分析相关接口仍混在同一个 router 中
|
||||
- 请求体仍有较多手动解析逻辑
|
||||
修复内容:
|
||||
- 592 行的 advanced_router 按职责拆为四个子路由,端点路径全部不变:[design_router.py](../src/moldinsight/api/design_router.py)(布局/冷浇/模架/倒扣)、[cost_router.py](../src/moldinsight/api/cost_router.py)、[machining_router.py](../src/moldinsight/api/machining_router.py)(CAM/碰撞/刀路/电极/仿真)、[export_router.py](../src/moldinsight/api/export_router.py)(导出/下载/建议)
|
||||
- 全部请求体改 Pydantic 模型(`request.json()` 手动解析退役),校验失败统一 422;`_get_cached_import` 上提为 [core_modules.py](../src/moldinsight/api/core_modules.py) 共用
|
||||
- 契约测试:[tests/test_advanced_split_contract.py](../tests/test_advanced_split_contract.py)(路径不丢、鉴权不丢、422 语义、纯计算端点冒烟)
|
||||
- openapi.json 重导出 + 前端 `gen:api`(接口变更三件套随批完成)
|
||||
|
||||
影响:
|
||||
- 路由边界不清晰
|
||||
- OpenAPI 可读性差
|
||||
- 接口参数校验不统一
|
||||
- 后续继续扩展时维护成本高
|
||||
|
||||
建议:
|
||||
- 拆分为 export / design / cost 等子路由
|
||||
- 高优先级请求体改为 Pydantic 模型
|
||||
|
||||
优先级:**P1**
|
||||
~~原现状 / 影响~~:导出/估算/设计接口混在单文件,边界不清晰、OpenAPI 可读性差、参数校验不统一。
|
||||
|
||||
### D2. 铝价模拟数据未显式标注来源
|
||||
|
||||
@@ -93,21 +99,17 @@
|
||||
|
||||
优先级:**P2**
|
||||
|
||||
### D3. shared/platform 边界仍需继续收敛
|
||||
### D3. shared/platform 边界仍需继续收敛 —— ORM 归属已清偿(2026-09-17,批次 4)
|
||||
|
||||
现状:
|
||||
- `shared` 同时承担平台基础能力与部分历史耦合职责
|
||||
- 共享 ORM 与 app factory 仍是主要耦合点
|
||||
已完成部分:
|
||||
- 共享 ORM(原最强耦合点)按模块拆分:base / identity(shared)+ moldinsight/models + inventory/models;跨模块只允许裸 FK,单模块部署 mapper 可独立配置(详见 §2.8 与 [ARCHITECTURE.md](ARCHITECTURE.md) §6.1)
|
||||
- 旧 `shared/models/database.py` 物理删除,无兼容 facade;归属边界由 [tests/test_model_ownership.py](../tests/test_model_ownership.py) 锁定
|
||||
|
||||
影响:
|
||||
- 模块边界认知成本较高
|
||||
- 新增逻辑容易继续堆入 shared
|
||||
仍保留的收敛方向(低优先级,随实际重构推进):
|
||||
- [app_factory.py](../src/shared/app_factory.py) 组合职责偏重(ARCHITECTURE §6.2)
|
||||
- identity / platform 的边界语义(ROADMAP §2.1)
|
||||
|
||||
建议:
|
||||
- 继续从文档、目录语义、职责边界上推进收敛
|
||||
- 在后续实际重构中优先避免把业务逻辑继续沉入 shared
|
||||
|
||||
优先级:**P2**
|
||||
优先级:**P3**(剩余部分)
|
||||
|
||||
### D4. 文档现状 / 规划 / 历史混放
|
||||
|
||||
@@ -171,32 +173,33 @@
|
||||
|
||||
~~原现状 / 影响~~:各存储方法内部自行 commit,型腔保存失败留半成品数据且任务仍 completed。
|
||||
|
||||
### D10. OCC 全局单线程串行 + 超时重建泄漏线程
|
||||
### D10. OCC 全局单线程串行 + 超时重建泄漏线程 —— 已清偿(2026-09-18,方案 B 实施)
|
||||
|
||||
现状:
|
||||
- 所有 OCC 操作经 `max_workers=1` executor 串行([processing_service.py](../src/moldinsight/services/processing_service.py)),celery 并发无法扩展 OCC 吞吐
|
||||
- 超时重建 executor 每次泄漏 1 个线程,长期运行只涨不降
|
||||
**方案 B(常驻 OCC 进程池,kill-on-timeout 根治泄漏)已实施**(2026-09-18):
|
||||
- `run_occ(fn, *args)` → `run_occ(op_name, payload)`;执行器由进程内线程池替换为常驻工作进程池 [occ_process_pool.py](../src/moldinsight/services/occ_process_pool.py) + 操作注册表 [occ_worker.py](../src/moldinsight/core/occ_worker.py)(新增;详见 [topics/performance/OCC_THROUGHPUT.md](topics/performance/OCC_THROUGHPUT.md) §5)
|
||||
- 超时/崩溃 = terminate() 换新补位——**残留线程泄漏根治**(C++ 栈由进程边界回收);OCC segfault 不再波及 API/worker 主进程
|
||||
- 调用点全部迁移(解析/网格/型腔/分析/倒扣/STEP 转换),TopoDS 形状不跨进程(`generate_cavity` 的方案形状 STEP 导出改在子进程内持久化,返回 export_manifest)
|
||||
- 顺带删除:内存形状缓存链(`_cache_export_shapes` / `get_export_shapes` / `_persist_step_exports`)、`CADExporter.export_mold_results`(零调用方)、shape_loader(→ [stp_materializer.py](../src/moldinsight/services/stp_materializer.py))
|
||||
- 回归测试:[tests/test_occ_process_pool.py](../tests/test_occ_process_pool.py)(OCC-gated,6 例含真实盒体 STP 端到端)
|
||||
|
||||
影响:
|
||||
- 一个长耗时型腔生成阻塞全部几何处理;线程随故障累积
|
||||
**方案 A 部署参数落地**(2026-09-18):`CELERY_CONCURRENCY` / `CELERY_MAX_TASKS_PER_CHILD` 进 [Dockerfile.celery](../deploy/Dockerfile.celery) + compose + `.env.example`(`--max-tasks-per-child` 仍保留为进程回收兜底)。
|
||||
|
||||
建议:
|
||||
- 记录吞吐上限为已知约束;线程泄漏治理方案设计先行(见 [ROADMAP.md](ROADMAP.md) §3.1 批次 4)
|
||||
保留为已知约束(非待修缺陷):
|
||||
- 单进程内 OCC 串行是正确性要求(OCC 非线程安全),吞吐扩展走多进程(方案 A/B)
|
||||
- 每个操作从 STP 原件重新加载形状(STEP 重载成本秒级)——进程隔离的设计取舍,见 OCC_THROUGHPUT §1.2/§5
|
||||
|
||||
优先级:**P2**
|
||||
优先级:~~**P3**~~ **已清偿**
|
||||
|
||||
### D11. HTML 报告本地磁盘与 RustFS 双写双读
|
||||
### D11. HTML 报告本地磁盘与 RustFS 双写双读 —— 已清偿(2026-09-18)
|
||||
|
||||
现状:
|
||||
- 可视化 HTML/摘要同时写本地 `html_output/`(/html 静态挂载)与 RustFS
|
||||
修复内容:
|
||||
- **写侧**:可视化产物不再落节点本地 `html_output/`——HTMLGenerator 每任务写临时目录([processing_service.py](../src/moldinsight/services/processing_service.py)),`.html` / `_summary.json` / `_data.json` 三件统一裸传 RustFS 报告键 `html/reports/{filename}`(文件名寻址,同源同秒重复分析即覆盖刷新);`HTMLFile` 表仅存元数据
|
||||
- **读侧**:`/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 头
|
||||
- 回归测试:[tests/test_html_report_router.py](../tests/test_html_report_router.py)(8 例:四链路命中、新旧格式记录区分、媒体类型、404、穿越拒绝)
|
||||
|
||||
影响:
|
||||
- 多副本下 /html 命中结果取决于负载均衡,跨副本文件不共享;同一份报告两套来源
|
||||
|
||||
建议:
|
||||
- 统一 RustFS 为唯一来源,本地仅作按需缓存
|
||||
|
||||
优先级:**P2**
|
||||
~~原现状 / 影响~~:可视化 HTML/摘要同时写本地与 RustFS,多副本下 /html 命中结果取决于负载均衡,跨副本文件不共享。
|
||||
|
||||
### D12. 应用启动时自动执行 alembic 迁移
|
||||
|
||||
@@ -216,20 +219,21 @@
|
||||
|
||||
优先级:**P2**(剩余锁文件部分)
|
||||
|
||||
### D14. 配置漂移:弱默认 / 死配置 / 重复解析
|
||||
### D14. 配置漂移:弱默认 / 死配置 / 重复解析 —— 已清偿(2026-09-16 ~ 09-17,批次 1 / 3)
|
||||
|
||||
现状:
|
||||
- ~~RUSTFS_* 弱默认~~(2026-09-16 代码侧已去除);~~compose 侧 SECRET_KEY / ADMIN_PASSWORD 弱默认~~(2026-09-16 已去除:改用 `${VAR:?}` 强制显式配置,`create_admin_user` 对空 ADMIN_PASSWORD 显式报错)
|
||||
- MAX_FILE_SIZE 配置项未被使用([file_handler.py](../src/shared/utils/file_handler.py) 硬编码 50MB)
|
||||
- [celery_app.py](../src/celery_app.py) 重新 load_dotenv 并手拼 REDIS URL,与 settings 两份实现
|
||||
修复内容:
|
||||
- ~~RUSTFS_* 弱默认~~(批次 0 代码侧去除);~~compose 侧 SECRET_KEY / ADMIN_PASSWORD 弱默认~~(批次 1 改 `${VAR:?}` 强制显式配置)
|
||||
- ~~MAX_FILE_SIZE 死配置~~(批次 3):upload/batch 路由的 `FileHandler` 接 `settings.UPLOAD_DIR / settings.MAX_FILE_SIZE`(此前处理器硬编码 50MB;接线后默认上限变为 100MB,以 .env 为准)
|
||||
- ~~celery_app 重复拼装~~(批次 3):删除自行 load_dotenv + 手拼 REDIS URL,broker/backend 复用 `Settings.redis_url`(新增 property,连接串唯一拼装点)
|
||||
|
||||
影响:
|
||||
- 违背"关键项不兜底"硬约束;配置行为与文档不一致
|
||||
优先级:已清偿
|
||||
|
||||
建议:
|
||||
- 去掉弱默认、对齐或删除死配置、celery_app 复用 settings
|
||||
### D15. 前端 `npm run build` 因既有 TS 错误失败(批次 3 连带发现)—— 已清偿(2026-09-17,批次 4)
|
||||
|
||||
优先级:**P2**
|
||||
修复内容:
|
||||
- [vite.config.ts](../frontend/vite.config.ts) 删除未使用的回调参数 `mode`(TS6133 源头,一行修复);`vue-tsc -b` 实测通过,生产构建链路恢复
|
||||
|
||||
~~原现状 / 影响~~:`vue-tsc -b`(`npm run build` 的类型检查步)因既有 TS6133 失败,前端无法出生产包(与批次 3 改动无关的既有问题)。
|
||||
|
||||
---
|
||||
|
||||
@@ -238,14 +242,14 @@
|
||||
> 注:2026-09-15 后端设计审查后,治理**执行顺序**以 [ROADMAP.md](ROADMAP.md) §3.1 批次计划为准(批次 0–4);D5–D14 的批次归属见该表。本节保留原有优先项作为补充说明。
|
||||
|
||||
### 第一优先级
|
||||
1. `advanced_router` 拆分
|
||||
2. 高优先级接口补 Pydantic 请求模型
|
||||
1. ~~`advanced_router` 拆分~~(2026-09-17 批次 3 完成,见 D1)
|
||||
2. ~~高优先级接口补 Pydantic 请求模型~~(2026-09-17 批次 3 完成)
|
||||
3. 文档主骨架收口并减少重复说明
|
||||
|
||||
### 第二优先级
|
||||
4. 铝价模拟数据来源显式化
|
||||
5. 部署历史文档归档
|
||||
6. shared/platform 语义继续收敛
|
||||
6. shared/platform 语义继续收敛(共享 ORM 归属已于批次 4 清偿,剩余为 app_factory 组合职责等,见 D3)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# OCC 处理吞吐与隔离方案设计(OCC_THROUGHPUT)
|
||||
|
||||
> 文档定位:**OCC(PythonOCC)处理吞吐与故障隔离的专题设计文档**。
|
||||
> 本文回答"OCC 串行瓶颈与超时线程泄漏的根治路线";现状事实以 [../../../STATUS.md](../../../STATUS.md) 为准,债务归属 [../../TECH_DEBT.md](../../TECH_DEBT.md) D10。
|
||||
> 2026-09-17 随批次 4 产出:**方案设计先行**,短期项(方案 A)零代码可用,中期的接口演进与进程池实施留待后续批次。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状与硬约束
|
||||
|
||||
### 1.1 运行时事实
|
||||
|
||||
- 所有 OCC 操作(STEP 解析 / 布尔运算 / 三角化 / 倒扣检测 / STEP 转换等)统一经 [processing_service.py](../../../src/moldinsight/services/processing_service.py) 的 `run_occ(op_name, payload)` 投入 **常驻 OCC 工作进程池**([occ_process_pool.py](../../../src/moldinsight/services/occ_process_pool.py),默认 1 进程 = 1 串行通道)——OCC 非线程安全,通道内串行是正确性要求,不是实现偷懒。
|
||||
- 操作在 [occ_worker.py](../../../src/moldinsight/core/occ_worker.py) 以注册表形式实现(parse_stp / generate_mesh / generate_cavity / analyze_mold_design / detect_undercuts / convert_component_step 等);输入输出全部是**文件路径 + 普通字典**,TopoDS_Shape 不跨进程传输(方案 B 硬约束,见 §1.2)。
|
||||
- Celery worker 为 prefork 模式,`processing_service` 是模块级单例:**每个 worker 子进程各持一个常驻 OCC 工作进程**。因此 OCC 并行度 = worker 子进程数,与 web 进程数无关(web 侧 `run_occ` 仅服务于轻量同步调用,如倒扣检测)。
|
||||
- 超时/崩溃 = `terminate()` 该工作进程并换新补位——进程边界干净回收,无线程滞留。
|
||||
|
||||
### 1.2 硬约束(决定方案边界)
|
||||
|
||||
| 约束 | 含义 |
|
||||
|---|---|
|
||||
| OCC 非线程安全 | 任何方案中,**一个进程内 OCC 操作必须串行**;并行只能靠多进程 |
|
||||
| C++ 栈不可中断 | 线程级超时只能"抛弃"不能"击杀";**只有进程级 kill 是干净的故障恢复** |
|
||||
| `run_occ(fn, *args)` 传闭包/绑定方法 | 函数对象不可跨进程 pickle——进程化方案必须改接口为"操作名 + 可序列化参数" |
|
||||
| STEP 重载成本 | 进程间不共享 OCC 形状对象;跨进程方案每次调用需重新读文件/传 BRep(几秒级) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案对比
|
||||
|
||||
### 方案 A:Celery prefork 并行伸缩(短期,零新代码)
|
||||
|
||||
**做法**:承认"每子进程一个串行 OCC 通道"的既有事实,把 OCC 吞吐问题转化为 worker 进程数问题:`celery -A celery_app worker --concurrency=N`,N = 期望的并行分析数(受 CPU 核数与每进程内存约束)。
|
||||
|
||||
- **优点**:零代码改动;进程边界天然兜住线程泄漏——泄漏线程随子进程存亡,配合 `--max-tasks-per-child=M`(子进程处理 M 个任务后重启回收)可把滞留线程的存续时间限制在一个批次内。
|
||||
- **代价**:每个子进程常驻完整 Python + OCC 运行时(数百 MB),N 不能无脑调大;DB 连接按 celery 角色池(pool_size=5)随子进程倍增,PG `max_connections` 需要相应预算。
|
||||
- **不解决**:单任务超时后该子进程内的线程滞留(被 max-tasks-per-child 兜底回收);单任务无加速(串行本质不变)。
|
||||
|
||||
**结论:立即可用的推荐做法**。部署侧调整(concurrency / max-tasks-per-child)随下次镜像与 compose 评审落地,先在 [OPERATIONS.md](../../OPERATIONS.md) 记录启动参数建议。
|
||||
|
||||
### 方案 B:常驻 OCC 进程池 + kill-on-timeout(中期,推荐演进方向)
|
||||
|
||||
**做法**:在 `run_occ` 接口之下替换执行器——不再是 `ThreadPoolExecutor`,而是**常驻的单线程 OCC 工作进程池**(每进程一个事件循环:接任务 → 执行 → 回报)。超时由主进程 `terminate()` 工作进程并更换新进程补位。
|
||||
|
||||
- 接口演进:`run_occ(fn, *args)` → `run_occ(op_name: str, payload: dict)`,操作名注册表映射到模块级函数(STEP 文件路径进、JSON/BRep 文件出,杜绝 pickle 大对象);各调用点(解析、型腔、倒扣、导出三角化……)逐一迁移。
|
||||
- **优点**:超时 = 杀进程,**故障恢复干净彻底**(D10 残留泄漏根治);OCC 崩溃(segfault)不再波及 API/worker 主进程;进程池大小与 celery 并发解耦。
|
||||
- **代价**:一次明确的接口迁移(所有 `run_occ` 调用点 + 结果序列化);进程池自管理(补位、健康检查、启动预热——spawn 下 import OCC 秒级,需常驻而非按任务拉起);跨进程只传文件路径 + JSON,现有"传形状对象"的内部调用要改为落盘中转。
|
||||
- **风险**:自建进程池的运维复杂度;Windows 开发环境 spawn 语义与 Linux fork 差异需测试覆盖。
|
||||
|
||||
### 方案 C:OCC sidecar 服务(长期,视伸缩需求)
|
||||
|
||||
**做法**:OCC 能力独立成进程/容器(HTTP 或 gRPC),API 与 worker 都是客户端;STEP 按路径/对象键传入,返回 JSON 摘要 + 产物对象键。
|
||||
|
||||
- **优点**:隔离最彻底;OCC 可独立伸缩、独立发布、独立扩容 GPU/内存型节点;多语言可复用。
|
||||
- **代价**:新增一个部署单元与序列化边界(大网格/形状数据传输设计);超出当前"单 compose 栈"的部署叙事,需与 DEPLOYMENT 文档体系一起演进。
|
||||
|
||||
**结论:除非出现独立伸缩/隔离性硬需求,暂不启动。**
|
||||
|
||||
---
|
||||
|
||||
## 3. 决策与路线
|
||||
|
||||
| 阶段 | 动作 | 状态 |
|
||||
|---|---|---|
|
||||
| 短期 | 方案 A:`--concurrency` 伸缩 + `--max-tasks-per-child` 兜底回收;`cancel_futures=True` 修复重建并发风险 | ✅ 部署参数 2026-09-18 落地(`CELERY_CONCURRENCY` / `CELERY_MAX_TASKS_PER_CHILD` 进 Dockerfile.celery + compose + .env.example) |
|
||||
| 中期 | 方案 B:`run_occ(op_name, payload)` 接口演进 + 常驻进程池,kill-on-timeout 根治泄漏 | ✅ 2026-09-18 实施完成(见 §5;回归测试 [tests/test_occ_process_pool.py](../../../tests/test_occ_process_pool.py)) |
|
||||
| 长期 | 方案 C:sidecar,仅在出现独立伸缩需求时启动 | 暂不启动 |
|
||||
|
||||
## 4. 已落地的缓解(2026-09-17,批次 4)
|
||||
|
||||
`_reset_occ_executor` 的 `shutdown(wait=False)` 补 `cancel_futures=True`。这不只是卫生问题:旧实现下旧 executor 的**排队任务不会消失**——若挂死线程后来"慢恢复",旧线程会继续消化旧队列,与新 executor **并发操作非线程安全的 OCC**(数据竞争 / 崩溃风险)。补参后排队任务即被丢弃,残留问题收敛为"运行中线程滞留 1 个",由方案 A 的进程回收兜底。
|
||||
|
||||
## 5. 方案 B 实施记录(2026-09-18)
|
||||
|
||||
**接口**:`run_occ(fn, *args)` → `run_occ(op_name, payload)`;执行器由进程内线程池替换为常驻进程池。
|
||||
|
||||
- **新增** [occ_process_pool.py](../../../src/moldinsight/services/occ_process_pool.py):`OccProcessPool`(默认 size=1)。每个 `_OccWorker` 是一个 spawn 出的常驻子进程 + 双工管道 + 独立 `asyncio.Lock`(通道串行);阻塞收发经 `asyncio.to_thread` 不卡事件循环。操作超时或进程死亡 → `terminate()` + 换新补位;任务级整体超时(`process_file_with_storage` 外层 wait_for)→ `recover()` 整体重建。`shutdown()` 供应用退出/测试清理。
|
||||
- **新增** [occ_worker.py](../../../src/moldinsight/core/occ_worker.py):操作注册表 + `worker_main` 消息循环。OCC 模块在 handler 内惰性导入(父进程 pip 环境无 OCC 也可 import),进程内单例缓存(parser/planner/analyzer/mesh_gen 等)。全部操作输入输出为**文件路径 + 普通字典**,TopoDS 不跨进程。
|
||||
- **调用点迁移**(processing_service):
|
||||
- `parse_stp` = 原 load_step_file + analyze_geometry 两步合一(形状在子进程内即生即用)
|
||||
- `generate_mesh` / `analyze_mold_design` / `detect_undercuts` / `convert_component_step` 同名对位
|
||||
- `generate_cavity` = 分模 + 方案形状持久化 STEP 导出全在子进程内;`_export_shapes`(TopoDS)不再回主进程,返回 export_manifest(与旧 `_persist_step_exports` 结构一致,主进程原样存 export_artifacts)
|
||||
- 旧 `_cache_export_shapes` / `get_export_shapes` / `_persist_step_exports` / `_export_shapes_cache` 及线程 executor / `_reset_occ_executor` 全部删除(跨进程本就不存在内存形状缓存,export_router 相应移除 `export_mold_results` 内存分支)
|
||||
- [shape_loader.py](../../../src/moldinsight/services/shape_loader.py) → [stp_materializer.py](../../../src/moldinsight/services/stp_materializer.py):只把 STP 原件落盘临时文件,OCC 解析交给子进程操作
|
||||
- **成本确认**:每个 spawn 子进程首次操作需 import OCC(秒级);进程常驻后后续操作复用缓存实例。每个操作从 STP 原件重新加载形状(STEP 重载成本,见 §1.2)——原线程方案跨步骤共享 shape 的内存优势让位于进程隔离,符合方案 B 设计取舍。
|
||||
- **测试**:[tests/test_occ_process_pool.py](../../../tests/test_occ_process_pool.py)(OCC-gated,6 例):spawn+管道往返 / 未知操作错误回传 / 子进程异常浮出 / 超时换新补位 / 真实盒体 STP 解析端到端 / generate_cavity 分模+STEP 落盘端到端。
|
||||
@@ -9,9 +9,9 @@
|
||||
<div class="card aluminum-price-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
<span class="aluminum-icon">🪙</span> 铝金属价格 (SHFE)
|
||||
<span class="aluminum-icon">🪙</span> 铝金属价格
|
||||
</div>
|
||||
<span class="aluminum-source">数据来源: 上海期货交易所 | 更新于 {{ aluminum.date }}</span>
|
||||
<span class="aluminum-source">{{ aluminumSourceLabel }}</span>
|
||||
</div>
|
||||
<div class="aluminum-content">
|
||||
<div class="aluminum-price-row">
|
||||
@@ -78,7 +78,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, onMounted, nextTick, ref } from 'vue'
|
||||
import { computed, reactive, onMounted, nextTick, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { apiRequest } from '@/shared/api'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
@@ -98,6 +98,8 @@ interface AluminumPrice {
|
||||
low: number
|
||||
prev_close: number
|
||||
week_ago_price: number
|
||||
// D2:数据来源声明——simulated 为模拟走势(参考数据,非实时行情)
|
||||
source?: string
|
||||
}
|
||||
|
||||
interface HistoryItem {
|
||||
@@ -112,6 +114,7 @@ const aluminum = reactive({
|
||||
change: 0,
|
||||
change_percent: 0,
|
||||
open: 0,
|
||||
source: '',
|
||||
high: 0,
|
||||
low: 0,
|
||||
prev_close: 0,
|
||||
@@ -124,12 +127,21 @@ const formatAluminumPrice = (val: number | null) => {
|
||||
return Math.round(val).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// D2:来源标注以接口 source 字段为准,不再硬编码交易所名称——
|
||||
// 此前显示"数据来源: 上海期货交易所"而数据实为模拟走势,属虚假来源声明
|
||||
const aluminumSourceLabel = computed(() => {
|
||||
if (aluminum.source === 'simulated') return '模拟数据 · 参考走势,非实时行情'
|
||||
if (aluminum.source) return `数据来源: ${aluminum.source}`
|
||||
return `更新于 ${aluminum.date}`
|
||||
})
|
||||
|
||||
const loadAluminumPrice = async () => {
|
||||
try {
|
||||
const data = await apiRequest<AluminumPrice>('/api/aluminum-price/current')
|
||||
aluminum.price = data.price
|
||||
aluminum.unit = data.unit
|
||||
aluminum.date = data.date
|
||||
aluminum.source = data.source ?? ''
|
||||
aluminum.change = data.change
|
||||
aluminum.change_percent = data.change_percent
|
||||
aluminum.open = data.open
|
||||
|
||||
@@ -240,7 +240,7 @@ const resetPassword = async (user: UserItem) => {
|
||||
try {
|
||||
await apiRequest(`/api/auth/users/${user.id}/reset-password`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(newPassword),
|
||||
body: JSON.stringify({ new_password: newPassword }),
|
||||
})
|
||||
addNotification('密码已重置', 'success')
|
||||
} catch (e) {
|
||||
|
||||
@@ -403,8 +403,4 @@ export const moldinsightApi = {
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
|
||||
getAluminumPrice() {
|
||||
return apiRequest<{ price: number; unit: string; updated_at: string }>('/api/aluminum-price/current')
|
||||
},
|
||||
}
|
||||
|
||||
+1713
-959
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
export default defineConfig(() => ({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
+6
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
- 从 shared.config.settings 读取 DB 配置,构造同步 URL(psycopg2)供 alembic 使用
|
||||
(项目运行时用 asyncpg,但 alembic 是同步库,需 psycopg2)
|
||||
- target_metadata 指向 shared.models.database.Base.metadata
|
||||
- target_metadata 指向 shared.models.base.Base.metadata(全量模型注册见下方 import)
|
||||
- 支持 ALEMBIC_URL 环境变量覆盖(用于离线/空库生成初始迁移,如 sqlite:///empty.db)
|
||||
"""
|
||||
from logging.config import fileConfig
|
||||
@@ -18,8 +18,11 @@ project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root / "src"))
|
||||
|
||||
from shared.config.settings import settings # noqa: E402
|
||||
from shared.models.database import Base # noqa: E402
|
||||
import shared.models.database # noqa: E402,F401 # 导入所有模型,确保 metadata 注册
|
||||
from shared.models.base import Base # noqa: E402
|
||||
# 导入全部三包模型,确保 metadata 注册(全量注册点约定见 shared/models/base.py)
|
||||
import shared.models.identity # noqa: E402,F401
|
||||
import moldinsight.models # noqa: E402,F401
|
||||
import inventory.models # noqa: E402,F401
|
||||
|
||||
config = context.config
|
||||
|
||||
|
||||
+1861
-613
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@ sys.path.insert(0, str(src_root))
|
||||
from sqlalchemy import select
|
||||
|
||||
from database.database import db_manager
|
||||
from models.database import MoldCavityData
|
||||
from moldinsight.models import MoldCavityData
|
||||
from storage.rustfs_storage import rustfs_manager
|
||||
from config.settings import settings
|
||||
from services.storage_integration_rustfs import StorageIntegrationService
|
||||
|
||||
@@ -14,7 +14,7 @@ sys.path.insert(0, str(project_root / "src"))
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy import text, select
|
||||
from config.settings import settings
|
||||
from models.database import SalesOrder, Customer
|
||||
from inventory.models import SalesOrder, Customer
|
||||
|
||||
|
||||
async def check():
|
||||
|
||||
+8
-15
@@ -1,23 +1,16 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
"""Celery 应用入口。
|
||||
|
||||
D14:broker/backend 复用 settings 的 Redis 配置——此前本模块自行
|
||||
load_dotenv 并手拼 REDIS URL,与 settings 两份实现、行为可能漂移。
|
||||
"""
|
||||
from celery import Celery
|
||||
|
||||
load_dotenv()
|
||||
|
||||
redis_host = os.getenv("REDIS_HOST", "localhost")
|
||||
redis_port = os.getenv("REDIS_PORT", "6379")
|
||||
redis_password = os.getenv("REDIS_PASSWORD", "")
|
||||
redis_db = os.getenv("REDIS_DB", "0")
|
||||
|
||||
if redis_password:
|
||||
broker_url = f"redis://:{redis_password}@{redis_host}:{redis_port}/{redis_db}"
|
||||
else:
|
||||
broker_url = f"redis://{redis_host}:{redis_port}/{redis_db}"
|
||||
from shared.config.settings import settings
|
||||
|
||||
app = Celery(
|
||||
"moldinsight",
|
||||
broker=broker_url,
|
||||
backend=broker_url,
|
||||
broker=settings.redis_url,
|
||||
backend=settings.redis_url,
|
||||
include=["celery_tasks"],
|
||||
)
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ def _register_routers(app):
|
||||
app = create_app(
|
||||
title="Gemold - 进销存管理系统",
|
||||
service_name="inventory",
|
||||
mount_html=False,
|
||||
serve_frontend_static=False,
|
||||
register_routers=_register_routers,
|
||||
)
|
||||
|
||||
@@ -20,11 +20,18 @@ def _register_routers(app):
|
||||
except Exception as e:
|
||||
print(f"[WARN] MoldInsight 路由: {e}")
|
||||
|
||||
# D11:HTML 报告代理挂根路径,URL 形状与原 StaticFiles 保持一致(/html/{filename})
|
||||
try:
|
||||
from moldinsight.api.html_report_router import include_into as include_html_report
|
||||
include_html_report(app)
|
||||
except Exception as e:
|
||||
print(f"[WARN] HTML 报告路由: {e}")
|
||||
|
||||
|
||||
app = create_app(
|
||||
title="Gemold - 模具分析引擎",
|
||||
service_name="moldinsight",
|
||||
mount_html=True,
|
||||
connect_rustfs=True,
|
||||
serve_frontend_static=False,
|
||||
register_routers=_register_routers,
|
||||
)
|
||||
|
||||
@@ -20,6 +20,13 @@ def _register_routers(app):
|
||||
except Exception as e:
|
||||
print(f"[WARN] MoldInsight 路由: {e}")
|
||||
|
||||
# D11:HTML 报告代理挂根路径,URL 形状与原 StaticFiles 保持一致(/html/{filename})
|
||||
try:
|
||||
from moldinsight.api.html_report_router import include_into as include_html_report
|
||||
include_html_report(app)
|
||||
except Exception as e:
|
||||
print(f"[WARN] HTML 报告路由: {e}")
|
||||
|
||||
try:
|
||||
from inventory.api import inventory_router
|
||||
app.include_router(inventory_router)
|
||||
@@ -30,7 +37,7 @@ def _register_routers(app):
|
||||
app = create_app(
|
||||
title="Gemold - Unified Backend",
|
||||
service_name="unified",
|
||||
mount_html=True,
|
||||
connect_rustfs=True,
|
||||
serve_frontend_static=False,
|
||||
register_routers=_register_routers,
|
||||
)
|
||||
|
||||
@@ -17,7 +17,8 @@ from datetime import datetime
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user, get_current_admin_user
|
||||
from shared.models.database import User, Customer
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Customer
|
||||
from ..schemas import CustomerCreate, CustomerResponse
|
||||
|
||||
router = APIRouter(prefix="/customers", tags=["客户管理"])
|
||||
|
||||
@@ -15,10 +15,8 @@ from sqlalchemy import select, func
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import (
|
||||
User, Product, Supplier, Customer, Warehouse,
|
||||
Inventory, PurchaseOrder, SalesOrder
|
||||
)
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Product, Supplier, Customer, Warehouse, Inventory, PurchaseOrder, SalesOrder
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["仪表盘"])
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Optional, List
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from ..schemas import (
|
||||
ReceiptCreate,
|
||||
PaymentCreate,
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Optional
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from ..schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse
|
||||
from ..services.inventory_service import inventory_service
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ from typing import Optional, List
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User, Product, MaterialPriceHistory, MaterialSupplier, Supplier
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Product, MaterialPriceHistory, MaterialSupplier, Supplier
|
||||
from ..schemas import (
|
||||
MaterialPriceHistoryCreate,
|
||||
MaterialPriceHistoryResponse,
|
||||
|
||||
@@ -18,7 +18,9 @@ from pathlib import Path
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user, get_current_admin_user
|
||||
from shared.models.database import User, Product, ProductMaterial, STPFile, ProcessingTask
|
||||
from shared.models.identity import User
|
||||
from moldinsight.models import STPFile, ProcessingTask
|
||||
from inventory.models import Product, ProductMaterial
|
||||
from ..schemas import (
|
||||
ProductCreate,
|
||||
ProductResponse,
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from ..schemas import (
|
||||
PurchaseDemandCalculateRequest,
|
||||
PurchaseDemandResponse,
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Optional
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from ..schemas import (
|
||||
PurchaseOrderCreate,
|
||||
PurchaseOrderResponse,
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Optional
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from ..schemas import (
|
||||
SalesOrderCreate,
|
||||
SalesOrderResponse,
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Optional
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from ..schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse
|
||||
from ..services.stock_movement_service import stock_movement_service
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ from datetime import datetime
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user, get_current_admin_user
|
||||
from shared.models.database import User, Supplier
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Supplier
|
||||
from ..schemas import SupplierCreate, SupplierResponse
|
||||
|
||||
router = APIRouter(prefix="/suppliers", tags=["供应商管理"])
|
||||
|
||||
@@ -15,7 +15,8 @@ from datetime import datetime
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User, Warehouse
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Warehouse
|
||||
from ..schemas import WarehouseCreate, WarehouseResponse
|
||||
|
||||
router = APIRouter(prefix="/warehouses", tags=["仓库管理"])
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""inventory 域模型出口(目录 / 仓储 / 交易 / 财务四个域文件)。
|
||||
|
||||
全量模型注册点见 shared/models/base.py 模块 docstring;
|
||||
业务代码按需 `from inventory.models import Product, ...`。
|
||||
"""
|
||||
from inventory.models.catalog import (
|
||||
Product,
|
||||
ProductMaterial,
|
||||
MaterialPriceHistory,
|
||||
MaterialSupplier,
|
||||
Supplier,
|
||||
Customer,
|
||||
)
|
||||
from inventory.models.warehouse import (
|
||||
Warehouse,
|
||||
Inventory,
|
||||
StockMovement,
|
||||
)
|
||||
from inventory.models.trading import (
|
||||
PurchaseOrder,
|
||||
PurchaseOrderItem,
|
||||
SalesOrder,
|
||||
SalesOrderItem,
|
||||
)
|
||||
from inventory.models.finance import (
|
||||
FinanceTransaction,
|
||||
FinanceAllocation,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Product",
|
||||
"ProductMaterial",
|
||||
"MaterialPriceHistory",
|
||||
"MaterialSupplier",
|
||||
"Supplier",
|
||||
"Customer",
|
||||
"Warehouse",
|
||||
"Inventory",
|
||||
"StockMovement",
|
||||
"PurchaseOrder",
|
||||
"PurchaseOrderItem",
|
||||
"SalesOrder",
|
||||
"SalesOrderItem",
|
||||
"FinanceTransaction",
|
||||
"FinanceAllocation",
|
||||
]
|
||||
@@ -0,0 +1,166 @@
|
||||
"""inventory 目录域模型:成品/物料/BOM/价格/供应商/客户。
|
||||
|
||||
从旧 shared/models/database.py 拆出(D3,2026-09-17)。
|
||||
跨模块桥接只保留裸 FK(base.py 约定):operator 类字段 user_id -> users.id 不建 relationship。
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, Numeric, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from shared.models.base import Base
|
||||
|
||||
|
||||
class Product(Base):
|
||||
"""产品表"""
|
||||
__tablename__ = "products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
sku = Column(String(50), unique=True, index=True, nullable=False)
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
category = Column(String(100), nullable=True)
|
||||
unit = Column(String(20), default="件")
|
||||
item_type = Column(String(20), default="finished", index=True)
|
||||
cost_price = Column(Numeric(12, 2), default=0)
|
||||
sale_price = Column(Numeric(12, 2), default=0)
|
||||
min_stock = Column(Integer, default=0)
|
||||
max_stock = Column(Integer, default=1000)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
inventory = relationship("Inventory", back_populates="product", uselist=False)
|
||||
stock_movements = relationship("StockMovement", back_populates="product")
|
||||
bom_materials = relationship(
|
||||
"ProductMaterial",
|
||||
foreign_keys="ProductMaterial.finished_product_id",
|
||||
back_populates="finished_product",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
used_in_products = relationship(
|
||||
"ProductMaterial",
|
||||
foreign_keys="ProductMaterial.material_product_id",
|
||||
back_populates="material_product"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Product(id={self.id}, sku='{self.sku}', name='{self.name}')>"
|
||||
|
||||
|
||||
class ProductMaterial(Base):
|
||||
__tablename__ = "product_materials"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("finished_product_id", "material_product_id", name="uq_product_material_unique"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
finished_product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
material_product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
quantity = Column(Numeric(12, 4), nullable=False)
|
||||
loss_rate = Column(Numeric(5, 4), default=0)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
finished_product = relationship(
|
||||
"Product",
|
||||
foreign_keys=[finished_product_id],
|
||||
back_populates="bom_materials"
|
||||
)
|
||||
material_product = relationship(
|
||||
"Product",
|
||||
foreign_keys=[material_product_id],
|
||||
back_populates="used_in_products"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ProductMaterial(finished_product_id={self.finished_product_id}, material_product_id={self.material_product_id})>"
|
||||
|
||||
|
||||
class MaterialPriceHistory(Base):
|
||||
"""物料价格历史表"""
|
||||
__tablename__ = "material_price_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
price = Column(Numeric(12, 2), nullable=False)
|
||||
effective_date = Column(DateTime, default=func.now(), index=True)
|
||||
supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=True, index=True)
|
||||
remark = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
product = relationship("Product", backref="price_history")
|
||||
supplier = relationship("Supplier", backref="price_history")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MaterialPriceHistory(product_id={self.product_id}, price={self.price}, date={self.effective_date})>"
|
||||
|
||||
|
||||
class MaterialSupplier(Base):
|
||||
"""物料供应商关联表"""
|
||||
__tablename__ = "material_suppliers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=False, index=True)
|
||||
is_primary = Column(Boolean, default=False)
|
||||
contact_person = Column(String(100), nullable=True)
|
||||
contact_phone = Column(String(50), nullable=True)
|
||||
lead_time = Column(Integer, nullable=True) # 交货周期(天)
|
||||
min_order_quantity = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
product = relationship("Product", backref="suppliers")
|
||||
supplier = relationship("Supplier", backref="materials")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MaterialSupplier(product_id={self.product_id}, supplier_id={self.supplier_id}, primary={self.is_primary})>"
|
||||
|
||||
|
||||
class Supplier(Base):
|
||||
"""供应商表"""
|
||||
__tablename__ = "suppliers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
contact_person = Column(String(100), nullable=True)
|
||||
phone = Column(String(50), nullable=True)
|
||||
email = Column(String(100), nullable=True)
|
||||
address = Column(Text, nullable=True)
|
||||
bank_name = Column(String(100), nullable=True)
|
||||
bank_account = Column(String(50), nullable=True)
|
||||
tax_number = Column(String(50), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
purchase_orders = relationship("PurchaseOrder", back_populates="supplier")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Supplier(id={self.id}, name='{self.name}')>"
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
"""客户表"""
|
||||
__tablename__ = "customers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
contact_person = Column(String(100), nullable=True)
|
||||
phone = Column(String(50), nullable=True)
|
||||
email = Column(String(100), nullable=True)
|
||||
address = Column(Text, nullable=True)
|
||||
bank_name = Column(String(100), nullable=True)
|
||||
bank_account = Column(String(50), nullable=True)
|
||||
tax_number = Column(String(50), nullable=True)
|
||||
credit_limit = Column(Numeric(12, 2), default=0)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
sales_orders = relationship("SalesOrder", back_populates="customer")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Customer(id={self.id}, name='{self.name}')>"
|
||||
@@ -0,0 +1,45 @@
|
||||
"""inventory 财务域模型:收付款交易与订单分摊。"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Numeric, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from shared.models.base import Base
|
||||
|
||||
|
||||
class FinanceTransaction(Base):
|
||||
__tablename__ = "finance_transactions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
txn_no = Column(String(50), unique=True, index=True, nullable=False)
|
||||
txn_type = Column(String(20), nullable=False, index=True)
|
||||
partner_type = Column(String(20), nullable=False, index=True)
|
||||
partner_id = Column(Integer, nullable=False, index=True)
|
||||
amount = Column(Numeric(12, 2), nullable=False)
|
||||
txn_date = Column(DateTime, default=func.now(), index=True)
|
||||
method = Column(String(30), default="bank")
|
||||
account_name = Column(String(100), nullable=True)
|
||||
status = Column(String(20), default="confirmed", index=True)
|
||||
remark = Column(Text, nullable=True)
|
||||
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
allocations = relationship("FinanceAllocation", back_populates="transaction", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<FinanceTransaction(txn_no='{self.txn_no}', txn_type='{self.txn_type}', amount={self.amount})>"
|
||||
|
||||
|
||||
class FinanceAllocation(Base):
|
||||
__tablename__ = "finance_allocations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
transaction_id = Column(Integer, ForeignKey("finance_transactions.id"), nullable=False, index=True)
|
||||
order_type = Column(String(20), nullable=False, index=True)
|
||||
order_id = Column(Integer, nullable=False, index=True)
|
||||
allocated_amount = Column(Numeric(12, 2), nullable=False)
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
transaction = relationship("FinanceTransaction", back_populates="allocations")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<FinanceAllocation(transaction_id={self.transaction_id}, order_type='{self.order_type}', amount={self.allocated_amount})>"
|
||||
@@ -0,0 +1,108 @@
|
||||
"""inventory 交易域模型:采购订单/销售订单及明细。"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Date, Numeric, ForeignKey, CheckConstraint
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from shared.models.base import Base
|
||||
|
||||
|
||||
class PurchaseOrder(Base):
|
||||
"""采购订单表"""
|
||||
__tablename__ = "purchase_orders"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_no = Column(String(50), unique=True, index=True, nullable=False)
|
||||
supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=False, index=True)
|
||||
order_date = Column(DateTime, default=func.now())
|
||||
expected_date = Column(Date, nullable=True)
|
||||
status = Column(String(20), default="draft")
|
||||
total_amount = Column(Numeric(12, 2), default=0)
|
||||
paid_amount = Column(Numeric(12, 2), default=0)
|
||||
remark = Column(Text, nullable=True)
|
||||
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
# 状态变更时间
|
||||
received_date = Column(DateTime, nullable=True) # 已收货时间
|
||||
paid_date = Column(DateTime, nullable=True) # 已付款时间
|
||||
|
||||
supplier = relationship("Supplier", back_populates="purchase_orders")
|
||||
items = relationship("PurchaseOrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PurchaseOrder(order_no='{self.order_no}', status='{self.status}')>"
|
||||
|
||||
|
||||
class PurchaseOrderItem(Base):
|
||||
"""采购订单明细表"""
|
||||
__tablename__ = "purchase_order_items"
|
||||
__table_args__ = (
|
||||
CheckConstraint("quantity > 0 AND received_quantity >= 0 AND received_quantity <= quantity", name="ck_purchase_order_items_qty"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("purchase_orders.id"), nullable=False)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
quantity = Column(Integer, nullable=False)
|
||||
received_quantity = Column(Integer, default=0)
|
||||
unit_price = Column(Numeric(12, 2), nullable=False)
|
||||
amount = Column(Numeric(12, 2), nullable=False)
|
||||
remark = Column(Text, nullable=True)
|
||||
|
||||
order = relationship("PurchaseOrder", back_populates="items")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PurchaseOrderItem(order_id={self.order_id}, product_id={self.product_id})>"
|
||||
|
||||
|
||||
class SalesOrder(Base):
|
||||
"""销售订单表"""
|
||||
__tablename__ = "sales_orders"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_no = Column(String(50), unique=True, index=True, nullable=False)
|
||||
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False, index=True)
|
||||
order_date = Column(DateTime, default=func.now())
|
||||
delivery_date = Column(Date, nullable=True)
|
||||
manufacturing_date = Column(DateTime, nullable=True)
|
||||
actual_delivery_date = Column(DateTime, nullable=True)
|
||||
actual_payment_date = Column(DateTime, nullable=True)
|
||||
status = Column(String(20), default="draft")
|
||||
production_status = Column(String(20), default="not_started", index=True)
|
||||
production_no = Column(String(50), nullable=True, index=True)
|
||||
planned_material_cost = Column(Numeric(12, 2), default=0)
|
||||
actual_material_cost = Column(Numeric(12, 2), default=0)
|
||||
total_amount = Column(Numeric(12, 2), default=0)
|
||||
received_amount = Column(Numeric(12, 2), default=0)
|
||||
remark = Column(Text, nullable=True)
|
||||
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
customer = relationship("Customer", back_populates="sales_orders")
|
||||
items = relationship("SalesOrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SalesOrder(order_no='{self.order_no}', status='{self.status}')>"
|
||||
|
||||
|
||||
class SalesOrderItem(Base):
|
||||
"""销售订单明细表"""
|
||||
__tablename__ = "sales_order_items"
|
||||
__table_args__ = (
|
||||
CheckConstraint("quantity > 0 AND delivered_quantity >= 0 AND delivered_quantity <= quantity", name="ck_sales_order_items_qty"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("sales_orders.id"), nullable=False)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
quantity = Column(Integer, nullable=False)
|
||||
delivered_quantity = Column(Integer, default=0)
|
||||
unit_price = Column(Numeric(12, 2), nullable=False)
|
||||
amount = Column(Numeric(12, 2), nullable=False)
|
||||
remark = Column(Text, nullable=True)
|
||||
|
||||
order = relationship("SalesOrder", back_populates="items")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SalesOrderItem(order_id={self.order_id}, product_id={self.product_id})>"
|
||||
@@ -0,0 +1,80 @@
|
||||
"""inventory 仓储域模型:仓库/库存/库存流水。"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, Numeric, ForeignKey, UniqueConstraint, CheckConstraint
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from shared.models.base import Base
|
||||
|
||||
|
||||
class Warehouse(Base):
|
||||
"""仓库表"""
|
||||
__tablename__ = "warehouses"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
address = Column(Text, nullable=True)
|
||||
manager = Column(String(100), nullable=True)
|
||||
phone = Column(String(50), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_default = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
inventories = relationship("Inventory", back_populates="warehouse")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Warehouse(id={self.id}, name='{self.name}')>"
|
||||
|
||||
|
||||
class Inventory(Base):
|
||||
"""库存表"""
|
||||
__tablename__ = "inventory"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("product_id", "warehouse_id", name="uq_inventory_product_warehouse"),
|
||||
CheckConstraint("quantity >= 0 AND locked_quantity >= 0 AND locked_quantity <= quantity", name="ck_inventory_qty_nonnegative"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False, index=True)
|
||||
quantity = Column(Numeric(12, 4), default=0)
|
||||
locked_quantity = Column(Numeric(12, 4), default=0)
|
||||
batch_number = Column(String(50), nullable=True)
|
||||
location = Column(String(100), nullable=True)
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
product = relationship("Product", back_populates="inventory")
|
||||
warehouse = relationship("Warehouse", back_populates="inventories")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Inventory(product_id={self.product_id}, quantity={self.quantity})>"
|
||||
|
||||
@property
|
||||
def available_quantity(self):
|
||||
return self.quantity - self.locked_quantity
|
||||
|
||||
|
||||
class StockMovement(Base):
|
||||
"""库存变动记录表"""
|
||||
__tablename__ = "stock_movements"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False)
|
||||
movement_type = Column(String(20), nullable=False)
|
||||
quantity = Column(Numeric(12, 4), nullable=False)
|
||||
before_quantity = Column(Numeric(12, 4), default=0)
|
||||
after_quantity = Column(Numeric(12, 4), default=0)
|
||||
reference_type = Column(String(50), nullable=True)
|
||||
reference_id = Column(Integer, nullable=True)
|
||||
reference_no = Column(String(50), nullable=True)
|
||||
unit_price = Column(Numeric(12, 2), nullable=True)
|
||||
total_amount = Column(Numeric(12, 2), nullable=True)
|
||||
remark = Column(Text, nullable=True)
|
||||
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
product = relationship("Product", back_populates="stock_movements")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<StockMovement(id={self.id}, type='{self.movement_type}', qty={self.quantity})>"
|
||||
@@ -11,18 +11,8 @@ from typing import Optional, List, Dict, Tuple
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from shared.models.database import (
|
||||
User,
|
||||
Customer,
|
||||
Supplier,
|
||||
Product,
|
||||
SalesOrder,
|
||||
SalesOrderItem,
|
||||
PurchaseOrder,
|
||||
PurchaseOrderItem,
|
||||
FinanceTransaction,
|
||||
FinanceAllocation,
|
||||
)
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Customer, Supplier, Product, SalesOrder, SalesOrderItem, PurchaseOrder, PurchaseOrderItem, FinanceTransaction, FinanceAllocation
|
||||
from ..schemas import (
|
||||
ReceiptCreate,
|
||||
PaymentCreate,
|
||||
|
||||
@@ -10,7 +10,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from shared.models.database import User, Product, Warehouse, Inventory
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Product, Warehouse, Inventory
|
||||
from ..schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse
|
||||
|
||||
|
||||
|
||||
@@ -10,18 +10,8 @@ from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from shared.models.database import (
|
||||
User,
|
||||
Product,
|
||||
ProductMaterial,
|
||||
SalesOrder,
|
||||
SalesOrderItem,
|
||||
Inventory,
|
||||
MaterialSupplier,
|
||||
Supplier,
|
||||
PurchaseOrder,
|
||||
PurchaseOrderItem,
|
||||
)
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Product, ProductMaterial, SalesOrder, SalesOrderItem, Inventory, MaterialSupplier, Supplier, PurchaseOrder, PurchaseOrderItem
|
||||
from ..utils import generate_order_no
|
||||
from ..schemas.purchase_demand_schemas import (
|
||||
PurchaseDemandItemResponse,
|
||||
|
||||
@@ -10,16 +10,8 @@ from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, update
|
||||
|
||||
from shared.models.database import (
|
||||
User,
|
||||
Supplier,
|
||||
Product,
|
||||
Warehouse,
|
||||
Inventory,
|
||||
StockMovement,
|
||||
PurchaseOrder,
|
||||
PurchaseOrderItem,
|
||||
)
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Supplier, Product, Warehouse, Inventory, StockMovement, PurchaseOrder, PurchaseOrderItem
|
||||
from ..schemas import (
|
||||
PurchaseOrderCreate,
|
||||
PurchaseOrderResponse,
|
||||
|
||||
@@ -12,17 +12,8 @@ from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, delete, update
|
||||
|
||||
from shared.models.database import (
|
||||
User,
|
||||
Customer,
|
||||
Product,
|
||||
ProductMaterial,
|
||||
Warehouse,
|
||||
Inventory,
|
||||
StockMovement,
|
||||
SalesOrder,
|
||||
SalesOrderItem,
|
||||
)
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Customer, Product, ProductMaterial, Warehouse, Inventory, StockMovement, SalesOrder, SalesOrderItem
|
||||
from ..schemas import (
|
||||
SalesOrderCreate,
|
||||
SalesOrderResponse,
|
||||
|
||||
@@ -9,7 +9,8 @@ from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, func
|
||||
|
||||
from shared.models.database import User, Product, Warehouse, Inventory, StockMovement
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Product, Warehouse, Inventory, StockMovement
|
||||
from ..schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse
|
||||
from ..utils import generate_order_no
|
||||
|
||||
|
||||
@@ -3,32 +3,54 @@ import importlib
|
||||
|
||||
from shared.config.settings import settings
|
||||
from shared.utils.logger import get_logger
|
||||
from moldinsight.api.route_registry import route_load_status
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def _safe_include(module_path: str, label: str):
|
||||
# 业务路由装载清单:新增路由必须登记于此。
|
||||
# 失败语义(原 _safe_include 仅 WARNING 跳过,进程带病启动不可感知):
|
||||
# - 非 DEBUG:记录进 route_load_status["failed"],/api/health 呈现 degraded
|
||||
# - DEBUG:直接抛错 fail fast——开发环境路由缺失必须当场暴露
|
||||
ROUTE_MODULES = [
|
||||
# (label, module_path, debug_only)
|
||||
("健康检查", "moldinsight.api.health_router", False),
|
||||
("上传", "moldinsight.api.upload_router", False),
|
||||
("批量", "moldinsight.api.batch_router", False),
|
||||
("任务", "moldinsight.api.task_router", False),
|
||||
("历史", "moldinsight.api.history_router", False),
|
||||
("CAM", "moldinsight.api.cam_router", False),
|
||||
("设计", "moldinsight.api.design_router", False),
|
||||
("成本", "moldinsight.api.cost_router", False),
|
||||
("加工", "moldinsight.api.machining_router", False),
|
||||
("导出", "moldinsight.api.export_router", False),
|
||||
("铝价", "moldinsight.api.aluminum_price_routes", False),
|
||||
# 调试端点会 dump 全量任务数据,仅 DEBUG 模式注册(双重防线:还需登录)
|
||||
("调试", "moldinsight.api.debug_router", True),
|
||||
]
|
||||
|
||||
|
||||
def _safe_include(label: str, module_path: str, debug_only: bool = False):
|
||||
if debug_only and not settings.DEBUG:
|
||||
route_load_status["disabled"].append({"label": label, "module": module_path})
|
||||
return
|
||||
try:
|
||||
module = importlib.import_module(module_path)
|
||||
router_obj = getattr(module, "router", None)
|
||||
if router_obj is None:
|
||||
raise ValueError("未找到 router 对象")
|
||||
router.include_router(router_obj)
|
||||
route_load_status["loaded"].append({"label": label, "module": module_path})
|
||||
logger.info(f"{label} 路由加载成功")
|
||||
except Exception as exc:
|
||||
logger.warning(f"{label} 路由加载失败,已跳过: {exc}")
|
||||
route_load_status["failed"].append(
|
||||
{"label": label, "module": module_path, "error": str(exc)}
|
||||
)
|
||||
logger.error(f"{label} 路由加载失败: {exc}")
|
||||
if settings.DEBUG:
|
||||
raise
|
||||
|
||||
|
||||
_safe_include("moldinsight.api.health_router", "健康检查")
|
||||
_safe_include("moldinsight.api.upload_router", "上传")
|
||||
_safe_include("moldinsight.api.batch_router", "批量")
|
||||
_safe_include("moldinsight.api.task_router", "任务")
|
||||
_safe_include("moldinsight.api.history_router", "历史")
|
||||
_safe_include("moldinsight.api.cam_router", "CAM")
|
||||
_safe_include("moldinsight.api.advanced_router", "高级")
|
||||
_safe_include("moldinsight.api.aluminum_price_routes", "铝价")
|
||||
|
||||
# 调试端点会 dump 全量任务数据,仅 DEBUG 模式注册(双重防线:还需登录)
|
||||
if settings.DEBUG:
|
||||
_safe_include("moldinsight.api.debug_router", "调试")
|
||||
for _label, _module_path, _debug_only in ROUTE_MODULES:
|
||||
_safe_include(_label, _module_path, _debug_only)
|
||||
|
||||
@@ -1,592 +0,0 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from moldinsight.services.processing_service import processing_service
|
||||
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
|
||||
from moldinsight.services.task_query_service import TaskQueryService
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.database import User
|
||||
from moldinsight.core.cad_exporter import CADExporter
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
cad_exporter = CADExporter()
|
||||
storage_service = StorageIntegrationService()
|
||||
|
||||
_cached_instances = {}
|
||||
|
||||
|
||||
def _get_cached_import(key: str):
|
||||
"""惰性导入核心模块,避免路由器模块级加载时的循环依赖。"""
|
||||
if key in _cached_instances:
|
||||
return _cached_instances[key]
|
||||
try:
|
||||
if key == "side_action_designer":
|
||||
from moldinsight.core.side_action_designer import SideActionDesigner
|
||||
instance = SideActionDesigner()
|
||||
elif key == "cavity_layout_optimizer":
|
||||
from moldinsight.core.cavity_layout_optimizer import CavityLayoutOptimizer
|
||||
instance = CavityLayoutOptimizer()
|
||||
elif key == "mold_system_designer":
|
||||
from moldinsight.core.mold_system_designer import MoldSystemDesigner
|
||||
instance = MoldSystemDesigner()
|
||||
elif key == "mold_cam_designer":
|
||||
from moldinsight.core.mold_cam import MoldCAMDesigner
|
||||
instance = MoldCAMDesigner()
|
||||
elif key == "collision_detector":
|
||||
from moldinsight.core.mold_machining import CollisionDetector
|
||||
instance = CollisionDetector()
|
||||
elif key == "toolpath_optimizer":
|
||||
from moldinsight.core.mold_machining import ToolpathOptimizer
|
||||
instance = ToolpathOptimizer()
|
||||
elif key == "edm_designer":
|
||||
from moldinsight.core.mold_machining import EDMElectrodeDesigner
|
||||
instance = EDMElectrodeDesigner()
|
||||
elif key == "machining_simulator":
|
||||
from moldinsight.core.mold_machining import MachiningSimulator
|
||||
instance = MachiningSimulator()
|
||||
else:
|
||||
return None
|
||||
_cached_instances[key] = instance
|
||||
return instance
|
||||
except Exception as e:
|
||||
logger.warning(f"核心模块 {key} 加载失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _ensure_task_access(
|
||||
db_session: AsyncSession,
|
||||
task_id: str,
|
||||
user_id: int,
|
||||
):
|
||||
# 归属校验统一走 TaskQueryService(与 /api/status 共用,含 404/403 语义)
|
||||
return await TaskQueryService.ensure_task_access(db_session, task_id, user_id)
|
||||
|
||||
|
||||
def _get_export_artifacts(task_data: dict) -> dict:
|
||||
if not isinstance(task_data, dict):
|
||||
return {}
|
||||
direct = task_data.get("export_artifacts")
|
||||
if isinstance(direct, dict):
|
||||
return direct
|
||||
parameters = task_data.get("parameters")
|
||||
if isinstance(parameters, dict) and isinstance(parameters.get("export_artifacts"), dict):
|
||||
return parameters.get("export_artifacts")
|
||||
return {}
|
||||
|
||||
|
||||
def _expand_components(components):
|
||||
requested = components or ["cavity", "core"]
|
||||
if "all" in requested:
|
||||
return ["cavity", "core", "parting_surface"]
|
||||
return list(dict.fromkeys(requested))
|
||||
|
||||
|
||||
def _augment_export_files(task_id: str, files):
|
||||
items = []
|
||||
for file in files or []:
|
||||
item = dict(file)
|
||||
relative_path = item.get("relative_path")
|
||||
if not relative_path and item.get("filepath"):
|
||||
relative_path = cad_exporter.get_relative_path(item["filepath"])
|
||||
if relative_path:
|
||||
relative_path = str(relative_path).replace("\\", "/").strip("/")
|
||||
item["relative_path"] = relative_path
|
||||
item["download_path"] = f"/api/export-download/{quote(relative_path, safe='/')}?task_id={task_id}"
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def _merge_export_artifacts(existing: dict, export_result: dict) -> dict:
|
||||
merged = dict(existing or {})
|
||||
schemes = dict(merged.get("schemes") or {})
|
||||
scheme_id = export_result.get("scheme_id") or "default"
|
||||
previous = dict(schemes.get(scheme_id) or {})
|
||||
|
||||
file_map = {}
|
||||
for file in previous.get("files", []):
|
||||
file_map[(file.get("component"), file.get("format"))] = file
|
||||
for file in export_result.get("files", []):
|
||||
file_map[(file.get("component"), file.get("format"))] = file
|
||||
|
||||
schemes[scheme_id] = {
|
||||
"base_filename": export_result.get("base_filename") or previous.get("base_filename"),
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"files": sorted(
|
||||
file_map.values(),
|
||||
key=lambda item: (item.get("component", ""), item.get("format", "")),
|
||||
),
|
||||
"errors": export_result.get("errors", []),
|
||||
"total_files": len(file_map),
|
||||
"total_errors": len(export_result.get("errors", [])),
|
||||
}
|
||||
|
||||
merged["version"] = 1
|
||||
merged["task_id"] = export_result.get("task_id") or merged.get("task_id")
|
||||
merged["generated_at"] = merged.get("generated_at") or datetime.now().isoformat()
|
||||
merged["schemes"] = schemes
|
||||
return merged
|
||||
|
||||
|
||||
def _select_persisted_files(task_id: str, task_data: dict, scheme_id: str, formats, components):
|
||||
artifacts = _get_export_artifacts(task_data)
|
||||
scheme_data = (artifacts.get("schemes") or {}).get(scheme_id)
|
||||
if not scheme_data:
|
||||
return None
|
||||
|
||||
component_list = _expand_components(components)
|
||||
format_list = list(dict.fromkeys(formats or ["step", "stl"]))
|
||||
expected = {(component, fmt) for component in component_list for fmt in format_list}
|
||||
|
||||
available = []
|
||||
available_keys = set()
|
||||
for file in scheme_data.get("files", []):
|
||||
component = file.get("component")
|
||||
fmt = file.get("format")
|
||||
if component not in component_list or fmt not in format_list:
|
||||
continue
|
||||
relative_path = str(file.get("relative_path") or "").replace("\\", "/").strip("/")
|
||||
if not relative_path:
|
||||
continue
|
||||
full_path = os.path.join(cad_exporter.output_dir, relative_path.replace("/", os.sep))
|
||||
if not os.path.exists(full_path):
|
||||
continue
|
||||
available.append(file)
|
||||
available_keys.add((component, fmt))
|
||||
|
||||
if expected and not expected.issubset(available_keys):
|
||||
return None
|
||||
|
||||
return _augment_export_files(task_id, available)
|
||||
|
||||
|
||||
@router.post("/optimize-layout")
|
||||
async def optimize_cavity_layout(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
product_bbox = body.get("product_bbox", {"dimensions": [100, 100, 50]})
|
||||
cavity_count = body.get("cavity_count", 1)
|
||||
mold_base_size = body.get("mold_base_size")
|
||||
layout_type = body.get("layout_type", "auto")
|
||||
if cavity_count < 1 or cavity_count > 64:
|
||||
raise HTTPException(400, "型腔数量必须在 1-64 之间")
|
||||
optimizer = _get_cached_import("cavity_layout_optimizer")
|
||||
if not optimizer:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = optimizer.optimize_layout(
|
||||
product_bbox=product_bbox,
|
||||
cavity_count=cavity_count,
|
||||
mold_base_size=mold_base_size,
|
||||
layout_type=layout_type,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-cooling")
|
||||
async def design_cooling_system(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
mold_size = body.get("mold_size", {"length": 300, "width": 300, "height": 200})
|
||||
product_bbox = body.get("product_bbox", {"dimensions": [100, 100, 50]})
|
||||
material = body.get("material", "ABS")
|
||||
cavity_count = body.get("cavity_count", 1)
|
||||
cycle_time_target = body.get("cycle_time_target")
|
||||
from moldinsight.core.mold_system_designer import CoolingSystemDesigner
|
||||
designer = CoolingSystemDesigner()
|
||||
result = designer.design_cooling_system(
|
||||
mold_size=mold_size, product_bbox=product_bbox,
|
||||
material=material, cavity_count=cavity_count,
|
||||
cycle_time_target=cycle_time_target,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-gating")
|
||||
async def design_gating_system(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
product_bbox = body.get("product_bbox", {"dimensions": [100, 100, 50]})
|
||||
material = body.get("material", "ABS")
|
||||
cavity_count = body.get("cavity_count", 1)
|
||||
gate_type = body.get("gate_type", "auto")
|
||||
layout_positions = body.get("layout_positions")
|
||||
from moldinsight.core.mold_system_designer import GatingSystemDesigner
|
||||
designer = GatingSystemDesigner()
|
||||
result = designer.design_gating_system(
|
||||
product_bbox=product_bbox, material=material,
|
||||
cavity_count=cavity_count, gate_type=gate_type,
|
||||
layout_positions=layout_positions,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-mold-system")
|
||||
async def design_complete_mold_system(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
mold_size = body.get("mold_size", {"length": 300, "width": 300, "height": 200})
|
||||
product_bbox = body.get("product_bbox", {"dimensions": [100, 100, 50]})
|
||||
material = body.get("material", "ABS")
|
||||
cavity_count = body.get("cavity_count", 1)
|
||||
gate_type = body.get("gate_type", "auto")
|
||||
cycle_time_target = body.get("cycle_time_target")
|
||||
layout_positions = body.get("layout_positions")
|
||||
ds = _get_cached_import("mold_system_designer")
|
||||
if not ds:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = ds.design_complete_system(
|
||||
mold_size=mold_size, product_bbox=product_bbox,
|
||||
material=material, cavity_count=cavity_count,
|
||||
gate_type=gate_type, cycle_time_target=cycle_time_target,
|
||||
layout_positions=layout_positions,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/detect-undercuts")
|
||||
async def detect_undercuts(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
body = await request.json()
|
||||
task_id = body.get("task_id")
|
||||
parting_direction = body.get("parting_direction", [0, 0, 1])
|
||||
mold_size = body.get("mold_size", {"length": 300, "width": 300, "height": 200})
|
||||
if not task_id:
|
||||
raise HTTPException(400, "缺少 task_id")
|
||||
|
||||
await _ensure_task_access(db_session, task_id, current_user.id)
|
||||
|
||||
sd = _get_cached_import("side_action_designer")
|
||||
if not sd:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
|
||||
# 从持久化 STP 原件重建几何(此前传 shape=None 会被兜底吞掉,永远返回"无倒扣")
|
||||
from moldinsight.services.shape_loader import get_shape_loader
|
||||
shape = await get_shape_loader().load_shape_for_task(db_session, task_id)
|
||||
if shape is None:
|
||||
raise HTTPException(410, "任务几何不可用:无法从存储重建 STP 形状,请重新上传分析")
|
||||
|
||||
result = await processing_service.run_occ(
|
||||
sd.analyze_and_design,
|
||||
shape,
|
||||
parting_direction,
|
||||
mold_size,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/cost-estimate")
|
||||
async def estimate_cost(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""模具成本估算:优先使用 LLM,未启用时降级为规则式估算"""
|
||||
body = await request.json()
|
||||
task_id = body.get("task_id")
|
||||
if not task_id:
|
||||
raise HTTPException(400, "缺少 task_id")
|
||||
|
||||
await _ensure_task_access(db_session, task_id, current_user.id)
|
||||
|
||||
# 统一走任务视图:进行中读 Redis,完成态由 PG+RustFS 组装(Redis 大对象已瘦身)
|
||||
task_data = await TaskQueryService.get_task_view(db_session, task_id)
|
||||
if not task_data:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
analysis_result = task_data.get("analysis_result")
|
||||
if not analysis_result:
|
||||
raise HTTPException(400, "该任务尚未完成分析")
|
||||
detailed_context = {
|
||||
"candidate_schemes": task_data.get("candidate_schemes", []),
|
||||
"geometry_data": task_data.get("geometry_data", {}),
|
||||
"metadata": {"selected_material": task_data.get("material")},
|
||||
}
|
||||
# 优先使用 LLM
|
||||
from moldinsight.services.llm_service import llm_service
|
||||
result = await llm_service.estimate_cost(analysis_result, detailed_context)
|
||||
if result is not None:
|
||||
result["source"] = "ai"
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
# LLM 未启用或失败,降级为规则估算
|
||||
from moldinsight.services.cost_estimate_service import estimate_cost_by_rules
|
||||
rules_result = estimate_cost_by_rules(analysis_result, detailed_context)
|
||||
return {"status": "success", "data": rules_result}
|
||||
|
||||
|
||||
@router.post("/design-cam")
|
||||
async def design_mold_cam(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
cavity_bbox = body.get("cavity_bbox", {"dimensions": [100, 100, 50], "min": [-50, -50, -25], "max": [50, 50, 25]})
|
||||
stock_bbox = body.get("stock_bbox", {"dimensions": [150, 150, 100], "min": [-75, -75, -50], "max": [75, 75, 50]})
|
||||
mold_steel = body.get("mold_steel", "P20")
|
||||
surface_quality = body.get("surface_quality", "standard")
|
||||
controller = body.get("controller", "fanuc")
|
||||
cam = _get_cached_import("mold_cam_designer")
|
||||
if not cam:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = cam.design_mold_cam(
|
||||
cavity_bbox=cavity_bbox, stock_bbox=stock_bbox,
|
||||
mold_steel=mold_steel, surface_quality=surface_quality,
|
||||
controller=controller,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/check-collision")
|
||||
async def check_toolpath_collision(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
toolpath_points = body.get("toolpath_points", [[0, 0, 50], [10, 10, -5], [20, 20, -10]])
|
||||
tool = body.get("tool", {"diameter": 10, "flute_length": 30, "shank_diameter": 10})
|
||||
stock_bbox = body.get("stock_bbox", {"min": [-50, -50, -25], "max": [50, 50, 25]})
|
||||
clamp_positions = body.get("clamp_positions")
|
||||
cd = _get_cached_import("collision_detector")
|
||||
if not cd:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = cd.check_toolpath_safety(toolpath_points, tool, stock_bbox, clamp_positions)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/optimize-toolpath")
|
||||
async def optimize_toolpath(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
toolpath_points = body.get("toolpath_points", [[0, 0, 50], [10, 10, -5], [20, 20, -10]])
|
||||
cutting_params = body.get("cutting_params", {"feed_rate_mm_min": 500})
|
||||
stock_bbox = body.get("stock_bbox")
|
||||
to = _get_cached_import("toolpath_optimizer")
|
||||
if not to:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = to.optimize_toolpath(toolpath_points, cutting_params, stock_bbox)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-electrodes")
|
||||
async def design_edm_electrodes(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
undercut_regions = body.get("undercut_regions", [{"center": [0, 0, 0], "area": 100, "type": "undercut"}])
|
||||
cavity_bbox = body.get("cavity_bbox", {"dimensions": [100, 100, 50]})
|
||||
material = body.get("material", "copper")
|
||||
spark_gap = body.get("spark_gap", 0.05)
|
||||
overburn = body.get("overburn", 0.1)
|
||||
ed = _get_cached_import("edm_designer")
|
||||
if not ed:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = ed.design_electrodes(undercut_regions, cavity_bbox, material, spark_gap, overburn)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/simulate-machining")
|
||||
async def simulate_machining(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
operations = body.get("operations", [{"strategy": "z_level_roughing", "levels": [{"z": -5}]}])
|
||||
stock_bbox = body.get("stock_bbox", {"dimensions": [100, 100, 50], "min": [-50, -50, -25], "max": [50, 50, 25]})
|
||||
resolution = body.get("resolution", 2.0)
|
||||
ms = _get_cached_import("machining_simulator")
|
||||
if not ms:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = ms.simulate_machining(operations, stock_bbox, resolution)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/export-mold")
|
||||
async def export_mold_results(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
body = await request.json()
|
||||
task_id = body.get("task_id")
|
||||
scheme_id = body.get("scheme_id")
|
||||
formats = body.get("formats", ["step", "stl"])
|
||||
components = body.get("components", ["cavity", "core"])
|
||||
|
||||
if not task_id:
|
||||
raise HTTPException(404, "缺少 task_id")
|
||||
|
||||
await _ensure_task_access(db_session, task_id, current_user.id)
|
||||
task_data = await TaskQueryService.get_task_view(db_session, task_id)
|
||||
if not task_data:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
|
||||
resolved_scheme_id = scheme_id or task_data.get("best_scheme_id") or "default"
|
||||
persisted_files = _select_persisted_files(
|
||||
task_id=task_id,
|
||||
task_data=task_data,
|
||||
scheme_id=resolved_scheme_id,
|
||||
formats=formats,
|
||||
components=components,
|
||||
)
|
||||
if persisted_files:
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"base_filename": Path(task_data.get("filename", f"mold_{task_id}")).stem,
|
||||
"task_id": task_id,
|
||||
"scheme_id": resolved_scheme_id,
|
||||
"files": persisted_files,
|
||||
"errors": [],
|
||||
"total_files": len(persisted_files),
|
||||
"total_errors": 0,
|
||||
"source": "persisted",
|
||||
},
|
||||
}
|
||||
|
||||
cavity_shapes = processing_service.get_export_shapes(
|
||||
task_id,
|
||||
resolved_scheme_id,
|
||||
)
|
||||
filename = task_data.get("filename", f"mold_{task_id}")
|
||||
|
||||
if not cavity_shapes:
|
||||
# 内存 shape 缓存失效(如服务重启):从持久化的单组件 STEP
|
||||
# 现场转换缺失格式,用户无需重新分析
|
||||
artifacts = _get_export_artifacts(task_data)
|
||||
scheme_data = (artifacts.get("schemes") or {}).get(resolved_scheme_id)
|
||||
if scheme_data:
|
||||
base_filename = scheme_data.get("base_filename") or Path(filename).stem
|
||||
regenerated = await processing_service.regenerate_export_from_persisted(
|
||||
task_id=task_id,
|
||||
scheme_id=resolved_scheme_id,
|
||||
formats=formats,
|
||||
components=_expand_components(components),
|
||||
base_filename=base_filename,
|
||||
scheme_files=scheme_data.get("files", []),
|
||||
)
|
||||
if regenerated:
|
||||
regenerated["files"] = _augment_export_files(
|
||||
task_id, regenerated.get("files", [])
|
||||
)
|
||||
# 合并进持久化 manifest,后续请求直接命中持久化路径
|
||||
merged_artifacts = _merge_export_artifacts(artifacts, regenerated)
|
||||
await storage_service.update_task_parameters(
|
||||
db_session,
|
||||
task_id,
|
||||
{"export_artifacts": merged_artifacts},
|
||||
)
|
||||
# D9:存储方法已不再自行 commit,请求侧显式提交
|
||||
await db_session.commit()
|
||||
await redis_task_manager.update_task(
|
||||
task_id, {"export_artifacts": merged_artifacts}
|
||||
)
|
||||
TaskQueryService.invalidate_task_view(task_id)
|
||||
|
||||
return {"status": "success", "data": regenerated}
|
||||
|
||||
raise HTTPException(
|
||||
409,
|
||||
"导出缓存已失效或任务尚未完成,请重新分析后再导出以保证方案一致性",
|
||||
)
|
||||
|
||||
base_filename = Path(filename).stem
|
||||
result = cad_exporter.export_mold_results(
|
||||
cavity_data=cavity_shapes,
|
||||
base_filename=base_filename,
|
||||
formats=formats,
|
||||
components=components,
|
||||
task_id=task_id,
|
||||
scheme_id=resolved_scheme_id,
|
||||
)
|
||||
result["files"] = _augment_export_files(task_id, result.get("files", []))
|
||||
result["source"] = "generated"
|
||||
|
||||
merged_artifacts = _merge_export_artifacts(_get_export_artifacts(task_data), result)
|
||||
await storage_service.update_task_parameters(
|
||||
db_session,
|
||||
task_id,
|
||||
{"export_artifacts": merged_artifacts},
|
||||
)
|
||||
# D9:存储方法已不再自行 commit,请求侧显式提交
|
||||
await db_session.commit()
|
||||
await redis_task_manager.update_task(task_id, {"export_artifacts": merged_artifacts})
|
||||
TaskQueryService.invalidate_task_view(task_id) # parameters 已变更,缓存视图失效
|
||||
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.get("/export-download/{filepath:path}")
|
||||
async def download_export_file(
|
||||
filepath: str,
|
||||
task_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
if not task_id:
|
||||
raise HTTPException(400, "缺少 task_id")
|
||||
|
||||
await _ensure_task_access(db_session, task_id, current_user.id)
|
||||
task_data = await TaskQueryService.get_task_view(db_session, task_id)
|
||||
if not task_data:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
|
||||
allowed_paths = set()
|
||||
artifacts = _get_export_artifacts(task_data)
|
||||
for scheme in (artifacts.get("schemes") or {}).values():
|
||||
for file in scheme.get("files", []):
|
||||
relative_path = str(file.get("relative_path") or "").replace("\\", "/").strip("/")
|
||||
if relative_path:
|
||||
allowed_paths.add(relative_path)
|
||||
|
||||
normalized_path = str(filepath or "").replace("\\", "/").strip("/")
|
||||
if normalized_path not in allowed_paths:
|
||||
raise HTTPException(403, "该文件不在任务允许下载清单中")
|
||||
|
||||
full_path = os.path.join(cad_exporter.output_dir, normalized_path.replace("/", os.sep))
|
||||
if not os.path.exists(full_path):
|
||||
raise HTTPException(404, "文件不存在")
|
||||
if not os.path.abspath(full_path).startswith(os.path.abspath(cad_exporter.output_dir)):
|
||||
raise HTTPException(403, "禁止访问")
|
||||
media_types = {
|
||||
".step": "application/step", ".stp": "application/step",
|
||||
".iges": "application/iges", ".igs": "application/iges",
|
||||
".stl": "model/stl", ".brep": "application/octet-stream",
|
||||
}
|
||||
ext = Path(full_path).suffix.lower()
|
||||
media_type = media_types.get(ext, "application/octet-stream")
|
||||
return FileResponse(full_path, media_type=media_type, filename=os.path.basename(full_path))
|
||||
|
||||
|
||||
@router.get("/export-recommendations")
|
||||
async def get_export_recommendations(
|
||||
target: str = "ug",
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
result = cad_exporter.get_export_recommendations(target)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@@ -18,19 +18,22 @@ from sqlalchemy.orm import joinedload
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User, ProcessingTask, STPFile
|
||||
from shared.models.identity import User
|
||||
from moldinsight.models import ProcessingTask, STPFile
|
||||
from shared.models.schemas import ProcessingStatus, create_task_info
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from shared.utils.file_handler import FileHandler
|
||||
from shared.utils.logger import get_logger
|
||||
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
|
||||
from shared.config.settings import settings
|
||||
from moldinsight.services.task_storage_service import TaskStorageService
|
||||
from moldinsight.services.task_dispatcher import dispatch_processing
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
file_handler = FileHandler()
|
||||
# D14:上传限制接 settings(MAX_FILE_SIZE 此前为死配置,文件处理器硬编码 50MB)
|
||||
file_handler = FileHandler(upload_dir=settings.UPLOAD_DIR, max_file_size=settings.MAX_FILE_SIZE)
|
||||
|
||||
|
||||
@router.post("/batch-upload")
|
||||
@@ -60,7 +63,7 @@ async def batch_upload(
|
||||
|
||||
batch_id = str(uuid.uuid4())
|
||||
tasks: List[Dict[str, Any]] = []
|
||||
storage_service = StorageIntegrationService()
|
||||
storage_service = TaskStorageService()
|
||||
|
||||
for file in files:
|
||||
# 文件类型检查
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.database import User, ProcessingTask
|
||||
from shared.models.identity import User
|
||||
from moldinsight.models import ProcessingTask
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from moldinsight.services.cam_bundle_service import cam_bundle_service
|
||||
from moldinsight.services.task_query_service import TaskQueryService
|
||||
@@ -21,20 +26,25 @@ DEFAULT_CAM_PREFERENCES = {
|
||||
}
|
||||
|
||||
|
||||
class CamPlanRequest(BaseModel):
|
||||
"""未提供的偏好字段回落到任务持久化偏好,再回落到默认值。"""
|
||||
task_id: str
|
||||
scheme_id: Optional[str] = None
|
||||
mold_steel: Optional[str] = None
|
||||
surface_quality: Optional[str] = None
|
||||
controller: Optional[str] = None
|
||||
include_gcode: Optional[bool] = None
|
||||
|
||||
|
||||
@router.post("/cam/plan")
|
||||
async def generate_cam_plan(
|
||||
request: Request,
|
||||
body: CamPlanRequest,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""基于任务分模结果生成 CAM 准备包(MVP)。"""
|
||||
_ = current_user
|
||||
body = await request.json()
|
||||
task_id = body.get("task_id")
|
||||
scheme_id = body.get("scheme_id")
|
||||
|
||||
if not task_id:
|
||||
raise HTTPException(status_code=400, detail="缺少 task_id")
|
||||
task_id = body.task_id
|
||||
|
||||
task_result = await db_session.execute(
|
||||
select(ProcessingTask).where(ProcessingTask.task_id == task_id)
|
||||
@@ -47,23 +57,26 @@ async def generate_cam_plan(
|
||||
processing_task.parameters.get("cam_preferences", {}) or {}
|
||||
)
|
||||
|
||||
mold_steel = body.get(
|
||||
"mold_steel",
|
||||
persisted_preferences.get("mold_steel", DEFAULT_CAM_PREFERENCES["mold_steel"]),
|
||||
# 注意 include_gcode 显式判 None:False 是有效值,不能走 or 回落
|
||||
mold_steel = (
|
||||
body.mold_steel
|
||||
if body.mold_steel is not None
|
||||
else persisted_preferences.get("mold_steel", DEFAULT_CAM_PREFERENCES["mold_steel"])
|
||||
)
|
||||
surface_quality = body.get(
|
||||
"surface_quality",
|
||||
persisted_preferences.get("surface_quality", DEFAULT_CAM_PREFERENCES["surface_quality"]),
|
||||
surface_quality = (
|
||||
body.surface_quality
|
||||
if body.surface_quality is not None
|
||||
else persisted_preferences.get("surface_quality", DEFAULT_CAM_PREFERENCES["surface_quality"])
|
||||
)
|
||||
controller = body.get(
|
||||
"controller",
|
||||
persisted_preferences.get("controller", DEFAULT_CAM_PREFERENCES["controller"]),
|
||||
)
|
||||
include_gcode = bool(
|
||||
body.get(
|
||||
"include_gcode",
|
||||
persisted_preferences.get("include_gcode", DEFAULT_CAM_PREFERENCES["include_gcode"]),
|
||||
controller = (
|
||||
body.controller
|
||||
if body.controller is not None
|
||||
else persisted_preferences.get("controller", DEFAULT_CAM_PREFERENCES["controller"])
|
||||
)
|
||||
include_gcode = (
|
||||
body.include_gcode
|
||||
if body.include_gcode is not None
|
||||
else persisted_preferences.get("include_gcode", DEFAULT_CAM_PREFERENCES["include_gcode"])
|
||||
)
|
||||
|
||||
task_view = await TaskQueryService.get_task_view(db_session, task_id)
|
||||
@@ -73,9 +86,11 @@ async def generate_cam_plan(
|
||||
raise HTTPException(status_code=400, detail="任务尚未完成,无法生成CAM计划")
|
||||
|
||||
try:
|
||||
data = cam_bundle_service.build_bundle(
|
||||
# CAM 刀路计算为纯 Python 重计算,投放线程池避免阻塞事件循环
|
||||
data = await asyncio.to_thread(
|
||||
cam_bundle_service.build_bundle,
|
||||
task_view=task_view,
|
||||
scheme_id=scheme_id,
|
||||
scheme_id=body.scheme_id,
|
||||
mold_steel=mold_steel,
|
||||
surface_quality=surface_quality,
|
||||
controller=controller,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""核心计算模块的惰性装载器(原 advanced_router._get_cached_import,D1 拆分时上提共用)。
|
||||
|
||||
- 惰性导入:避免路由模块级加载核心包(含 OCC 重模块)的导入开销与循环依赖
|
||||
- 装载失败返回 None 且不缓存失败(与原实现一致,端点统一 503「服务不可用」)
|
||||
- 实例缓存:设计/加工模块为纯 Python 计算(构造后无 self 突变,方法仅读入参),
|
||||
可安全地被 asyncio.to_thread 并发调用;OCC 相关的 side_action_designer
|
||||
经 processing_service.run_occ 的常驻 OCC 进程池使用(方案 B,见
|
||||
docs/topics/performance/OCC_THROUGHPUT.md)
|
||||
"""
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_lock = threading.Lock()
|
||||
_instances: dict = {}
|
||||
|
||||
_LOADERS = {
|
||||
"side_action_designer": ("moldinsight.core.side_action_designer", "SideActionDesigner"),
|
||||
"cavity_layout_optimizer": ("moldinsight.core.cavity_layout_optimizer", "CavityLayoutOptimizer"),
|
||||
"mold_system_designer": ("moldinsight.core.mold_system_designer", "MoldSystemDesigner"),
|
||||
"mold_cam_designer": ("moldinsight.core.mold_cam", "MoldCAMDesigner"),
|
||||
"collision_detector": ("moldinsight.core.mold_machining", "CollisionDetector"),
|
||||
"toolpath_optimizer": ("moldinsight.core.mold_machining", "ToolpathOptimizer"),
|
||||
"edm_designer": ("moldinsight.core.mold_machining", "EDMElectrodeDesigner"),
|
||||
"machining_simulator": ("moldinsight.core.mold_machining", "MachiningSimulator"),
|
||||
}
|
||||
|
||||
|
||||
def get_core_module(key: str):
|
||||
if key in _instances:
|
||||
return _instances[key]
|
||||
if key not in _LOADERS:
|
||||
return None
|
||||
with _lock:
|
||||
if key in _instances:
|
||||
return _instances[key]
|
||||
module_path, class_name = _LOADERS[key]
|
||||
try:
|
||||
module = __import__(module_path, fromlist=[class_name])
|
||||
instance = getattr(module, class_name)()
|
||||
except Exception as e:
|
||||
logger.warning(f"核心模块 {key} 加载失败: {e}")
|
||||
return None
|
||||
_instances[key] = instance
|
||||
return instance
|
||||
@@ -0,0 +1,51 @@
|
||||
# api/cost_router.py
|
||||
"""成本估算接口(批次 3 自 advanced_router 拆分,D1)。"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.identity import User
|
||||
from moldinsight.services.task_query_service import TaskQueryService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CostEstimateRequest(BaseModel):
|
||||
task_id: str
|
||||
|
||||
|
||||
@router.post("/cost-estimate")
|
||||
async def estimate_cost(
|
||||
body: CostEstimateRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""模具成本估算:优先使用 LLM,未启用时降级为规则式估算"""
|
||||
# 归属校验统一走 TaskQueryService(与 /api/status 共用,含 404/403 语义)
|
||||
await TaskQueryService.ensure_task_access(db_session, body.task_id, current_user.id)
|
||||
|
||||
# 统一走任务视图:进行中读 Redis,完成态由 PG+RustFS 组装(Redis 大对象已瘦身)
|
||||
task_data = await TaskQueryService.get_task_view(db_session, body.task_id)
|
||||
if not task_data:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
analysis_result = task_data.get("analysis_result")
|
||||
if not analysis_result:
|
||||
raise HTTPException(400, "该任务尚未完成分析")
|
||||
detailed_context = {
|
||||
"candidate_schemes": task_data.get("candidate_schemes", []),
|
||||
"geometry_data": task_data.get("geometry_data", {}),
|
||||
"metadata": {"selected_material": task_data.get("material")},
|
||||
}
|
||||
# 优先使用 LLM
|
||||
from moldinsight.services.llm_service import llm_service
|
||||
result = await llm_service.estimate_cost(analysis_result, detailed_context)
|
||||
if result is not None:
|
||||
result["source"] = "ai"
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
# LLM 未启用或失败,降级为规则估算
|
||||
from moldinsight.services.cost_estimate_service import estimate_cost_by_rules
|
||||
rules_result = estimate_cost_by_rules(analysis_result, detailed_context)
|
||||
return {"status": "success", "data": rules_result}
|
||||
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends
|
||||
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# api/design_router.py
|
||||
"""模具结构设计类接口(批次 3 自 advanced_router 拆分,D1)。
|
||||
|
||||
- 请求体一律 Pydantic 模型(原 request.json() 手动解析退役,校验失败统一 422)
|
||||
- 纯 Python 设计计算统一经 asyncio.to_thread 投放线程池,不阻塞事件循环;
|
||||
OCC 相关的倒扣检测经 processing_service.run_occ 的常驻 OCC 进程池
|
||||
(PythonOCC 非线程安全,进程内串行;见 OCC_THROUGHPUT.md 方案 B)
|
||||
"""
|
||||
import asyncio
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.identity import User
|
||||
from moldinsight.services.processing_service import processing_service
|
||||
from moldinsight.services.task_query_service import TaskQueryService
|
||||
from moldinsight.api.core_modules import get_core_module
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ---- 请求模型 ----
|
||||
|
||||
class BBox3D(BaseModel):
|
||||
dimensions: List[float] = Field(default_factory=lambda: [100.0, 100.0, 50.0])
|
||||
min: Optional[List[float]] = None
|
||||
max: Optional[List[float]] = None
|
||||
|
||||
|
||||
class MoldSize(BaseModel):
|
||||
length: float = 300.0
|
||||
width: float = 300.0
|
||||
height: float = 200.0
|
||||
|
||||
|
||||
class OptimizeLayoutRequest(BaseModel):
|
||||
product_bbox: BBox3D = Field(default_factory=BBox3D)
|
||||
cavity_count: int = Field(default=1, ge=1, le=64)
|
||||
mold_base_size: Optional[BBox3D] = None
|
||||
layout_type: str = "auto"
|
||||
|
||||
|
||||
class CoolingDesignRequest(BaseModel):
|
||||
mold_size: MoldSize = Field(default_factory=MoldSize)
|
||||
product_bbox: BBox3D = Field(default_factory=BBox3D)
|
||||
material: str = "ABS"
|
||||
cavity_count: int = Field(default=1, ge=1, le=64)
|
||||
cycle_time_target: Optional[float] = None
|
||||
|
||||
|
||||
class GatingDesignRequest(BaseModel):
|
||||
product_bbox: BBox3D = Field(default_factory=BBox3D)
|
||||
material: str = "ABS"
|
||||
cavity_count: int = Field(default=1, ge=1, le=64)
|
||||
gate_type: str = "auto"
|
||||
layout_positions: Optional[List[List[float]]] = None
|
||||
|
||||
|
||||
class MoldSystemDesignRequest(BaseModel):
|
||||
mold_size: MoldSize = Field(default_factory=MoldSize)
|
||||
product_bbox: BBox3D = Field(default_factory=BBox3D)
|
||||
material: str = "ABS"
|
||||
cavity_count: int = Field(default=1, ge=1, le=64)
|
||||
gate_type: str = "auto"
|
||||
cycle_time_target: Optional[float] = None
|
||||
layout_positions: Optional[List[List[float]]] = None
|
||||
|
||||
|
||||
class UndercutDetectRequest(BaseModel):
|
||||
task_id: str
|
||||
parting_direction: List[float] = Field(default_factory=lambda: [0.0, 0.0, 1.0])
|
||||
mold_size: MoldSize = Field(default_factory=MoldSize)
|
||||
|
||||
|
||||
@router.post("/optimize-layout")
|
||||
async def optimize_cavity_layout(
|
||||
body: OptimizeLayoutRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
optimizer = get_core_module("cavity_layout_optimizer")
|
||||
if not optimizer:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
# 纯 Python 布局优化,投放线程池避免阻塞事件循环
|
||||
result = await asyncio.to_thread(
|
||||
optimizer.optimize_layout,
|
||||
product_bbox=body.product_bbox.model_dump(exclude_none=True),
|
||||
cavity_count=body.cavity_count,
|
||||
mold_base_size=body.mold_base_size.model_dump(exclude_none=True) if body.mold_base_size else None,
|
||||
layout_type=body.layout_type,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-cooling")
|
||||
async def design_cooling_system(
|
||||
body: CoolingDesignRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
from moldinsight.core.mold_system_designer import CoolingSystemDesigner
|
||||
designer = CoolingSystemDesigner()
|
||||
result = await asyncio.to_thread(
|
||||
designer.design_cooling_system,
|
||||
mold_size=body.mold_size.model_dump(),
|
||||
product_bbox=body.product_bbox.model_dump(exclude_none=True),
|
||||
material=body.material,
|
||||
cavity_count=body.cavity_count,
|
||||
cycle_time_target=body.cycle_time_target,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-gating")
|
||||
async def design_gating_system(
|
||||
body: GatingDesignRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
from moldinsight.core.mold_system_designer import GatingSystemDesigner
|
||||
designer = GatingSystemDesigner()
|
||||
result = await asyncio.to_thread(
|
||||
designer.design_gating_system,
|
||||
product_bbox=body.product_bbox.model_dump(exclude_none=True),
|
||||
material=body.material,
|
||||
cavity_count=body.cavity_count,
|
||||
gate_type=body.gate_type,
|
||||
layout_positions=body.layout_positions,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-mold-system")
|
||||
async def design_complete_mold_system(
|
||||
body: MoldSystemDesignRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
ds = get_core_module("mold_system_designer")
|
||||
if not ds:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = await asyncio.to_thread(
|
||||
ds.design_complete_system,
|
||||
mold_size=body.mold_size.model_dump(),
|
||||
product_bbox=body.product_bbox.model_dump(exclude_none=True),
|
||||
material=body.material,
|
||||
cavity_count=body.cavity_count,
|
||||
gate_type=body.gate_type,
|
||||
cycle_time_target=body.cycle_time_target,
|
||||
layout_positions=body.layout_positions,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/detect-undercuts")
|
||||
async def detect_undercuts(
|
||||
body: UndercutDetectRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
# 归属校验统一走 TaskQueryService(与 /api/status 共用,含 404/403 语义)
|
||||
await TaskQueryService.ensure_task_access(db_session, body.task_id, current_user.id)
|
||||
|
||||
sd = get_core_module("side_action_designer")
|
||||
if not sd:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
|
||||
# 方案 B:从持久化 STP 原件落盘,倒扣分析在常驻 OCC 子进程内完成(形状不跨进程)
|
||||
from moldinsight.services.stp_materializer import get_stp_materializer
|
||||
stp_path = await get_stp_materializer().materialize_stp_for_task(db_session, body.task_id)
|
||||
if stp_path is None:
|
||||
raise HTTPException(410, "任务几何不可用:无法从存储重建 STP 形状,请重新上传分析")
|
||||
|
||||
try:
|
||||
result = await processing_service.run_occ(
|
||||
"detect_undercuts",
|
||||
{
|
||||
"stp_path": str(stp_path),
|
||||
"parting_direction": body.parting_direction,
|
||||
"mold_size": body.mold_size.model_dump(),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
stp_path.unlink(missing_ok=True)
|
||||
return {"status": "success", "data": result}
|
||||
@@ -0,0 +1,271 @@
|
||||
# api/export_router.py
|
||||
"""导出类接口(批次 3 自 advanced_router 拆分,D1)。
|
||||
|
||||
导出产物清单(export_artifacts)的合并/校验辅助函数自原文件平移,
|
||||
行为不变;任务归属校验直接调用 TaskQueryService.ensure_task_access。
|
||||
"""
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
import os
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.identity import User
|
||||
from moldinsight.services.processing_service import processing_service
|
||||
from moldinsight.services.task_query_service import TaskQueryService
|
||||
from moldinsight.services.task_storage_service import TaskStorageService
|
||||
from moldinsight.core.cad_exporter import CADExporter
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
cad_exporter = CADExporter()
|
||||
|
||||
|
||||
class ExportMoldRequest(BaseModel):
|
||||
task_id: str
|
||||
scheme_id: Optional[str] = None
|
||||
formats: List[str] = Field(default_factory=lambda: ["step", "stl"])
|
||||
components: List[str] = Field(default_factory=lambda: ["cavity", "core"])
|
||||
|
||||
|
||||
# ---- 导出产物清单辅助(自原 advanced_router 平移) ----
|
||||
|
||||
def _get_export_artifacts(task_data: dict) -> dict:
|
||||
if not isinstance(task_data, dict):
|
||||
return {}
|
||||
direct = task_data.get("export_artifacts")
|
||||
if isinstance(direct, dict):
|
||||
return direct
|
||||
parameters = task_data.get("parameters")
|
||||
if isinstance(parameters, dict) and isinstance(parameters.get("export_artifacts"), dict):
|
||||
return parameters.get("export_artifacts")
|
||||
return {}
|
||||
|
||||
|
||||
def _expand_components(components):
|
||||
requested = components or ["cavity", "core"]
|
||||
if "all" in requested:
|
||||
return ["cavity", "core", "parting_surface"]
|
||||
return list(dict.fromkeys(requested))
|
||||
|
||||
|
||||
def _augment_export_files(task_id: str, files):
|
||||
items = []
|
||||
for file in files or []:
|
||||
item = dict(file)
|
||||
relative_path = item.get("relative_path")
|
||||
if not relative_path and item.get("filepath"):
|
||||
relative_path = cad_exporter.get_relative_path(item["filepath"])
|
||||
if relative_path:
|
||||
relative_path = str(relative_path).replace("\\", "/").strip("/")
|
||||
item["relative_path"] = relative_path
|
||||
item["download_path"] = f"/api/export-download/{quote(relative_path, safe='/')}?task_id={task_id}"
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def _merge_export_artifacts(existing: dict, export_result: dict) -> dict:
|
||||
merged = dict(existing or {})
|
||||
schemes = dict(merged.get("schemes") or {})
|
||||
scheme_id = export_result.get("scheme_id") or "default"
|
||||
previous = dict(schemes.get(scheme_id) or {})
|
||||
|
||||
file_map = {}
|
||||
for file in previous.get("files", []):
|
||||
file_map[(file.get("component"), file.get("format"))] = file
|
||||
for file in export_result.get("files", []):
|
||||
file_map[(file.get("component"), file.get("format"))] = file
|
||||
|
||||
schemes[scheme_id] = {
|
||||
"base_filename": export_result.get("base_filename") or previous.get("base_filename"),
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"files": sorted(
|
||||
file_map.values(),
|
||||
key=lambda item: (item.get("component", ""), item.get("format", "")),
|
||||
),
|
||||
"errors": export_result.get("errors", []),
|
||||
"total_files": len(file_map),
|
||||
"total_errors": len(export_result.get("errors", [])),
|
||||
}
|
||||
|
||||
merged["version"] = 1
|
||||
merged["task_id"] = export_result.get("task_id") or merged.get("task_id")
|
||||
merged["generated_at"] = merged.get("generated_at") or datetime.now().isoformat()
|
||||
merged["schemes"] = schemes
|
||||
return merged
|
||||
|
||||
|
||||
def _select_persisted_files(task_id: str, task_data: dict, scheme_id: str, formats, components):
|
||||
artifacts = _get_export_artifacts(task_data)
|
||||
scheme_data = (artifacts.get("schemes") or {}).get(scheme_id)
|
||||
if not scheme_data:
|
||||
return None
|
||||
|
||||
component_list = _expand_components(components)
|
||||
format_list = list(dict.fromkeys(formats or ["step", "stl"]))
|
||||
expected = {(component, fmt) for component in component_list for fmt in format_list}
|
||||
|
||||
available = []
|
||||
available_keys = set()
|
||||
for file in scheme_data.get("files", []):
|
||||
component = file.get("component")
|
||||
fmt = file.get("format")
|
||||
if component not in component_list or fmt not in format_list:
|
||||
continue
|
||||
relative_path = str(file.get("relative_path") or "").replace("\\", "/").strip("/")
|
||||
if not relative_path:
|
||||
continue
|
||||
full_path = os.path.join(cad_exporter.output_dir, relative_path.replace("/", os.sep))
|
||||
if not os.path.exists(full_path):
|
||||
continue
|
||||
available.append(file)
|
||||
available_keys.add((component, fmt))
|
||||
|
||||
if expected and not expected.issubset(available_keys):
|
||||
return None
|
||||
|
||||
return _augment_export_files(task_id, available)
|
||||
|
||||
|
||||
# ---- 端点 ----
|
||||
|
||||
@router.post("/export-mold")
|
||||
async def export_mold_results(
|
||||
body: ExportMoldRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
task_id = body.task_id
|
||||
formats = body.formats
|
||||
components = body.components
|
||||
|
||||
# 归属校验统一走 TaskQueryService(与 /api/status 共用,含 404/403 语义)
|
||||
await TaskQueryService.ensure_task_access(db_session, task_id, current_user.id)
|
||||
task_data = await TaskQueryService.get_task_view(db_session, task_id)
|
||||
if not task_data:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
|
||||
resolved_scheme_id = body.scheme_id or task_data.get("best_scheme_id") or "default"
|
||||
persisted_files = _select_persisted_files(
|
||||
task_id=task_id,
|
||||
task_data=task_data,
|
||||
scheme_id=resolved_scheme_id,
|
||||
formats=formats,
|
||||
components=components,
|
||||
)
|
||||
if persisted_files:
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"base_filename": Path(task_data.get("filename", f"mold_{task_id}")).stem,
|
||||
"task_id": task_id,
|
||||
"scheme_id": resolved_scheme_id,
|
||||
"files": persisted_files,
|
||||
"errors": [],
|
||||
"total_files": len(persisted_files),
|
||||
"total_errors": 0,
|
||||
"source": "persisted",
|
||||
},
|
||||
}
|
||||
|
||||
# 方案 B 后主进程不再持有内存形状缓存(get_export_shapes 已删除):
|
||||
# 未命中持久化清单时,从持久化单组件 STEP 现场转换缺失格式,用户无需重新分析
|
||||
filename = task_data.get("filename", f"mold_{task_id}")
|
||||
artifacts = _get_export_artifacts(task_data)
|
||||
scheme_data = (artifacts.get("schemes") or {}).get(resolved_scheme_id)
|
||||
if scheme_data:
|
||||
base_filename = scheme_data.get("base_filename") or Path(filename).stem
|
||||
regenerated = await processing_service.regenerate_export_from_persisted(
|
||||
task_id=task_id,
|
||||
scheme_id=resolved_scheme_id,
|
||||
formats=formats,
|
||||
components=_expand_components(components),
|
||||
base_filename=base_filename,
|
||||
scheme_files=scheme_data.get("files", []),
|
||||
)
|
||||
if regenerated:
|
||||
regenerated["files"] = _augment_export_files(
|
||||
task_id, regenerated.get("files", [])
|
||||
)
|
||||
# 合并进持久化 manifest,后续请求直接命中持久化路径
|
||||
merged_artifacts = _merge_export_artifacts(artifacts, regenerated)
|
||||
await TaskStorageService().update_task_parameters(
|
||||
db_session,
|
||||
task_id,
|
||||
{"export_artifacts": merged_artifacts},
|
||||
)
|
||||
# D9:存储方法已不再自行 commit,请求侧显式提交
|
||||
await db_session.commit()
|
||||
await redis_task_manager.update_task(
|
||||
task_id, {"export_artifacts": merged_artifacts}
|
||||
)
|
||||
TaskQueryService.invalidate_task_view(task_id)
|
||||
|
||||
return {"status": "success", "data": regenerated}
|
||||
|
||||
raise HTTPException(
|
||||
409,
|
||||
"导出文件不可用:任务未生成持久化导出清单,请重新分析后再导出",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export-download/{filepath:path}")
|
||||
async def download_export_file(
|
||||
filepath: str,
|
||||
task_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
if not task_id:
|
||||
raise HTTPException(400, "缺少 task_id")
|
||||
|
||||
# 归属校验统一走 TaskQueryService(与 /api/status 共用,含 404/403 语义)
|
||||
await TaskQueryService.ensure_task_access(db_session, task_id, current_user.id)
|
||||
task_data = await TaskQueryService.get_task_view(db_session, task_id)
|
||||
if not task_data:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
|
||||
allowed_paths = set()
|
||||
artifacts = _get_export_artifacts(task_data)
|
||||
for scheme in (artifacts.get("schemes") or {}).values():
|
||||
for file in scheme.get("files", []):
|
||||
relative_path = str(file.get("relative_path") or "").replace("\\", "/").strip("/")
|
||||
if relative_path:
|
||||
allowed_paths.add(relative_path)
|
||||
|
||||
normalized_path = str(filepath or "").replace("\\", "/").strip("/")
|
||||
if normalized_path not in allowed_paths:
|
||||
raise HTTPException(403, "该文件不在任务允许下载清单中")
|
||||
|
||||
full_path = os.path.join(cad_exporter.output_dir, normalized_path.replace("/", os.sep))
|
||||
if not os.path.exists(full_path):
|
||||
raise HTTPException(404, "文件不存在")
|
||||
if not os.path.abspath(full_path).startswith(os.path.abspath(cad_exporter.output_dir)):
|
||||
raise HTTPException(403, "禁止访问")
|
||||
media_types = {
|
||||
".step": "application/step", ".stp": "application/step",
|
||||
".iges": "application/iges", ".igs": "application/iges",
|
||||
".stl": "model/stl", ".brep": "application/octet-stream",
|
||||
}
|
||||
ext = Path(full_path).suffix.lower()
|
||||
media_type = media_types.get(ext, "application/octet-stream")
|
||||
return FileResponse(full_path, media_type=media_type, filename=os.path.basename(full_path))
|
||||
|
||||
|
||||
@router.get("/export-recommendations")
|
||||
async def get_export_recommendations(
|
||||
target: str = "ug",
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
result = cad_exporter.get_export_recommendations(target)
|
||||
return {"status": "success", "data": result}
|
||||
@@ -1,7 +1,11 @@
|
||||
# api/v1/health_router.py
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from moldinsight.api.route_registry import route_load_status
|
||||
from moldinsight.core.occ_availability import is_pythonocc_available
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -9,10 +13,19 @@ router = APIRouter()
|
||||
@router.get("/health")
|
||||
@router.post("/health")
|
||||
async def health():
|
||||
task_count = await redis_task_manager.get_task_count()
|
||||
# 首次调用会触发 PythonOCC 导入(可能耗时数秒),投放线程池避免阻塞事件循环
|
||||
pythonocc_available = await asyncio.to_thread(is_pythonocc_available)
|
||||
failed_routes = route_load_status["failed"]
|
||||
return {
|
||||
"status": "healthy",
|
||||
"pythonocc": True,
|
||||
"total_tasks": task_count,
|
||||
"redis_connected": redis_task_manager.is_connected
|
||||
# 有业务路由装载失败即 degraded:进程活着但功能残缺,监控必须可感知
|
||||
"status": "degraded" if failed_routes else "healthy",
|
||||
# 真实探测 PythonOCC(此前硬编码 True,与上传预检的诚实化同源)
|
||||
"pythonocc": pythonocc_available,
|
||||
"total_tasks": await redis_task_manager.get_task_count(),
|
||||
"redis_connected": redis_task_manager.is_connected,
|
||||
"routes": {
|
||||
"loaded": [m["label"] for m in route_load_status["loaded"]],
|
||||
"failed": failed_routes,
|
||||
"disabled": [m["label"] for m in route_load_status["disabled"]],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
import urllib.parse
|
||||
|
||||
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
|
||||
from moldinsight.services.file_history_service import FileHistoryService
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from shared.utils.logger import get_logger
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -21,7 +21,7 @@ async def get_file_history(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""获取当前用户按文件名分组的文件历史记录(支持多上传)"""
|
||||
storage_service = StorageIntegrationService()
|
||||
storage_service = FileHistoryService()
|
||||
file_groups = await storage_service.get_all_file_groups(
|
||||
db_session, user_id=current_user.id
|
||||
)
|
||||
@@ -42,7 +42,7 @@ async def get_file_records(
|
||||
"""获取当前用户指定文件名的所有上传记录(支持多上传历史)"""
|
||||
decoded_filename = urllib.parse.unquote(filename)
|
||||
|
||||
storage_service = StorageIntegrationService()
|
||||
storage_service = FileHistoryService()
|
||||
file_records = await storage_service.get_file_history_by_filename(
|
||||
db_session,
|
||||
decoded_filename,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# api/html_report_router.py
|
||||
"""HTML 可视化报告读取代理(D11)。
|
||||
|
||||
报告产物唯一持久来源是 RustFS 报告键 html/reports/{filename}(文件名寻址),
|
||||
本路由以 GET /html/{filename} 提供读取,替代原节点本地 html_output 的
|
||||
StaticFiles 挂载——API 与 worker 容器文件系统不互通,本地盘从来不是
|
||||
可依赖的读取来源。
|
||||
|
||||
解析顺序(逐级兜底,每次未命中记日志):
|
||||
1. RustFS 报告键(新产物,裸 HTML / 裸 JSON)
|
||||
2. HTMLFile 表记录(遗留 html/{hash}.json JSON 包装 {'content'})
|
||||
3. 节点本地 html_output 目录(存量兜底,compose 共享卷;新产物不再写本地)
|
||||
|
||||
已知约束(沿用 StaticFiles 时代的既定姿态,非本次引入):本路由不做认证。
|
||||
iframe 加载报告时浏览器不会携带 Authorization 头,无法套用 API 鉴权。
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import select
|
||||
|
||||
from shared.config.settings import settings
|
||||
from shared.database.database import db_manager
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
from moldinsight.models import HTMLFile
|
||||
from moldinsight.storage.rustfs_storage import rustfs_manager
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# 存量兜底目录(模块常量,测试可替换)
|
||||
LOCAL_HTML_DIR = Path("html_output")
|
||||
|
||||
_MEDIA_TYPES = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".json": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def include_into(app) -> None:
|
||||
"""挂载到应用根路径——不能进 /api 前缀聚合:URL 形状必须保持
|
||||
/html/{filename}(持久化 cavity JSON 与前端 iframe 均引用此形状)。
|
||||
|
||||
失败语义与 route_registry._safe_include 一致:非 DEBUG 记入
|
||||
route_load_status['failed'](/api/health 呈现 degraded),DEBUG 直接抛错。
|
||||
"""
|
||||
try:
|
||||
app.include_router(router)
|
||||
except Exception as exc:
|
||||
from moldinsight.api.route_registry import route_load_status
|
||||
route_load_status["failed"].append(
|
||||
{"label": "HTML报告", "module": __name__, "error": str(exc)}
|
||||
)
|
||||
logger.error(f"HTML 报告路由加载失败: {exc}")
|
||||
if settings.DEBUG:
|
||||
raise
|
||||
|
||||
|
||||
def _validate_filename(filename: str) -> None:
|
||||
"""防路径穿越:只允许单段文件名(报告键固定为 html/reports/ 一级平铺)。"""
|
||||
if not filename or filename.startswith(".") or Path(filename).name != filename:
|
||||
raise HTTPException(status_code=404, detail=f"报告不存在: {filename}")
|
||||
|
||||
|
||||
def _media_type_for(filename: str) -> str:
|
||||
return _MEDIA_TYPES.get(Path(filename).suffix.lower(), "application/octet-stream")
|
||||
|
||||
|
||||
async def _download_from_rustfs(filename: str) -> Optional[bytes]:
|
||||
"""新产物:html/reports/{filename} 裸文件直取。"""
|
||||
if not rustfs_manager.is_connected:
|
||||
return None
|
||||
try:
|
||||
return await rustfs_manager.download_report_artifact(filename)
|
||||
except Exception as exc:
|
||||
logger.debug(f"报告键未命中(继续遗留解析): {filename}: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
async def _download_from_legacy_record(filename: str) -> Optional[bytes]:
|
||||
"""遗留 HTMLFile 记录:html/{hash}.json JSON 包装 {'content'}。"""
|
||||
if not rustfs_manager.is_connected:
|
||||
return None
|
||||
|
||||
async with db_manager.session() as session:
|
||||
result = await session.execute(
|
||||
select(HTMLFile)
|
||||
.where(HTMLFile.filename == filename)
|
||||
.order_by(HTMLFile.id.desc())
|
||||
)
|
||||
record = result.scalars().first()
|
||||
if record is None:
|
||||
return None
|
||||
|
||||
data = await rustfs_manager.download_file("html_files", record.object_key)
|
||||
if record.object_key.startswith(rustfs_manager.report_prefix + "/"):
|
||||
# 新格式记录:报告键直取瞬时失败走到这里,裸文件原样返回
|
||||
return data
|
||||
wrapper = json.loads(data.decode("utf-8"))
|
||||
content = wrapper.get("content")
|
||||
if content is None:
|
||||
raise ValueError(f"遗留报告对象缺少 content 字段: {record.object_key}")
|
||||
return content.encode("utf-8")
|
||||
|
||||
|
||||
def _download_from_local(filename: str) -> Optional[bytes]:
|
||||
"""存量兜底:旧 worker 写入共享卷 html_output 的历史产物。"""
|
||||
path = LOCAL_HTML_DIR / filename
|
||||
if path.is_file():
|
||||
return path.read_bytes()
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/html/{filename:path}", summary="读取 HTML 可视化报告")
|
||||
async def get_html_report(filename: str) -> Response:
|
||||
_validate_filename(filename)
|
||||
|
||||
data = await _download_from_rustfs(filename)
|
||||
source = "rustfs"
|
||||
if data is None:
|
||||
try:
|
||||
data = await _download_from_legacy_record(filename)
|
||||
except Exception as exc:
|
||||
logger.debug(f"遗留记录解析失败(继续本地兜底): {filename}: {exc}")
|
||||
source = "rustfs-legacy"
|
||||
if data is None:
|
||||
data = _download_from_local(filename)
|
||||
source = "local-fallback"
|
||||
if data is None:
|
||||
raise HTTPException(status_code=404, detail=f"报告不存在: {filename}")
|
||||
|
||||
if source != "rustfs":
|
||||
# 存量链路命中留痕,便于评估遗留对象与本地卷的清理时机
|
||||
logger.info(f"报告经 {source} 链路命中: {filename}")
|
||||
return Response(content=data, media_type=_media_type_for(filename))
|
||||
@@ -0,0 +1,167 @@
|
||||
# api/machining_router.py
|
||||
"""CAM / 加工仿真类接口(批次 3 自 advanced_router 拆分,D1)。
|
||||
|
||||
加工计算为纯 Python 重计算(非 OCC),统一经 asyncio.to_thread
|
||||
投放线程池执行,不阻塞事件循环。
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.identity import User
|
||||
from moldinsight.api.core_modules import get_core_module
|
||||
from moldinsight.api.design_router import BBox3D
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ---- 请求模型 ----
|
||||
|
||||
class ToolSpec(BaseModel):
|
||||
diameter: float = 10.0
|
||||
flute_length: float = 30.0
|
||||
shank_diameter: float = 10.0
|
||||
|
||||
|
||||
def _cam_cavity_bbox() -> BBox3D:
|
||||
return BBox3D(dimensions=[100.0, 100.0, 50.0], min=[-50.0, -50.0, -25.0], max=[50.0, 50.0, 25.0])
|
||||
|
||||
|
||||
def _cam_stock_bbox() -> BBox3D:
|
||||
return BBox3D(dimensions=[150.0, 150.0, 100.0], min=[-75.0, -75.0, -50.0], max=[75.0, 75.0, 50.0])
|
||||
|
||||
|
||||
class CamDesignRequest(BaseModel):
|
||||
cavity_bbox: BBox3D = Field(default_factory=_cam_cavity_bbox)
|
||||
stock_bbox: BBox3D = Field(default_factory=_cam_stock_bbox)
|
||||
mold_steel: str = "P20"
|
||||
surface_quality: str = "standard"
|
||||
controller: str = "fanuc"
|
||||
|
||||
|
||||
class CollisionCheckRequest(BaseModel):
|
||||
toolpath_points: List[List[float]] = Field(
|
||||
default_factory=lambda: [[0, 0, 50], [10, 10, -5], [20, 20, -10]]
|
||||
)
|
||||
tool: ToolSpec = Field(default_factory=ToolSpec)
|
||||
stock_bbox: BBox3D = Field(default_factory=_cam_cavity_bbox)
|
||||
clamp_positions: Optional[List[List[float]]] = None
|
||||
|
||||
|
||||
class ToolpathOptimizeRequest(BaseModel):
|
||||
toolpath_points: List[List[float]] = Field(
|
||||
default_factory=lambda: [[0, 0, 50], [10, 10, -5], [20, 20, -10]]
|
||||
)
|
||||
cutting_params: Dict[str, Any] = Field(default_factory=lambda: {"feed_rate_mm_min": 500})
|
||||
stock_bbox: Optional[BBox3D] = None
|
||||
|
||||
|
||||
class ElectrodeDesignRequest(BaseModel):
|
||||
undercut_regions: List[Dict[str, Any]] = Field(
|
||||
default_factory=lambda: [{"center": [0, 0, 0], "area": 100, "type": "undercut"}]
|
||||
)
|
||||
cavity_bbox: BBox3D = Field(default_factory=BBox3D)
|
||||
material: str = "copper"
|
||||
spark_gap: float = 0.05
|
||||
overburn: float = 0.1
|
||||
|
||||
|
||||
class MachiningSimulateRequest(BaseModel):
|
||||
operations: List[Dict[str, Any]] = Field(
|
||||
default_factory=lambda: [{"strategy": "z_level_roughing", "levels": [{"z": -5}]}]
|
||||
)
|
||||
stock_bbox: BBox3D = Field(default_factory=_cam_cavity_bbox)
|
||||
resolution: float = 2.0
|
||||
|
||||
|
||||
@router.post("/design-cam")
|
||||
async def design_mold_cam(
|
||||
body: CamDesignRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
cam = get_core_module("mold_cam_designer")
|
||||
if not cam:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = await asyncio.to_thread(
|
||||
cam.design_mold_cam,
|
||||
cavity_bbox=body.cavity_bbox.model_dump(exclude_none=True),
|
||||
stock_bbox=body.stock_bbox.model_dump(exclude_none=True),
|
||||
mold_steel=body.mold_steel,
|
||||
surface_quality=body.surface_quality,
|
||||
controller=body.controller,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/check-collision")
|
||||
async def check_toolpath_collision(
|
||||
body: CollisionCheckRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
cd = get_core_module("collision_detector")
|
||||
if not cd:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = await asyncio.to_thread(
|
||||
cd.check_toolpath_safety,
|
||||
body.toolpath_points,
|
||||
body.tool.model_dump(),
|
||||
body.stock_bbox.model_dump(exclude_none=True),
|
||||
body.clamp_positions,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/optimize-toolpath")
|
||||
async def optimize_toolpath(
|
||||
body: ToolpathOptimizeRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
to = get_core_module("toolpath_optimizer")
|
||||
if not to:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = await asyncio.to_thread(
|
||||
to.optimize_toolpath,
|
||||
body.toolpath_points,
|
||||
body.cutting_params,
|
||||
body.stock_bbox.model_dump(exclude_none=True) if body.stock_bbox else None,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-electrodes")
|
||||
async def design_edm_electrodes(
|
||||
body: ElectrodeDesignRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
ed = get_core_module("edm_designer")
|
||||
if not ed:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = await asyncio.to_thread(
|
||||
ed.design_electrodes,
|
||||
body.undercut_regions,
|
||||
body.cavity_bbox.model_dump(exclude_none=True),
|
||||
body.material,
|
||||
body.spark_gap,
|
||||
body.overburn,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/simulate-machining")
|
||||
async def simulate_machining(
|
||||
body: MachiningSimulateRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
ms = get_core_module("machining_simulator")
|
||||
if not ms:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = await asyncio.to_thread(
|
||||
ms.simulate_machining,
|
||||
body.operations,
|
||||
body.stock_bbox.model_dump(exclude_none=True),
|
||||
body.resolution,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
@@ -0,0 +1,18 @@
|
||||
"""路由装载注册表。
|
||||
|
||||
moldinsight/api/__init__.py 的 _safe_include 将装载结果登记于此,
|
||||
由 /api/health 对外呈现——路由装载失败不再只是 WARNING 日志(此前
|
||||
业务路由加载失败会被静默跳过,进程照常 healthy,功能残缺不可感知)。
|
||||
|
||||
本模块保持零依赖,供聚合入口与 health_router 双向引用而不产生循环导入。
|
||||
"""
|
||||
from typing import Dict, List
|
||||
|
||||
route_load_status: Dict[str, List[Dict[str, str]]] = {
|
||||
# 装载成功:{"label", "module"}
|
||||
"loaded": [],
|
||||
# 装载失败:{"label", "module", "error"}——存在条目时 /api/health 返回 degraded
|
||||
"failed": [],
|
||||
# 有意不注册(如 DEBUG 关闭时的调试路由):{"label", "module"}
|
||||
"disabled": [],
|
||||
}
|
||||
@@ -7,7 +7,7 @@ from moldinsight.services.task_query_service import TaskQueryService
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.utils.logger import get_logger
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@@ -4,34 +4,24 @@ import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from shared.models.schemas import ProcessingStatus, create_task_info
|
||||
from shared.config.settings import settings
|
||||
from shared.utils.file_handler import FileHandler
|
||||
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
|
||||
from moldinsight.services.task_storage_service import TaskStorageService
|
||||
from moldinsight.services.task_dispatcher import dispatch_processing
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from shared.database.database import get_db_session
|
||||
from shared.utils.logger import get_logger
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from moldinsight.core.occ_availability import is_pythonocc_available
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
file_handler = FileHandler()
|
||||
|
||||
|
||||
def _occ_available() -> bool:
|
||||
"""真实检测 PythonOCC 可用性(惰性导入,缺失时不影响本路由加载)。
|
||||
|
||||
此前该字段硬编码 True,响应不诚实;几何处理依赖 OCC,
|
||||
不可用时任务会在处理阶段以明确错误失败。
|
||||
"""
|
||||
try:
|
||||
import OCC.Core.STEPControl # noqa: F401
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
# D14:上传限制接 settings(MAX_FILE_SIZE 此前为死配置,文件处理器硬编码 50MB)
|
||||
file_handler = FileHandler(upload_dir=settings.UPLOAD_DIR, max_file_size=settings.MAX_FILE_SIZE)
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
@@ -72,7 +62,7 @@ async def upload_stp(
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
logger.info(f"[UPLOAD] 文件已保存: {file_path} ({file_size} bytes), task_id={task_id}")
|
||||
|
||||
storage_service = StorageIntegrationService()
|
||||
storage_service = TaskStorageService()
|
||||
|
||||
stp_file = await storage_service.save_stp_file(
|
||||
session=db_session,
|
||||
@@ -114,7 +104,7 @@ async def upload_stp(
|
||||
"file_info": {
|
||||
"filename": file.filename,
|
||||
"size": file_size,
|
||||
"pythonocc_available": _occ_available(),
|
||||
"pythonocc_available": is_pythonocc_available(),
|
||||
"database_file_id": stp_file.id,
|
||||
"sha256": file_meta["sha256"],
|
||||
},
|
||||
|
||||
@@ -374,116 +374,6 @@ class CADExporter:
|
||||
logger.error(f"BRep 导出失败: {e}")
|
||||
return False
|
||||
|
||||
def export_mold_results(self, cavity_data: Dict,
|
||||
base_filename: str,
|
||||
formats: List[str] = None,
|
||||
components: List[str] = None,
|
||||
task_id: Optional[str] = None,
|
||||
scheme_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
if formats is None:
|
||||
formats = ["step", "stl"]
|
||||
if components is None:
|
||||
components = ["cavity", "core"]
|
||||
|
||||
export_dir = self.build_export_dir(
|
||||
base_filename=base_filename,
|
||||
task_id=task_id,
|
||||
scheme_id=scheme_id,
|
||||
)
|
||||
os.makedirs(export_dir, exist_ok=True)
|
||||
|
||||
results = {
|
||||
"base_filename": base_filename,
|
||||
"task_id": task_id,
|
||||
"scheme_id": scheme_id,
|
||||
"export_dir": export_dir,
|
||||
"files": [],
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
shape_map = self._SHAPE_MAP
|
||||
|
||||
shapes_to_export: List[Tuple[str, str, TopoDS_Shape]] = []
|
||||
assembly_shapes: List[Tuple[TopoDS_Shape, str]] = []
|
||||
|
||||
for comp in components:
|
||||
if comp == "all":
|
||||
for key, (data_key, label) in shape_map.items():
|
||||
shape = cavity_data.get(data_key)
|
||||
if shape is not None:
|
||||
shapes_to_export.append((key, label, shape))
|
||||
assembly_shapes.append((shape, label))
|
||||
break
|
||||
elif comp in shape_map:
|
||||
data_key, label = shape_map[comp]
|
||||
shape = cavity_data.get(data_key)
|
||||
if shape is not None:
|
||||
shapes_to_export.append((comp, label, shape))
|
||||
assembly_shapes.append((shape, label))
|
||||
else:
|
||||
results["errors"].append(f"{label}形状不可用")
|
||||
|
||||
# STEP: 所有组件合并为一个装配体文件
|
||||
if "step" in formats and assembly_shapes:
|
||||
filepath = os.path.join(export_dir, f"{base_filename}_mold.step")
|
||||
success = self.export_assembly_step(assembly_shapes, filepath)
|
||||
if success:
|
||||
file_size = os.path.getsize(filepath)
|
||||
relative_path = self.get_relative_path(filepath)
|
||||
results["files"].append({
|
||||
"component": "assembly",
|
||||
"component_label": "模具装配体",
|
||||
"format": "step",
|
||||
"filepath": filepath,
|
||||
"relative_path": relative_path,
|
||||
"filename": os.path.basename(filepath),
|
||||
"size_bytes": file_size,
|
||||
"size_readable": self._format_file_size(file_size),
|
||||
})
|
||||
else:
|
||||
results["errors"].append("装配体 STEP 导出失败")
|
||||
|
||||
# IGES / STL / BRep: 逐组件导出
|
||||
non_assembly_formats = [f for f in formats if f != "step"]
|
||||
for comp_name, label, shape in shapes_to_export:
|
||||
for fmt in non_assembly_formats:
|
||||
filepath = os.path.join(export_dir, f"{base_filename}_{comp_name}.{fmt}")
|
||||
|
||||
success = False
|
||||
if fmt == "iges":
|
||||
success = self.export_iges(shape, filepath)
|
||||
elif fmt == "stl":
|
||||
success = self.export_stl(shape, filepath)
|
||||
elif fmt == "brep":
|
||||
success = self.export_brep(shape, filepath)
|
||||
else:
|
||||
results["errors"].append(f"不支持的格式: {fmt}")
|
||||
continue
|
||||
|
||||
if success:
|
||||
file_size = os.path.getsize(filepath)
|
||||
relative_path = self.get_relative_path(filepath)
|
||||
results["files"].append({
|
||||
"component": comp_name,
|
||||
"component_label": label,
|
||||
"format": fmt,
|
||||
"filepath": filepath,
|
||||
"relative_path": relative_path,
|
||||
"filename": os.path.basename(filepath),
|
||||
"size_bytes": file_size,
|
||||
"size_readable": self._format_file_size(file_size),
|
||||
})
|
||||
else:
|
||||
results["errors"].append(f"{label} ({fmt}) 导出失败")
|
||||
|
||||
results["total_files"] = len(results["files"])
|
||||
results["total_errors"] = len(results["errors"])
|
||||
|
||||
logger.info(f"模具导出完成: {results['total_files']} 个文件, "
|
||||
f"{results['total_errors']} 个错误")
|
||||
|
||||
return results
|
||||
|
||||
def export_assembly_step(self, shapes_with_names: List[Tuple[TopoDS_Shape, str]],
|
||||
filepath: str,
|
||||
schema: str = "AP214") -> bool:
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""PythonOCC 可用性探测。
|
||||
|
||||
惰性导入探测,供上传预检(upload_router)与 /api/health 共用——
|
||||
此前两处各自实现或硬编码,探测语义收口于一处。
|
||||
"""
|
||||
|
||||
|
||||
def is_pythonocc_available() -> bool:
|
||||
try:
|
||||
import OCC.Core.STEPControl # noqa: F401
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,251 @@
|
||||
# core/occ_worker.py
|
||||
"""OCC 常驻工作进程入口(方案 B,见 docs/topics/performance/OCC_THROUGHPUT.md)。
|
||||
|
||||
子进程经 multiprocessing spawn 拉起后运行 worker_main 消息循环:父进程经管道
|
||||
下发 (op_name, payload),本进程从 _OPS 注册表取 handler 执行并回传结果。
|
||||
所有操作输入输出均为文件路径 + 可 pickle 的普通字典,**杜绝 pickle OCC 对象**
|
||||
(TopoDS_Shape 为 C++ 原生内存对象,跨进程传输的唯一干净方式是经文件中转)。
|
||||
|
||||
OCC 模块在 handler 内惰性导入:本模块在无 OCC 的父进程(pip 环境)导入不报错,
|
||||
子进程(conda 环境)首次执行某操作时才加载对应模块——保持 processing_service
|
||||
可无 OCC 导入(OCC 契约测试在无 OCC 环境 skip)。
|
||||
"""
|
||||
import os
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict
|
||||
|
||||
# ─── 操作注册表 ─────────────────────────────────────────────────
|
||||
# 新增 OCC 操作必须在此注册:op_name -> 单 payload 字典的 handler。
|
||||
# handler 内惰性 import OCC 相关核心模块。
|
||||
_OPS: Dict[str, Callable[[Dict[str, Any]], Any]] = {}
|
||||
|
||||
|
||||
def _op(name: str):
|
||||
"""注册操作到 _OPS。"""
|
||||
def deco(fn):
|
||||
_OPS[name] = fn
|
||||
return fn
|
||||
return deco
|
||||
|
||||
|
||||
# 进程内惰性单例缓存(OCC 实例化成本高,子进程常驻期间复用)
|
||||
_OCC_CACHE: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def _cached(key: str, factory: Callable[[], Any]) -> Any:
|
||||
if key not in _OCC_CACHE:
|
||||
_OCC_CACHE[key] = factory()
|
||||
return _OCC_CACHE[key]
|
||||
|
||||
|
||||
def _get_parser():
|
||||
from moldinsight.core.stp_parser import STPParser
|
||||
return STPParser()
|
||||
|
||||
|
||||
def _get_planner():
|
||||
from moldinsight.core.multi_scheme_planner import MultiSchemeMoldPlanner
|
||||
return MultiSchemeMoldPlanner()
|
||||
|
||||
|
||||
def _get_geometry_analyzer():
|
||||
from moldinsight.core.geometry_analyzer import GeometryAnalyzer
|
||||
return GeometryAnalyzer()
|
||||
|
||||
|
||||
def _get_mesh_generator():
|
||||
from moldinsight.core.mesh_generator import MeshGenerator
|
||||
return MeshGenerator(quality="medium")
|
||||
|
||||
|
||||
def _get_side_action_designer():
|
||||
from moldinsight.core.side_action_designer import SideActionDesigner
|
||||
return SideActionDesigner()
|
||||
|
||||
|
||||
def _get_cad_exporter(output_dir: str):
|
||||
from moldinsight.core.cad_exporter import CADExporter
|
||||
return CADExporter(output_dir=output_dir)
|
||||
|
||||
|
||||
# ─── 操作实现 ───────────────────────────────────────────────────
|
||||
|
||||
@_op("ping")
|
||||
def _op_ping(payload):
|
||||
return {"pong": True}
|
||||
|
||||
|
||||
@_op("sleep")
|
||||
def _op_sleep(payload):
|
||||
"""诊断/测试用:在子进程内挂起指定秒数。"""
|
||||
import time
|
||||
time.sleep(float(payload.get("seconds", 0)))
|
||||
return {"slept": True}
|
||||
|
||||
|
||||
@_op("warmup")
|
||||
def _op_warmup(payload):
|
||||
"""预热:子进程启动后触发,把 OCC 模块加载成本放到池启动而非首个分析任务。"""
|
||||
_cached("parser", _get_parser)
|
||||
_cached("planner", _get_planner)
|
||||
_cached("geometry_analyzer", _get_geometry_analyzer)
|
||||
_cached("mesh_generator", _get_mesh_generator)
|
||||
return {"warmed": True}
|
||||
|
||||
|
||||
@_op("parse_stp")
|
||||
def _op_parse_stp(payload):
|
||||
"""STP 解析 + 几何分析(原主进程 load_step_file → analyze_geometry 两步合一)。
|
||||
|
||||
形状在子进程内创建并即刻消费,不跨进程传输。
|
||||
"""
|
||||
parser = _cached("parser", _get_parser)
|
||||
shape = parser.load_step_file(Path(payload["stp_path"]))
|
||||
return parser.analyze_geometry(shape)
|
||||
|
||||
|
||||
@_op("generate_mesh")
|
||||
def _op_generate_mesh(payload):
|
||||
parser = _cached("parser", _get_parser)
|
||||
mesh_gen = _cached("mesh_generator", _get_mesh_generator)
|
||||
shape = parser.load_step_file(Path(payload["stp_path"]))
|
||||
return mesh_gen.generate_multi_lod_mesh(shape)
|
||||
|
||||
|
||||
@_op("generate_cavity")
|
||||
def _op_generate_cavity(payload):
|
||||
"""多方案分模 + 方案形状 STEP 导出,全部在子进程内完成。
|
||||
|
||||
plan_result 里携带的 _export_shapes(TopoDS 对象)无法跨进程,子进程直接
|
||||
经 CADExporter 落盘为持久化 STEP,返回文件 manifest——与旧 _persist_step_exports
|
||||
产物结构一致,主进程原样存入 export_artifacts。
|
||||
"""
|
||||
parser = _cached("parser", _get_parser)
|
||||
planner = _cached("planner", _get_planner)
|
||||
shape = parser.load_step_file(Path(payload["stp_path"]))
|
||||
plan_result = planner.generate_plan(
|
||||
shape=shape,
|
||||
material=payload["material"],
|
||||
is_foam_material=payload.get("is_foam_material", False),
|
||||
process_params=payload.get("process_params"),
|
||||
)
|
||||
export_shapes = plan_result.pop("_export_shapes", {}) or {}
|
||||
export_manifest = _persist_export_shapes(payload, export_shapes)
|
||||
return {"plan_result": plan_result, "export_manifest": export_manifest}
|
||||
|
||||
|
||||
def _persist_export_shapes(payload: Dict[str, Any], export_shapes: Dict[str, Any]) -> Any:
|
||||
"""在子进程内把各方案的 TopoDS 形状导出为持久化 STEP,返回 manifest。"""
|
||||
if not export_shapes:
|
||||
return None
|
||||
task_id = payload["task_id"]
|
||||
stp_path = payload["stp_path"]
|
||||
export_out_dir = payload.get("export_out_dir")
|
||||
if not export_out_dir:
|
||||
raise ValueError("generate_cavity 缺少 export_out_dir")
|
||||
|
||||
# 每次按任务目录新建 exporter(output_dir 依任务固定;模块 import 由 importlib 缓存)
|
||||
exporter = _get_cad_exporter(export_out_dir)
|
||||
base_filename = Path(stp_path).stem or f"mold_{task_id}"
|
||||
components = ["cavity", "core", "parting_surface", "product", "a_plate", "b_plate"]
|
||||
manifest = {
|
||||
"version": 1,
|
||||
"task_id": task_id,
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"schemes": {},
|
||||
}
|
||||
for scheme_id, cavity_data in export_shapes.items():
|
||||
try:
|
||||
result = exporter.export_persisted_steps(
|
||||
cavity_data=cavity_data,
|
||||
base_filename=base_filename,
|
||||
components=components,
|
||||
task_id=task_id,
|
||||
scheme_id=scheme_id,
|
||||
)
|
||||
manifest["schemes"][scheme_id] = {
|
||||
"base_filename": result.get("base_filename"),
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"files": result.get("files", []),
|
||||
"errors": result.get("errors", []),
|
||||
"total_files": result.get("total_files", 0),
|
||||
"total_errors": result.get("total_errors", 0),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger = _get_logger()
|
||||
logger.warning("持久化 STEP 导出失败: task=%s scheme=%s error=%s", task_id, scheme_id, exc)
|
||||
manifest["schemes"][scheme_id] = {
|
||||
"base_filename": base_filename,
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"files": [],
|
||||
"errors": [str(exc)],
|
||||
"total_files": 0,
|
||||
"total_errors": 1,
|
||||
}
|
||||
return manifest
|
||||
|
||||
|
||||
def _get_logger():
|
||||
from shared.utils.logger import get_logger
|
||||
return get_logger(__name__)
|
||||
|
||||
|
||||
@_op("analyze_mold_design")
|
||||
def _op_analyze_mold_design(payload):
|
||||
parser = _cached("parser", _get_parser)
|
||||
analyzer = _cached("geometry_analyzer", _get_geometry_analyzer)
|
||||
shape = parser.load_step_file(Path(payload["stp_path"]))
|
||||
return analyzer.analyze_mold_design(
|
||||
payload["geometry_data"],
|
||||
product_material=payload.get("product_material", "ABS"),
|
||||
shape=shape,
|
||||
)
|
||||
|
||||
|
||||
@_op("detect_undercuts")
|
||||
def _op_detect_undercuts(payload):
|
||||
parser = _cached("parser", _get_parser)
|
||||
designer = _cached("side_action_designer", _get_side_action_designer)
|
||||
shape = parser.load_step_file(Path(payload["stp_path"]))
|
||||
return designer.analyze_and_design(
|
||||
shape,
|
||||
payload["parting_direction"],
|
||||
payload["mold_size"],
|
||||
)
|
||||
|
||||
|
||||
@_op("convert_component_step")
|
||||
def _op_convert_component_step(payload):
|
||||
exporter = _get_cad_exporter(os.path.dirname(payload["step_path"]))
|
||||
return exporter.convert_component_step(
|
||||
payload["step_path"], payload["out_path"], payload["fmt"]
|
||||
)
|
||||
|
||||
|
||||
# ─── 子进程主循环 ───────────────────────────────────────────────
|
||||
|
||||
def worker_main(conn) -> None:
|
||||
"""OCC 子进程消息循环。conn 为 multiprocessing.Connection(双工)。
|
||||
|
||||
收到 None 或管道断开即退出;单条操作异常回传 error 不杀循环
|
||||
(进程级隔离意味着一个操作 segfault 才会杀死本进程,由父进程补位)。
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
msg = conn.recv()
|
||||
except (EOFError, KeyboardInterrupt, ConnectionResetError, OSError):
|
||||
break
|
||||
if msg is None:
|
||||
break
|
||||
op_name, payload = msg
|
||||
handler = _OPS.get(op_name)
|
||||
if handler is None:
|
||||
conn.send(("error", {"error": f"未知 OCC 操作: {op_name}"}))
|
||||
continue
|
||||
try:
|
||||
result = handler(payload)
|
||||
conn.send(("ok", result))
|
||||
except BaseException as exc: # noqa: BLE001 子进程内兜底,保证循环存活
|
||||
conn.send(("error", {"error": f"{type(exc).__name__}: {exc}", "traceback": traceback.format_exc()}))
|
||||
@@ -0,0 +1,28 @@
|
||||
"""moldinsight 域模型出口。
|
||||
|
||||
全量模型注册点见 shared/models/base.py 模块 docstring;
|
||||
业务代码按需 `from moldinsight.models import STPFile, ...`。
|
||||
"""
|
||||
from moldinsight.models.stp_analysis import (
|
||||
STPFile,
|
||||
GeometryData,
|
||||
MeshData,
|
||||
HTMLFile,
|
||||
ProcessingTask,
|
||||
MoldCavityData,
|
||||
FeatureDetection,
|
||||
DesignRecommendation,
|
||||
AnalysisMetrics,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"STPFile",
|
||||
"GeometryData",
|
||||
"MeshData",
|
||||
"HTMLFile",
|
||||
"ProcessingTask",
|
||||
"MoldCavityData",
|
||||
"FeatureDetection",
|
||||
"DesignRecommendation",
|
||||
"AnalysisMetrics",
|
||||
]
|
||||
@@ -0,0 +1,352 @@
|
||||
"""moldinsight 域模型:STEP 分析链路(源文件 + 各阶段产物 + 任务)。
|
||||
|
||||
从旧 shared/models/database.py 拆出(D3,2026-09-17)。
|
||||
跨模块桥接只保留裸 FK,不建 ORM relationship(base.py 约定):
|
||||
- STPFile.user_id -> users.id(原 user relationship 无使用方,已删)
|
||||
- STPFile.product_id -> products.id(原 product relationship 无使用方,已删;
|
||||
分析结果一键转成品的桥接在 inventory/api/product_routes.py 显式 select 两表)
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, LargeBinary, Boolean, Float, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from shared.models.base import Base
|
||||
|
||||
|
||||
class STPFile(Base):
|
||||
"""STP源文件元数据表 - 支持同一文件多次上传"""
|
||||
__tablename__ = "stp_files"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||
# 关联进销存成品(P2-1:分析结果可一键创建为成品并回写;裸 FK,见模块 docstring)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=True, index=True)
|
||||
|
||||
# 对象存储信息
|
||||
object_key = Column(String(500), nullable=False, index=True) # MinIO对象键
|
||||
storage_bucket = Column(String(100), nullable=False) # 存储桶名称
|
||||
object_url = Column(String(1000), nullable=True) # 预签名URL(可选)
|
||||
|
||||
# 文件信息
|
||||
original_filename = Column(String(255), nullable=False, index=True) # 添加索引支持按文件名查询
|
||||
file_size = Column(Integer, nullable=False)
|
||||
file_hash = Column(String(64), index=True) # 移除unique约束,允许同一文件多次上传
|
||||
mime_type = Column(String(50), default="application/octet-stream")
|
||||
|
||||
# 上传批次标识 - 用于区分同一文件的多次上传
|
||||
upload_batch = Column(String(36), index=True) # UUID批次号
|
||||
|
||||
# 时间戳
|
||||
upload_time = Column(DateTime, default=func.now())
|
||||
processed_time = Column(DateTime, nullable=True)
|
||||
|
||||
# 状态
|
||||
status = Column(String(20), default="pending", index=True) # pending, processing, completed, failed
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
# 分析摘要 - 快速查询字段
|
||||
volume = Column(Float, nullable=True) # 体积 mm³
|
||||
surface_area = Column(Float, nullable=True) # 表面积 mm²
|
||||
product_weight = Column(Float, nullable=True) # 产品重量 g
|
||||
|
||||
# 保留旧字段以兼容
|
||||
file_path = Column(String(500), nullable=True) # 本地路径(已弃用)
|
||||
file_content = Column(LargeBinary, nullable=True) # 本地存储(已弃用)
|
||||
filename = Column(String(255), nullable=True) # 已弃用
|
||||
|
||||
# 关联关系(均为本模块内子表)
|
||||
geometry_data = relationship("GeometryData", back_populates="stp_file", uselist=False)
|
||||
mesh_data = relationship("MeshData", back_populates="stp_file", uselist=False)
|
||||
mold_cavity_data = relationship("MoldCavityData", back_populates="stp_file", uselist=False)
|
||||
html_file = relationship("HTMLFile", back_populates="stp_file", uselist=False)
|
||||
analysis_metrics = relationship("AnalysisMetrics", back_populates="stp_file", uselist=False)
|
||||
feature_detections = relationship("FeatureDetection", back_populates="stp_file")
|
||||
design_recommendations = relationship("DesignRecommendation", back_populates="stp_file")
|
||||
processing_tasks = relationship("ProcessingTask", back_populates="stp_file")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<STPFile(id={self.id}, original_filename='{self.original_filename}', status='{self.status}')>"
|
||||
|
||||
class GeometryData(Base):
|
||||
"""几何数据JSON元数据表"""
|
||||
__tablename__ = "geometry_data"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 对象存储信息
|
||||
object_key = Column(String(500), nullable=False)
|
||||
storage_bucket = Column(String(100), nullable=False)
|
||||
object_url = Column(String(1000), nullable=True)
|
||||
|
||||
# 分析方法
|
||||
analysis_method = Column(String(50), default="pythonocc") # pythonocc, simulated
|
||||
|
||||
# 时间戳
|
||||
created_time = Column(DateTime, default=func.now())
|
||||
|
||||
# 几何属性摘要(便于快速查询)
|
||||
volume = Column(Float, nullable=True)
|
||||
surface_area = Column(Float, nullable=True)
|
||||
bounding_box_min = Column(JSON, nullable=True)
|
||||
bounding_box_max = Column(JSON, nullable=True)
|
||||
center_of_mass = Column(JSON, nullable=True)
|
||||
|
||||
# 拓扑信息
|
||||
topology_faces = Column(Integer, nullable=True)
|
||||
topology_edges = Column(Integer, nullable=True)
|
||||
topology_vertices = Column(Integer, nullable=True)
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="geometry_data")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<GeometryData(id={self.id}, stp_file_id={self.stp_file_id})>"
|
||||
|
||||
|
||||
class MeshData(Base):
|
||||
"""网格数据JSON元数据表(详细网格存 RustFS,PostgreSQL 存摘要)"""
|
||||
__tablename__ = "mesh_data"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 对象存储信息
|
||||
object_key = Column(String(500), nullable=False)
|
||||
storage_bucket = Column(String(100), nullable=False)
|
||||
object_url = Column(String(1000), nullable=True)
|
||||
|
||||
# 生成设置
|
||||
quality = Column(String(20), default="medium") # low / medium / high
|
||||
|
||||
# 网格规模信息
|
||||
vertex_count = Column(Integer, nullable=True)
|
||||
face_count = Column(Integer, nullable=True)
|
||||
point_count = Column(Integer, nullable=True) # 采样点云数量
|
||||
|
||||
# 网格边界框(便于快速查询)
|
||||
bounding_box_min = Column(JSON, nullable=True)
|
||||
bounding_box_max = Column(JSON, nullable=True)
|
||||
|
||||
# 时间戳
|
||||
created_time = Column(DateTime, default=func.now())
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="mesh_data")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MeshData(id={self.id}, stp_file_id={self.stp_file_id}, quality='{self.quality}')>"
|
||||
|
||||
class HTMLFile(Base):
|
||||
"""网页文件元数据表"""
|
||||
__tablename__ = "html_files"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 对象存储信息
|
||||
object_key = Column(String(500), nullable=False)
|
||||
storage_bucket = Column(String(100), nullable=False)
|
||||
object_url = Column(String(1000), nullable=True)
|
||||
|
||||
# 文件信息
|
||||
filename = Column(String(255), nullable=False)
|
||||
generated_time = Column(DateTime, default=func.now())
|
||||
|
||||
# 可视化相关元数据
|
||||
visualization_type = Column(String(50), default="3d_viewer")
|
||||
has_interactive_elements = Column(Boolean, default=True)
|
||||
|
||||
# 保留旧字段以兼容
|
||||
file_path = Column(String(500), nullable=True)
|
||||
html_content = Column(Text, nullable=True)
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="html_file")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<HTMLFile(id={self.id}, stp_file_id={self.stp_file_id}, object_key='{self.object_key}')>"
|
||||
|
||||
class ProcessingTask(Base):
|
||||
"""处理任务记录表"""
|
||||
__tablename__ = "processing_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
task_id = Column(String(36), unique=True, index=True, nullable=False)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 批量上传聚合 ID(批次 2:批量元数据入库——PG 为单一事实源,
|
||||
# 同批任务经此列聚合查询,不再依赖 Redis/进程内存存批量元数据)
|
||||
batch_id = Column(String(36), nullable=True, index=True)
|
||||
|
||||
# 任务类型和状态
|
||||
task_type = Column(String(50), default="stp_parsing") # stp_parsing, geometry_analysis, mold_generation
|
||||
status = Column(String(20), default="pending") # pending, processing, completed, failed
|
||||
|
||||
# 时间戳
|
||||
created_time = Column(DateTime, default=func.now())
|
||||
started_time = Column(DateTime, nullable=True)
|
||||
completed_time = Column(DateTime, nullable=True)
|
||||
|
||||
# 处理进度
|
||||
progress = Column(Integer, default=0) # 0-100
|
||||
current_step = Column(String(100), nullable=True)
|
||||
|
||||
# 错误信息
|
||||
error_message = Column(Text, nullable=True)
|
||||
error_stack = Column(Text, nullable=True)
|
||||
|
||||
# 处理参数
|
||||
parameters = Column(JSON, nullable=True) # 任务参数
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="processing_tasks")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ProcessingTask(id={self.id}, task_id='{self.task_id}', status='{self.status}')>"
|
||||
|
||||
class MoldCavityData(Base):
|
||||
"""模具型腔数据元数据表"""
|
||||
__tablename__ = "mold_cavity_data"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 对象存储信息
|
||||
detailed_object_key = Column(String(500), nullable=False) # 完整三维数据
|
||||
storage_bucket = Column(String(100), nullable=False)
|
||||
|
||||
# 模具类型和材料
|
||||
mold_material = Column(String(100), default="Aluminum Alloy 7075")
|
||||
mold_type = Column(String(50), default="single_cavity") # single_cavity, multi_cavity
|
||||
|
||||
# 工艺参数
|
||||
shrinkage_rate = Column(Float, nullable=False)
|
||||
draft_angle = Column(Float, nullable=False)
|
||||
parting_line_length = Column(Float, nullable=True)
|
||||
|
||||
# 生成时间
|
||||
generated_time = Column(DateTime, default=func.now())
|
||||
|
||||
# 关键信息摘要(快速查询字段)
|
||||
cavity_key_info = Column(JSON, nullable=True) # 完整关键信息
|
||||
|
||||
# 提取的字段(便于查询和排序)
|
||||
mold_size_length = Column(Float, nullable=True)
|
||||
mold_size_width = Column(Float, nullable=True)
|
||||
mold_size_height = Column(Float, nullable=True)
|
||||
estimated_clamping_force = Column(String(50), nullable=True)
|
||||
product_weight = Column(String(50), nullable=True)
|
||||
product_volume = Column(Float, nullable=True)
|
||||
wall_thickness_range = Column(String(50), nullable=True)
|
||||
complexity_score = Column(Float, nullable=True)
|
||||
|
||||
# 质量评估
|
||||
weld_line_risk = Column(String(50), nullable=True) # 熔接痕风险
|
||||
sink_mark_risk = Column(String(50), nullable=True) # 缩痕风险
|
||||
warpage_risk = Column(String(50), nullable=True) # 翘曲风险
|
||||
|
||||
# 多方案可信化摘要(第1周阶段1)
|
||||
best_scheme_id = Column(String(64), nullable=True, index=True)
|
||||
confidence_score = Column(Float, nullable=True)
|
||||
is_fallback = Column(Boolean, nullable=True, index=True)
|
||||
fallback_reason = Column(Text, nullable=True)
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="mold_cavity_data")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MoldCavityData(stp_file_id={self.stp_file_id}, mold_material='{self.mold_material}')>"
|
||||
|
||||
|
||||
class FeatureDetection(Base):
|
||||
"""特征检测结果表"""
|
||||
__tablename__ = "feature_detections"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 特征信息
|
||||
feature_type = Column(String(50), nullable=False, index=True) # thin_wall, thick_wall, wall_non_uniform, rib, boss, draft_angle, high_curvature, fillet
|
||||
confidence = Column(Float, nullable=False) # 0.0 - 1.0
|
||||
|
||||
# 位置和尺寸
|
||||
location = Column(JSON, nullable=True) # [x, y, z]
|
||||
dimensions = Column(JSON, nullable=True) # [length, width, height]
|
||||
|
||||
# 特征参数
|
||||
parameters = Column(JSON, nullable=True) # 自定义参数
|
||||
|
||||
# 检测时间
|
||||
detected_at = Column(DateTime, default=func.now())
|
||||
|
||||
# 关联的几何数据
|
||||
geometry_data_id = Column(Integer, ForeignKey("geometry_data.id"), nullable=True)
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="feature_detections")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<FeatureDetection(id={self.id}, feature_type='{self.feature_type}', confidence={self.confidence})>"
|
||||
|
||||
|
||||
class DesignRecommendation(Base):
|
||||
"""设计建议表"""
|
||||
__tablename__ = "design_recommendations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 建议信息
|
||||
rec_type = Column(String(50), nullable=False) # wall_thickness, draft_angle, etc.
|
||||
priority = Column(String(20), nullable=False) # high, medium, low
|
||||
description = Column(String(500), nullable=False)
|
||||
reason = Column(Text, nullable=True)
|
||||
|
||||
# 建议参数
|
||||
parameters = Column(JSON, nullable=True)
|
||||
|
||||
# 状态
|
||||
status = Column(String(20), default="pending") # pending, accepted, rejected
|
||||
user_notes = Column(Text, nullable=True)
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, nullable=True)
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="design_recommendations")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DesignRecommendation(id={self.id}, rec_type='{self.rec_type}', priority='{self.priority}')>"
|
||||
|
||||
|
||||
class AnalysisMetrics(Base):
|
||||
"""分析指标表"""
|
||||
__tablename__ = "analysis_metrics"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 质量指标
|
||||
volume_utilization = Column(Float, default=0) # 体积利用率
|
||||
topology_complexity = Column(Float, default=0) # 拓扑复杂度
|
||||
wall_uniformity = Column(Float, default=0) # 壁厚均匀性
|
||||
|
||||
# 分析摘要
|
||||
analysis_summary = Column(Text, nullable=True)
|
||||
|
||||
# FreeCAD 验证结果
|
||||
verification_status = Column(String(20), nullable=True) # passed, failed, pending, error
|
||||
verification_volume_diff = Column(Float, nullable=True) # 体积差异百分比
|
||||
verification_area_diff = Column(Float, nullable=True) # 表面积差异百分比
|
||||
verification_details = Column(JSON, nullable=True) # 完整验证结果
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="analysis_metrics")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AnalysisMetrics(stp_file_id={self.stp_file_id}, volume_utilization={self.volume_utilization})>"
|
||||
@@ -59,6 +59,9 @@ def get_aluminum_current_price() -> Dict:
|
||||
"low": round(price - random.uniform(10, 60), 0),
|
||||
"prev_close": prev_price,
|
||||
"week_ago_price": round(week_price, 0),
|
||||
# D2:数据为模拟走势(见模块 docstring),必须显式声明来源,
|
||||
# 前端按此字段展示"模拟/参考"标注,防止被当作实时行情
|
||||
"source": "simulated",
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +90,7 @@ def get_aluminum_price_history(days: int = 30) -> List[Dict]:
|
||||
"high": high_price,
|
||||
"low": low_price,
|
||||
"close": close_price,
|
||||
"source": "simulated", # D2:与 current 一致,逐项显式声明模拟来源
|
||||
})
|
||||
|
||||
random.seed()
|
||||
|
||||
+34
-396
@@ -1,27 +1,26 @@
|
||||
# services/storage_integration_rustfs.py
|
||||
"""存储集成服务 - 协调 PostgreSQL 和 RustFS"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
# services/analysis_storage_service.py
|
||||
"""分析结果数据存储——几何/网格/型腔/HTML/特征的 RustFS 上传与 PG 元数据,
|
||||
以及任务完整数据视图的组装。
|
||||
|
||||
批次 3 自 storage_integration_rustfs.py 按职责拆分(见 task_storage_service.py 头注)。
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
import json
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from shared.models.database import (
|
||||
STPFile, GeometryData, MeshData, MoldCavityData,
|
||||
HTMLFile, ProcessingTask, User,
|
||||
FeatureDetection, DesignRecommendation,
|
||||
UserActivity, SystemLog
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from moldinsight.models import STPFile, GeometryData, MeshData, MoldCavityData, HTMLFile, FeatureDetection, DesignRecommendation
|
||||
from moldinsight.storage.rustfs_storage import rustfs_manager
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class StorageIntegrationService:
|
||||
"""存储集成服务 - PostgreSQL + RustFS"""
|
||||
class AnalysisStorageService:
|
||||
"""分析结果数据(几何/网格/型腔/HTML/特征)存储与视图组装"""
|
||||
|
||||
@staticmethod
|
||||
def _resolve_best_scheme_payload(cavity_json: Dict[str, Any]) -> Dict[str, Any]:
|
||||
@@ -80,169 +79,6 @@ class StorageIntegrationService:
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
async def save_stp_file(self, session: AsyncSession,
|
||||
file_path: Path,
|
||||
original_filename: str,
|
||||
user_id: Optional[int] = None,
|
||||
upload_batch: Optional[str] = None) -> STPFile:
|
||||
"""保存STP文件到PostgreSQL元数据 + RustFS对象存储
|
||||
|
||||
支持同一文件多次上传,每次上传都会创建新记录
|
||||
"""
|
||||
|
||||
# 1. 上传到RustFS
|
||||
upload_result = await rustfs_manager.upload_file(
|
||||
file_type='stp_files',
|
||||
file_path=file_path,
|
||||
original_filename=original_filename,
|
||||
metadata={
|
||||
'original_filename': original_filename,
|
||||
'user_id': str(user_id) if user_id else 'anonymous',
|
||||
'upload_batch': upload_batch or str(uuid.uuid4())
|
||||
}
|
||||
)
|
||||
|
||||
file_hash = upload_result['file_hash']
|
||||
batch_id = upload_batch or str(uuid.uuid4())
|
||||
|
||||
# 2. 创建新PostgreSQL记录(每次上传都创建新记录)
|
||||
from datetime import datetime
|
||||
stp_file = STPFile(
|
||||
user_id=user_id,
|
||||
object_key=upload_result['object_key'],
|
||||
storage_bucket=upload_result['bucket'],
|
||||
original_filename=original_filename,
|
||||
file_size=upload_result['file_size'],
|
||||
file_hash=file_hash,
|
||||
upload_batch=batch_id,
|
||||
status="uploaded",
|
||||
file_path=str(file_path),
|
||||
upload_time=datetime.now()
|
||||
)
|
||||
|
||||
session.add(stp_file)
|
||||
# D9:仅 flush,与 ProcessingTask 由路由层一并原子提交(避免孤儿文件记录)
|
||||
await session.flush()
|
||||
await session.refresh(stp_file)
|
||||
|
||||
logger.info(f"STP文件保存成功 RustFS: {stp_file.id}, 批次: {batch_id}")
|
||||
return stp_file
|
||||
|
||||
async def create_processing_task(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
task_id: str,
|
||||
stp_file_id: int,
|
||||
task_type: str = "stp_parsing",
|
||||
parameters: Optional[Dict[str, Any]] = None,
|
||||
batch_id: Optional[str] = None,
|
||||
) -> ProcessingTask:
|
||||
"""创建处理任务记录(D9:仅 flush 不 commit,事务由调用方收口——
|
||||
与 STPFile 记录同批提交,避免留下无任务的孤儿文件记录;batch_id 用于批量任务聚合查询)
|
||||
"""
|
||||
try:
|
||||
task = ProcessingTask(
|
||||
task_id=task_id,
|
||||
stp_file_id=stp_file_id,
|
||||
task_type=task_type,
|
||||
status="pending",
|
||||
started_time=datetime.now(),
|
||||
parameters=parameters or {},
|
||||
batch_id=batch_id,
|
||||
)
|
||||
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
|
||||
logger.info(f"处理任务创建成功: {task_id}")
|
||||
return task
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"创建处理任务失败: {e}")
|
||||
raise
|
||||
|
||||
async def update_task_status(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
task_id: str,
|
||||
status: str,
|
||||
progress: Optional[int] = None,
|
||||
current_step: Optional[str] = None,
|
||||
error_message: Optional[str] = None
|
||||
):
|
||||
"""更新任务状态(保留即时 commit:进度/状态需跨事务对外可见,
|
||||
处理链路中的各阶段进度依赖它落库——D9 收口仅针对数据本体写方法)"""
|
||||
try:
|
||||
update_data = {
|
||||
"status": status,
|
||||
"completed_time": datetime.now() if status in ["completed", "failed"] else None,
|
||||
"error_message": error_message
|
||||
}
|
||||
|
||||
if progress is not None:
|
||||
update_data["progress"] = progress
|
||||
if current_step is not None:
|
||||
update_data["current_step"] = current_step
|
||||
|
||||
await session.execute(
|
||||
update(ProcessingTask)
|
||||
.where(ProcessingTask.task_id == task_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"任务状态更新: {task_id} -> {status}")
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"更新任务状态失败: {e}")
|
||||
raise
|
||||
|
||||
async def update_task_parameters(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
task_id: str,
|
||||
parameters: Dict[str, Any],
|
||||
):
|
||||
"""合并更新任务参数,便于保存阶段耗时等元数据。(D9:flush 不 commit,事务由调用方收口)"""
|
||||
try:
|
||||
task = await session.execute(
|
||||
select(ProcessingTask).where(ProcessingTask.task_id == task_id)
|
||||
)
|
||||
task = task.scalar_one_or_none()
|
||||
if task is None:
|
||||
return
|
||||
|
||||
merged = dict(task.parameters or {})
|
||||
merged.update(parameters or {})
|
||||
task.parameters = merged
|
||||
await session.flush()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"更新任务参数失败: {e}")
|
||||
raise
|
||||
|
||||
async def update_stp_file_status(self, session: AsyncSession, stp_file_id: int, status: str):
|
||||
"""更新STP文件状态(保留即时 commit,理由同 update_task_status)"""
|
||||
try:
|
||||
await session.execute(
|
||||
update(STPFile)
|
||||
.where(STPFile.id == stp_file_id)
|
||||
.values(
|
||||
status=status,
|
||||
processed_time=datetime.now() if status in ["completed", "failed"] else None
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"STP文件状态更新: ID {stp_file_id} -> {status}")
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"更新STP文件状态失败: {e}")
|
||||
raise
|
||||
|
||||
async def save_geometry_data(self, session: AsyncSession,
|
||||
stp_file_id: int,
|
||||
geometry_json: Dict[str, Any],
|
||||
@@ -435,39 +271,29 @@ class StorageIntegrationService:
|
||||
stp_file_id: int,
|
||||
filename: str,
|
||||
file_path: str,
|
||||
html_content: Optional[str] = None,
|
||||
visualization_type: str = "3d_viewer") -> HTMLFile:
|
||||
"""保存HTML文件到PostgreSQL元数据 + RustFS对象存储"""
|
||||
"""保存HTML文件:正文按报告键裸传 RustFS + PG 仅存元数据(D11)。
|
||||
|
||||
# 1. 获取文件哈希
|
||||
stp_file = await session.get(STPFile, stp_file_id)
|
||||
file_hash = stp_file.file_hash
|
||||
上传键固定 html/reports/{filename}(文件名寻址),读侧 GET /html/{filename}
|
||||
按文件名直取,不再写 JSON 包装对象(遗留 html/{hash}.json 仅由读侧兼容解析)。
|
||||
file_path 为任务内临时目录路径,任务结束即删除,仅供追溯,不作为读取来源。
|
||||
"""
|
||||
html_file_path = Path(file_path)
|
||||
if not html_file_path.exists():
|
||||
raise RuntimeError(f"HTML 可视化文件缺失: {file_path}")
|
||||
html_bytes = html_file_path.read_bytes()
|
||||
|
||||
# 2. 读取HTML内容(如果未提供)
|
||||
if html_content is None:
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
html_content = f.read()
|
||||
except Exception as e:
|
||||
logger.error(f"读取HTML文件失败: {e}")
|
||||
html_content = ""
|
||||
|
||||
# 3. 上传到RustFS
|
||||
html_json = {'content': html_content, 'filename': filename}
|
||||
upload_result = await rustfs_manager.upload_json_data(
|
||||
file_type='html_files',
|
||||
json_data=html_json,
|
||||
file_hash=file_hash
|
||||
object_key = await rustfs_manager.upload_report_artifact(
|
||||
filename, html_bytes, content_type="text/html; charset=utf-8"
|
||||
)
|
||||
|
||||
# 4. 创建PostgreSQL记录
|
||||
html_file = HTMLFile(
|
||||
stp_file_id=stp_file_id,
|
||||
object_key=upload_result['object_key'],
|
||||
storage_bucket=upload_result['bucket'],
|
||||
object_key=object_key,
|
||||
storage_bucket=rustfs_manager.bucket_name,
|
||||
filename=filename,
|
||||
file_path=file_path, # 保留本地路径
|
||||
# 停止双写完整 HTML 进 PG:读取路径走 RustFS(html_json.content),
|
||||
file_path=file_path,
|
||||
# 停止双写完整 HTML 进 PG:读取路径走 /html/{filename} 代理,
|
||||
# PG 仅存对象键与文件名,避免大文本撑爆表
|
||||
html_content=None,
|
||||
visualization_type=visualization_type
|
||||
@@ -478,7 +304,7 @@ class StorageIntegrationService:
|
||||
await session.flush()
|
||||
await session.refresh(html_file)
|
||||
|
||||
logger.info(f"HTML文件保存成功 RustFS: {html_file.id}")
|
||||
logger.info(f"HTML文件保存成功 RustFS: {html_file.id} ({object_key})")
|
||||
return html_file
|
||||
|
||||
async def save_features_and_recommendations(
|
||||
@@ -517,37 +343,9 @@ class StorageIntegrationService:
|
||||
await session.flush()
|
||||
logger.info(f"保存了 {len(features)} 个特征和 {len(recommendations)} 个建议")
|
||||
|
||||
async def log_user_activity(self, session: AsyncSession,
|
||||
user_id: int,
|
||||
activity_type: str,
|
||||
resource_type: Optional[str] = None,
|
||||
resource_id: Optional[int] = None,
|
||||
description: Optional[str] = None,
|
||||
metadata: Optional[Dict] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None):
|
||||
"""记录用户活动"""
|
||||
|
||||
activity = UserActivity(
|
||||
user_id=user_id,
|
||||
activity_type=activity_type,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
description=description,
|
||||
meta_data=metadata,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
|
||||
session.add(activity)
|
||||
await session.commit()
|
||||
logger.debug(f"用户活动记录: {activity_type} by user {user_id}")
|
||||
|
||||
async def get_stp_file_with_data(self, session: AsyncSession,
|
||||
stp_file_id: int) -> Dict[str, Any]:
|
||||
"""获取STP文件及其所有关联数据"""
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
try:
|
||||
# 1. 获取STP文件记录(使用 joinedload 预加载关联数据)
|
||||
result = await session.execute(
|
||||
@@ -579,7 +377,6 @@ class StorageIntegrationService:
|
||||
'geometry_data': None,
|
||||
'mesh_data': None,
|
||||
'mold_cavity_data': None,
|
||||
'html_content': None,
|
||||
'features': [],
|
||||
'recommendations': [],
|
||||
'analysis_metrics': None # 新增分析指标字段
|
||||
@@ -611,14 +408,10 @@ class StorageIntegrationService:
|
||||
)
|
||||
result['mesh_data'] = json.loads(mesh_bytes.decode('utf-8'))
|
||||
|
||||
# HTML文件
|
||||
if stp_file.html_file:
|
||||
html_bytes = await rustfs_manager.download_file(
|
||||
file_type='html_files',
|
||||
object_key=stp_file.html_file.object_key
|
||||
)
|
||||
html_json = json.loads(html_bytes.decode('utf-8'))
|
||||
result['html_content'] = html_json.get('content', '')
|
||||
# HTML 正文不再随本视图下载(D11):历史实现把整个 HTML 读进
|
||||
# result['html_content'],但所有调用方只取 geometry/cavity/features,
|
||||
# HTML 的读取入口是 /html/{filename} 代理路由——每次任务查询白下载
|
||||
# 数 MB 正文纯属浪费,已删除
|
||||
except Exception as e:
|
||||
logger.error(f"从RustFS获取数据失败: {e}")
|
||||
|
||||
@@ -666,157 +459,6 @@ class StorageIntegrationService:
|
||||
|
||||
return result
|
||||
|
||||
async def get_file_history_by_filename(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
filename: str,
|
||||
user_id: Optional[int] = None,
|
||||
limit: int = 50
|
||||
) -> list:
|
||||
"""获取同一文件名的所有上传历史记录"""
|
||||
from shared.models.database import ProcessingTask
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
query = select(STPFile).options(
|
||||
joinedload(STPFile.processing_tasks)
|
||||
).where(
|
||||
STPFile.original_filename == filename
|
||||
).order_by(STPFile.upload_time.desc())
|
||||
|
||||
if user_id:
|
||||
query = query.where(STPFile.user_id == user_id)
|
||||
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await session.execute(query)
|
||||
files = result.unique().scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
'id': f.id,
|
||||
'task_id': f.processing_tasks[0].task_id if f.processing_tasks else None,
|
||||
'upload_batch': f.upload_batch,
|
||||
'upload_time': f.upload_time.strftime('%Y-%m-%d %H:%M:%S') if f.upload_time else None,
|
||||
'file_size': f.file_size,
|
||||
'status': f.status,
|
||||
'volume': f.volume,
|
||||
'surface_area': f.surface_area,
|
||||
'product_weight': f.product_weight,
|
||||
'has_analysis': f.status == 'completed'
|
||||
}
|
||||
for f in files
|
||||
]
|
||||
|
||||
async def get_all_file_groups(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: Optional[int] = None,
|
||||
limit: int = 100
|
||||
) -> list:
|
||||
"""获取所有文件分组(按文件名分组),包含每个文件的最新分析结果"""
|
||||
|
||||
from sqlalchemy import func, desc
|
||||
from sqlalchemy.orm import joinedload
|
||||
from shared.models.database import ProcessingTask
|
||||
|
||||
# 子查询:获取每个文件名的最新上传
|
||||
subquery = (
|
||||
select(
|
||||
STPFile.original_filename,
|
||||
func.max(STPFile.upload_time).label('latest_upload')
|
||||
)
|
||||
.group_by(STPFile.original_filename)
|
||||
.order_by(desc('latest_upload'))
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if user_id:
|
||||
subquery = subquery.where(STPFile.user_id == user_id)
|
||||
|
||||
subquery = subquery.subquery()
|
||||
|
||||
# 主查询:获取最新记录和统计信息
|
||||
query = (
|
||||
select(STPFile).options(
|
||||
joinedload(STPFile.processing_tasks)
|
||||
)
|
||||
.join(
|
||||
subquery,
|
||||
(STPFile.original_filename == subquery.c.original_filename) &
|
||||
(STPFile.upload_time == subquery.c.latest_upload)
|
||||
)
|
||||
.order_by(STPFile.upload_time.desc())
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
latest_files = result.unique().scalars().all()
|
||||
|
||||
# 一次性聚合每个文件名的上传次数(替代逐文件 count 的 N+1 查询)
|
||||
count_subquery = (
|
||||
select(STPFile.original_filename, func.count().label("upload_count"))
|
||||
.group_by(STPFile.original_filename)
|
||||
)
|
||||
if user_id:
|
||||
count_subquery = count_subquery.where(STPFile.user_id == user_id)
|
||||
count_result = await session.execute(count_subquery)
|
||||
upload_counts = {
|
||||
row.original_filename: row.upload_count for row in count_result
|
||||
}
|
||||
|
||||
# 获取每个文件名的上传次数
|
||||
file_groups = []
|
||||
for f in latest_files:
|
||||
task_id = f.processing_tasks[0].task_id if f.processing_tasks else None
|
||||
upload_count = upload_counts.get(f.original_filename, 1)
|
||||
|
||||
file_groups.append({
|
||||
'filename': f.original_filename,
|
||||
'latest_id': f.id,
|
||||
'latest_task_id': task_id,
|
||||
'latest_upload_time': f.upload_time.strftime('%Y-%m-%d %H:%M:%S') if f.upload_time else None,
|
||||
'latest_status': f.status,
|
||||
'upload_count': upload_count,
|
||||
'file_size': f.file_size,
|
||||
'volume': f.volume,
|
||||
'surface_area': f.surface_area,
|
||||
'product_weight': f.product_weight
|
||||
})
|
||||
|
||||
return file_groups
|
||||
|
||||
async def update_stp_file_analysis_summary(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
stp_file_id: int,
|
||||
volume: Optional[float] = None,
|
||||
surface_area: Optional[float] = None,
|
||||
product_weight: Optional[float] = None
|
||||
):
|
||||
"""更新STP文件的分析摘要字段(用于快速查询)"""
|
||||
try:
|
||||
update_data = {}
|
||||
if volume is not None:
|
||||
update_data['volume'] = volume
|
||||
if surface_area is not None:
|
||||
update_data['surface_area'] = surface_area
|
||||
if product_weight is not None:
|
||||
update_data['product_weight'] = product_weight
|
||||
|
||||
if update_data:
|
||||
await session.execute(
|
||||
update(STPFile)
|
||||
.where(STPFile.id == stp_file_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
# D9:flush 不 commit,随结果包由编排层统一提交
|
||||
await session.flush()
|
||||
logger.info(f"STP文件分析摘要更新: ID {stp_file_id}")
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"更新STP文件分析摘要失败: {e}")
|
||||
raise
|
||||
|
||||
async def delete_stp_file_cascade(self, session: AsyncSession,
|
||||
stp_file_id: int):
|
||||
"""级联删除STP文件及其所有关联数据"""
|
||||
@@ -861,7 +503,3 @@ class StorageIntegrationService:
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"STP文件及其关联数据已删除: {stp_file_id}")
|
||||
|
||||
|
||||
# 全局存储集成服务实例
|
||||
storage_integration = StorageIntegrationService()
|
||||
@@ -0,0 +1,131 @@
|
||||
# services/file_history_service.py
|
||||
"""文件历史查询视图——按文件名分组的多版本上传历史。
|
||||
|
||||
批次 3 自 storage_integration_rustfs.py 按职责拆分(见 task_storage_service.py 头注)。
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, func, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from moldinsight.models import STPFile, ProcessingTask
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class FileHistoryService:
|
||||
"""按文件名聚合的上传历史查询"""
|
||||
|
||||
async def get_file_history_by_filename(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
filename: str,
|
||||
user_id: Optional[int] = None,
|
||||
limit: int = 50
|
||||
) -> list:
|
||||
"""获取同一文件名的所有上传历史记录"""
|
||||
|
||||
query = select(STPFile).options(
|
||||
joinedload(STPFile.processing_tasks)
|
||||
).where(
|
||||
STPFile.original_filename == filename
|
||||
).order_by(STPFile.upload_time.desc())
|
||||
|
||||
if user_id:
|
||||
query = query.where(STPFile.user_id == user_id)
|
||||
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await session.execute(query)
|
||||
files = result.unique().scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
'id': f.id,
|
||||
'task_id': f.processing_tasks[0].task_id if f.processing_tasks else None,
|
||||
'upload_batch': f.upload_batch,
|
||||
'upload_time': f.upload_time.strftime('%Y-%m-%d %H:%M:%S') if f.upload_time else None,
|
||||
'file_size': f.file_size,
|
||||
'status': f.status,
|
||||
'volume': f.volume,
|
||||
'surface_area': f.surface_area,
|
||||
'product_weight': f.product_weight,
|
||||
'has_analysis': f.status == 'completed'
|
||||
}
|
||||
for f in files
|
||||
]
|
||||
|
||||
async def get_all_file_groups(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: Optional[int] = None,
|
||||
limit: int = 100
|
||||
) -> list:
|
||||
"""获取所有文件分组(按文件名分组),包含每个文件的最新分析结果"""
|
||||
|
||||
# 子查询:获取每个文件名的最新上传
|
||||
subquery = (
|
||||
select(
|
||||
STPFile.original_filename,
|
||||
func.max(STPFile.upload_time).label('latest_upload')
|
||||
)
|
||||
.group_by(STPFile.original_filename)
|
||||
.order_by(desc('latest_upload'))
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if user_id:
|
||||
subquery = subquery.where(STPFile.user_id == user_id)
|
||||
|
||||
subquery = subquery.subquery()
|
||||
|
||||
# 主查询:获取最新记录和统计信息
|
||||
query = (
|
||||
select(STPFile).options(
|
||||
joinedload(STPFile.processing_tasks)
|
||||
)
|
||||
.join(
|
||||
subquery,
|
||||
(STPFile.original_filename == subquery.c.original_filename) &
|
||||
(STPFile.upload_time == subquery.c.latest_upload)
|
||||
)
|
||||
.order_by(STPFile.upload_time.desc())
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
latest_files = result.unique().scalars().all()
|
||||
|
||||
# 一次性聚合每个文件名的上传次数(替代逐文件 count 的 N+1 查询)
|
||||
count_subquery = (
|
||||
select(STPFile.original_filename, func.count().label("upload_count"))
|
||||
.group_by(STPFile.original_filename)
|
||||
)
|
||||
if user_id:
|
||||
count_subquery = count_subquery.where(STPFile.user_id == user_id)
|
||||
count_result = await session.execute(count_subquery)
|
||||
upload_counts = {
|
||||
row.original_filename: row.upload_count for row in count_result
|
||||
}
|
||||
|
||||
# 获取每个文件名的上传次数
|
||||
file_groups = []
|
||||
for f in latest_files:
|
||||
task_id = f.processing_tasks[0].task_id if f.processing_tasks else None
|
||||
upload_count = upload_counts.get(f.original_filename, 1)
|
||||
|
||||
file_groups.append({
|
||||
'filename': f.original_filename,
|
||||
'latest_id': f.id,
|
||||
'latest_task_id': task_id,
|
||||
'latest_upload_time': f.upload_time.strftime('%Y-%m-%d %H:%M:%S') if f.upload_time else None,
|
||||
'latest_status': f.status,
|
||||
'upload_count': upload_count,
|
||||
'file_size': f.file_size,
|
||||
'volume': f.volume,
|
||||
'surface_area': f.surface_area,
|
||||
'product_weight': f.product_weight
|
||||
})
|
||||
|
||||
return file_groups
|
||||
@@ -0,0 +1,144 @@
|
||||
# services/occ_process_pool.py
|
||||
"""常驻 OCC 工作进程池(方案 B,见 docs/topics/performance/OCC_THROUGHPUT.md)。
|
||||
|
||||
替代原进程内 `ThreadPoolExecutor(max_workers=1)`:
|
||||
- 每个工作进程是一个独立 OCC 通道(OCC 非线程安全,通道内串行),常驻不随任务拉起
|
||||
(spawn 下 import OCC 秒级,按任务拉起会把开销摊到每个任务上)
|
||||
- 超时/崩溃 = terminate() 换新进程补位——进程边界干净回收(线程级无法击杀 C++ 栈,
|
||||
旧方案每次超时滞留 1 个线程)
|
||||
- 输入输出走文件路径 + 普通字典,杜绝 pickle OCC 对象(见 core/occ_worker.py)
|
||||
"""
|
||||
import asyncio
|
||||
import multiprocessing
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from moldinsight.core.occ_worker import worker_main
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class _OccWorker:
|
||||
"""单个 OCC 工作进程的父进程侧封装。"""
|
||||
|
||||
def __init__(self, process, conn):
|
||||
self.process = process
|
||||
self.conn = conn
|
||||
self.lock = asyncio.Lock() # 通道串行:同一进程同时只有一个操作在途
|
||||
|
||||
async def run(self, op_name: str, payload: Dict[str, Any], timeout: float):
|
||||
# 阻塞式管道收发放 asyncio.to_thread,不卡事件循环;
|
||||
# 超时后父进程 terminate() 子进程 → 管道 EOF → 该线程 recv 立即返回,无泄漏
|
||||
result = await asyncio.wait_for(
|
||||
asyncio.to_thread(self._run_blocking, op_name, payload),
|
||||
timeout=timeout,
|
||||
)
|
||||
return result
|
||||
|
||||
def _run_blocking(self, op_name: str, payload: Dict[str, Any]):
|
||||
self.conn.send((op_name, payload))
|
||||
status, data = self.conn.recv()
|
||||
if status == "error":
|
||||
raise RuntimeError(data.get("error") or "OCC 子进程操作失败")
|
||||
return data
|
||||
|
||||
|
||||
class OccProcessPool:
|
||||
"""OCC 工作进程池(默认 1 进程 = 1 串行通道,与旧单线程语义一致)。
|
||||
|
||||
池大小与 celery 并发解耦(celery 并发走多 worker 子进程,每个持自己的池)。
|
||||
"""
|
||||
|
||||
def __init__(self, size: int = 1):
|
||||
if size < 1:
|
||||
raise ValueError("size 必须 >= 1")
|
||||
self._size = size
|
||||
self._ctx = multiprocessing.get_context("spawn")
|
||||
self._workers: list[_OccWorker] = []
|
||||
self._rr = 0
|
||||
# 保护 workers 列表与轮转指针;操作执行期不持锁
|
||||
self._pool_lock = asyncio.Lock()
|
||||
# 任务级整体超时(process_file_with_storage 外层 wait_for)时的在途 worker 追踪
|
||||
self._busy: Optional[_OccWorker] = None
|
||||
|
||||
async def run(self, op_name: str, payload: Dict[str, Any], timeout: float = 600):
|
||||
for attempt in range(3):
|
||||
async with self._pool_lock:
|
||||
await self._ensure_started()
|
||||
worker = self._workers[self._rr]
|
||||
self._rr = (self._rr + 1) % len(self._workers)
|
||||
self._busy = worker
|
||||
try:
|
||||
async with worker.lock:
|
||||
return await worker.run(op_name, payload, timeout)
|
||||
except asyncio.TimeoutError:
|
||||
await self._replace(worker)
|
||||
raise
|
||||
except Exception as exc:
|
||||
if not worker.process.is_alive():
|
||||
# OCC segfault 等进程死亡:换新补位后重试该操作
|
||||
logger.warning(f"OCC 工作进程异常退出,重试操作 {op_name}: {exc}")
|
||||
await self._replace(worker)
|
||||
continue
|
||||
raise
|
||||
finally:
|
||||
if self._busy is worker:
|
||||
self._busy = None
|
||||
raise RuntimeError(f"OCC 工作进程连续异常,操作 {op_name} 未能完成")
|
||||
|
||||
async def recover(self):
|
||||
"""任务级整体超时恢复:重建整个池,丢弃可能正卡在挂死 OCC 操作上的进程。
|
||||
|
||||
单通道池重建代价可忽略;重建后下次 run 自动按需补拉。
|
||||
"""
|
||||
async with self._pool_lock:
|
||||
for worker in self._workers:
|
||||
await self._terminate(worker)
|
||||
self._workers = []
|
||||
self._busy = None
|
||||
logger.warning("OCC 进程池已整体重建(任务级超时恢复)")
|
||||
|
||||
async def shutdown(self):
|
||||
async with self._pool_lock:
|
||||
for worker in self._workers:
|
||||
await self._terminate(worker)
|
||||
self._workers = []
|
||||
self._busy = None
|
||||
|
||||
async def _ensure_started(self):
|
||||
if not self._workers:
|
||||
for _ in range(self._size):
|
||||
self._workers.append(self._spawn_one())
|
||||
logger.info(f"OCC 进程池已启动: {self._size} 个常驻工作进程")
|
||||
|
||||
def _spawn_one(self) -> _OccWorker:
|
||||
parent_conn, child_conn = self._ctx.Pipe(duplex=True)
|
||||
proc = self._ctx.Process(target=worker_main, args=(child_conn,), daemon=True)
|
||||
proc.start()
|
||||
child_conn.close() # 父进程侧关闭子端,只留 parent_conn
|
||||
return _OccWorker(proc, parent_conn)
|
||||
|
||||
async def _replace(self, worker: _OccWorker):
|
||||
async with self._pool_lock:
|
||||
await self._terminate(worker)
|
||||
new_worker = self._spawn_one()
|
||||
try:
|
||||
idx = self._workers.index(worker)
|
||||
except ValueError:
|
||||
# 已被并发重建移除,新进程追加补位
|
||||
self._workers.append(new_worker)
|
||||
else:
|
||||
self._workers[idx] = new_worker
|
||||
logger.warning("OCC 工作进程已重建补位")
|
||||
|
||||
@staticmethod
|
||||
async def _terminate(worker: _OccWorker):
|
||||
try:
|
||||
worker.process.terminate()
|
||||
worker.process.join(timeout=5)
|
||||
except Exception as exc:
|
||||
logger.warning(f"终止 OCC 工作进程异常: {exc}")
|
||||
try:
|
||||
worker.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -6,8 +6,6 @@ import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
@@ -15,19 +13,17 @@ from typing import Optional, Dict, Any, List, Tuple
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from moldinsight.core.stp_parser import STPParser
|
||||
from moldinsight.core.geometry_analyzer import GeometryAnalyzer
|
||||
from moldinsight.core.mesh_generator import MeshGenerator
|
||||
from moldinsight.core.multi_scheme_planner import MultiSchemeMoldPlanner
|
||||
from moldinsight.core.cad_exporter import CADExporter
|
||||
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
|
||||
from moldinsight.services.occ_process_pool import OccProcessPool
|
||||
from moldinsight.services.task_storage_service import TaskStorageService
|
||||
from moldinsight.services.analysis_storage_service import AnalysisStorageService
|
||||
from moldinsight.storage.rustfs_storage import rustfs_manager
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from moldinsight.services.material_service import MaterialService
|
||||
from moldinsight.services.calculation_service import CalculationService
|
||||
from moldinsight.services.llm_service import llm_service
|
||||
from shared.models.schemas import ProcessingStatus
|
||||
from shared.models.database import STPFile
|
||||
from moldinsight.models import STPFile
|
||||
from shared.database.database import db_manager
|
||||
from shared.utils.html_generator import HTMLGenerator
|
||||
from shared.utils.logger import get_logger
|
||||
@@ -39,41 +35,29 @@ class ProcessingService:
|
||||
"""核心处理流程编排 — 协调 STP 解析、网格、型腔、计算、保存、验证"""
|
||||
|
||||
def __init__(self):
|
||||
self.stp_parser = STPParser()
|
||||
self.geometry_analyzer = GeometryAnalyzer()
|
||||
self.mesh_generator = MeshGenerator(quality="medium")
|
||||
self.html_generator = HTMLGenerator()
|
||||
self.storage_service = StorageIntegrationService()
|
||||
self.multi_scheme_planner = MultiSchemeMoldPlanner()
|
||||
# 批次 3 按职责拆分:任务/文件生命周期 与 分析结果数据(原 StorageIntegrationService)
|
||||
self.task_storage = TaskStorageService()
|
||||
self.analysis_storage = AnalysisStorageService()
|
||||
# cad_exporter 仅用于导出文件条目构建/输出目录(export_artifacts 路径语义);
|
||||
# 真正的 OCC 形状导出在子进程内完成(见 core/occ_worker.py)
|
||||
self.cad_exporter = CADExporter()
|
||||
# TopoDS_Shape 为 C++ 原生内存对象,LRU 上限防止长期运行内存只涨不降
|
||||
self._export_shapes_cache: "OrderedDict[str, Dict[str, Dict[str, Any]]]" = OrderedDict()
|
||||
self._export_shapes_cache_max = 32
|
||||
# OCC 非线程安全,max_workers=1 保证所有 OCC 操作序列化执行,避免偶发崩溃
|
||||
self._occ_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="occ")
|
||||
# D11:HTMLGenerator 不再持有常驻实例,可视化产物统一写任务内临时目录后
|
||||
# 上传 RustFS 报告键(见 process_file_core)
|
||||
# OCC 方案 B:所有几何操作(解析/布尔/三角化/倒扣/转换)经常驻 OCC 进程池,
|
||||
# 进程边界干净回收超时/崩溃——替代原线程级单通道 executor(见 OCC_THROUGHPUT.md)
|
||||
self._occ_pool = OccProcessPool()
|
||||
|
||||
# ─── 对外入口 ───
|
||||
|
||||
def _reset_occ_executor(self):
|
||||
"""超时后重建 OCC executor。
|
||||
async def run_occ(self, op_name: str, payload: Dict[str, Any],
|
||||
timeout: float = 600) -> Any:
|
||||
"""在常驻 OCC 工作进程中执行同步几何操作(方案 B)。
|
||||
|
||||
asyncio.wait_for 只能取消协程,正在执行 OCC 布尔运算的线程无法中断;
|
||||
单 worker executor 中一个挂死线程会让后续任务永久排队直至重启。
|
||||
代价是泄漏 1 个线程,收益是恢复服务可用性。
|
||||
OCC 非线程安全,所有几何计算统一经本入口在独立进程中串行执行;
|
||||
超时/进程崩溃由进程池 terminate + 换新补位,干净回收(见 OCC_THROUGHPUT.md)。
|
||||
op_name 须已在 occ_worker._OPS 注册;payload 只含文件路径 + 普通字典。
|
||||
"""
|
||||
old = self._occ_executor
|
||||
self._occ_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="occ")
|
||||
old.shutdown(wait=False)
|
||||
logger.warning("OCC executor 已因处理超时重建(放弃等待旧线程,可能泄漏 1 个线程)")
|
||||
|
||||
async def run_occ(self, fn, *args):
|
||||
"""在 OCC 单线程 executor 中执行同步几何操作。
|
||||
|
||||
OCC 非线程安全,所有几何计算(解析/布尔/三角化)统一经由本入口串行执行,
|
||||
避免各调用方自行创建线程池造成并发崩溃。
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(self._occ_executor, fn, *args)
|
||||
return await self._occ_pool.run(op_name, payload, timeout=timeout)
|
||||
|
||||
async def _materialize_source_file(self, stp_file: STPFile) -> Tuple[Path, Optional[Path]]:
|
||||
"""把待处理文件落到本地磁盘,返回 (本地路径, 临时目录或 None)。
|
||||
@@ -158,8 +142,8 @@ class ProcessingService:
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"处理超时: {task_id}")
|
||||
# OCC 线程无法取消:抛弃整个 executor,避免挂死线程堵死后续所有任务
|
||||
self._reset_occ_executor()
|
||||
# OCC 进程无法取消:整体重建进程池,干净杀掉可能卡死的 OCC 操作
|
||||
await self._occ_pool.recover()
|
||||
raise Exception(f"处理超时,超过{timeout_seconds}秒未完成")
|
||||
|
||||
except Exception as e:
|
||||
@@ -169,8 +153,8 @@ class ProcessingService:
|
||||
# 避免 failed 更新把半成品 flush 数据一起带上
|
||||
await db_session.rollback()
|
||||
|
||||
await self.storage_service.update_stp_file_status(db_session, stp_file_id, "failed")
|
||||
await self.storage_service.update_task_status(
|
||||
await self.task_storage.update_stp_file_status(db_session, stp_file_id, "failed")
|
||||
await self.task_storage.update_task_status(
|
||||
db_session, task_id, "failed", error_message=str(e)
|
||||
)
|
||||
|
||||
@@ -203,33 +187,31 @@ class ProcessingService:
|
||||
stage_timings: Dict[str, float] = {}
|
||||
|
||||
# 1. 解析STP文件
|
||||
await self.storage_service.update_task_status(
|
||||
await self.task_storage.update_task_status(
|
||||
db_session, task_id, "processing", 20, "解析STP文件"
|
||||
)
|
||||
|
||||
stage_started = time.perf_counter()
|
||||
loop = asyncio.get_running_loop()
|
||||
shape = await loop.run_in_executor(
|
||||
self._occ_executor, self.stp_parser.load_step_file, Path(file_path)
|
||||
)
|
||||
geometry_data = await loop.run_in_executor(
|
||||
self._occ_executor, self.stp_parser.analyze_geometry, shape
|
||||
# 方案 B:解析 + 几何分析在 OCC 子进程内一步完成(形状不跨进程)
|
||||
geometry_data = await self.run_occ(
|
||||
"parse_stp", {"stp_path": file_path}, timeout=timeout_seconds
|
||||
)
|
||||
stage_timings["parse_stp"] = round(time.perf_counter() - stage_started, 3)
|
||||
|
||||
# 2. 生成网格数据并持久化
|
||||
await self.storage_service.update_task_status(
|
||||
await self.task_storage.update_task_status(
|
||||
db_session, task_id, "processing", 30, "生成网格数据"
|
||||
)
|
||||
|
||||
stage_started = time.perf_counter()
|
||||
mesh_result = await self._step_generate_mesh(
|
||||
shape, geometry_data, file_path, db_session, stp_file_id, task_id
|
||||
geometry_data, file_path, db_session, stp_file_id, task_id,
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
stage_timings["generate_mesh"] = round(time.perf_counter() - stage_started, 3)
|
||||
|
||||
# 3. 生成模具型腔
|
||||
await self.storage_service.update_task_status(
|
||||
await self.task_storage.update_task_status(
|
||||
db_session, task_id, "processing", 40, "生成模具型腔"
|
||||
)
|
||||
|
||||
@@ -240,24 +222,16 @@ class ProcessingService:
|
||||
is_foam_material = MaterialService.is_foam_material(requested_material)
|
||||
|
||||
stage_started = time.perf_counter()
|
||||
plan_result = await self._step_generate_cavity(
|
||||
shape, selected_material, is_foam_material, process_params
|
||||
plan_result, export_artifacts = await self._step_generate_cavity(
|
||||
file_path, selected_material, is_foam_material, process_params,
|
||||
task_id, timeout=timeout_seconds,
|
||||
)
|
||||
stage_timings["generate_cavity"] = round(time.perf_counter() - stage_started, 3)
|
||||
export_shapes = {}
|
||||
export_artifacts = None
|
||||
if plan_result:
|
||||
export_shapes = plan_result.pop("_export_shapes", {}) or {}
|
||||
if export_shapes:
|
||||
self._cache_export_shapes(task_id, export_shapes)
|
||||
export_artifacts = self._persist_step_exports(
|
||||
task_id=task_id,
|
||||
original_filename=Path(file_path).name,
|
||||
export_shapes=export_shapes,
|
||||
)
|
||||
# 方案 B:各方案形状的持久化 STEP 已由子进程导出并返回 manifest(export_artifacts),
|
||||
# 主进程不再持有 TopoDS 引用(无跨进程传输)
|
||||
|
||||
# 4. 生成详细JSON数据 — 委托 CalculationService
|
||||
await self.storage_service.update_task_status(
|
||||
await self.task_storage.update_task_status(
|
||||
db_session, task_id, "processing", 60, "生成型腔详细数据"
|
||||
)
|
||||
|
||||
@@ -284,12 +258,12 @@ class ProcessingService:
|
||||
cavity_key_info = best_key_info
|
||||
|
||||
# 6. 保存几何数据到数据库
|
||||
await self.storage_service.update_task_status(
|
||||
await self.task_storage.update_task_status(
|
||||
db_session, task_id, "processing", 70, "保存几何数据"
|
||||
)
|
||||
|
||||
stage_started = time.perf_counter()
|
||||
await self.storage_service.save_geometry_data(
|
||||
await self.analysis_storage.save_geometry_data(
|
||||
db_session,
|
||||
stp_file_id,
|
||||
geometry_data,
|
||||
@@ -301,7 +275,7 @@ class ProcessingService:
|
||||
await db_session.commit()
|
||||
|
||||
# 7. 生成HTML可视化
|
||||
await self.storage_service.update_task_status(
|
||||
await self.task_storage.update_task_status(
|
||||
db_session, task_id, "processing", 85, "生成可视化报告"
|
||||
)
|
||||
|
||||
@@ -324,12 +298,20 @@ class ProcessingService:
|
||||
lod_data = mesh_result
|
||||
logger.info(f"LOD数据复用成功: {len(lods)} 级 (面数: {[lods[k]['face_count'] for k in sorted(lods.keys())]})")
|
||||
|
||||
# D11:可视化产物先写任务内临时目录,再统一上传 RustFS 报告键
|
||||
# (html/reports/{filename}),不再落节点本地 html_output——
|
||||
# API 与 worker 容器文件系统不互通,本地盘从来不是可依赖的读取来源
|
||||
html_out_dir = Path(tempfile.mkdtemp(prefix="moldinsight_html_"))
|
||||
try:
|
||||
html_generator = HTMLGenerator(output_dir=str(html_out_dir))
|
||||
|
||||
detailed_cavity_json = await self._attach_scheme_previews(
|
||||
detailed_cavity_json=detailed_cavity_json,
|
||||
geometry_data=geometry_data,
|
||||
stp_filename=Path(file_path).name,
|
||||
pointcloud_data=pointcloud_data,
|
||||
lod_data=lod_data,
|
||||
html_generator=html_generator,
|
||||
)
|
||||
|
||||
best_scheme = CalculationService.get_best_scheme(detailed_cavity_json)
|
||||
@@ -337,40 +319,43 @@ class ProcessingService:
|
||||
best_key_info = best_scheme.get("key_info", {}) if best_scheme else best_key_info
|
||||
|
||||
# 8. 保存模具型腔数据(包含方案级预览链接)
|
||||
await self.storage_service.save_mold_cavity_data(
|
||||
await self.analysis_storage.save_mold_cavity_data(
|
||||
db_session, stp_file_id, detailed_cavity_json
|
||||
)
|
||||
|
||||
html_file_path = self.html_generator.generate_and_save_visualization(
|
||||
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.storage_service.save_html_file(
|
||||
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)
|
||||
|
||||
# 9. 分析模具设计
|
||||
stage_started = time.perf_counter()
|
||||
loop = asyncio.get_running_loop()
|
||||
analysis_result = await loop.run_in_executor(
|
||||
self._occ_executor,
|
||||
lambda: self.geometry_analyzer.analyze_mold_design(
|
||||
geometry_data,
|
||||
product_material=requested_material,
|
||||
shape=shape,
|
||||
),
|
||||
analysis_result = await self.run_occ(
|
||||
"analyze_mold_design",
|
||||
{
|
||||
"geometry_data": geometry_data,
|
||||
"product_material": requested_material,
|
||||
"stp_path": file_path,
|
||||
},
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
|
||||
if analysis_result:
|
||||
await self.storage_service.save_features_and_recommendations(
|
||||
await self.analysis_storage.save_features_and_recommendations(
|
||||
db_session,
|
||||
stp_file_id,
|
||||
analysis_result.get("detected_features", []),
|
||||
@@ -381,7 +366,7 @@ class ProcessingService:
|
||||
stage_timings["analyze_design"] = round(time.perf_counter() - stage_started, 3)
|
||||
|
||||
# 9.6 更新STP文件的分析摘要字段
|
||||
await self.storage_service.update_stp_file_analysis_summary(
|
||||
await self.task_storage.update_stp_file_analysis_summary(
|
||||
db_session,
|
||||
stp_file_id,
|
||||
volume=geometry_data.get("volume", 0),
|
||||
@@ -419,7 +404,7 @@ class ProcessingService:
|
||||
stage_timings["generate_llm_report"] = round(time.perf_counter() - stage_started, 3)
|
||||
|
||||
# 10. 完成处理——先 flush 任务参数,完成状态提交时一并原子落库(D9)
|
||||
await self.storage_service.update_task_parameters(
|
||||
await self.task_storage.update_task_parameters(
|
||||
db_session,
|
||||
task_id,
|
||||
{
|
||||
@@ -431,8 +416,8 @@ class ProcessingService:
|
||||
**process_params,
|
||||
},
|
||||
)
|
||||
await self.storage_service.update_stp_file_status(db_session, stp_file_id, "completed")
|
||||
await self.storage_service.update_task_status(
|
||||
await self.task_storage.update_stp_file_status(db_session, stp_file_id, "completed")
|
||||
await self.task_storage.update_task_status(
|
||||
db_session, task_id, "completed", 100, "模具型腔生成完成"
|
||||
)
|
||||
|
||||
@@ -464,8 +449,8 @@ class ProcessingService:
|
||||
# D9:先丢弃未提交的数据本体再置失败(同外层说明)
|
||||
await db_session.rollback()
|
||||
|
||||
await self.storage_service.update_stp_file_status(db_session, stp_file_id, "failed")
|
||||
await self.storage_service.update_task_status(
|
||||
await self.task_storage.update_stp_file_status(db_session, stp_file_id, "failed")
|
||||
await self.task_storage.update_task_status(
|
||||
db_session, task_id, "failed", error_message=str(e)
|
||||
)
|
||||
|
||||
@@ -480,15 +465,15 @@ class ProcessingService:
|
||||
# ─── 内部步骤 ───
|
||||
|
||||
async def _step_generate_mesh(
|
||||
self, shape, geometry_data: dict, file_path: str,
|
||||
self, geometry_data: dict, file_path: str,
|
||||
db_session: AsyncSession, stp_file_id: int, task_id: str,
|
||||
timeout: float = 600,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""生成多级LOD网格并持久化,一次OCC剖分+trimesh简化,失败不影响主流程"""
|
||||
"""生成多级LOD网格并持久化(方案 B:子进程内一次 OCC 剖分),失败不影响主流程"""
|
||||
mesh_result = None
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
mesh_result = await loop.run_in_executor(
|
||||
self._occ_executor, self.mesh_generator.generate_multi_lod_mesh, shape
|
||||
mesh_result = await self.run_occ(
|
||||
"generate_mesh", {"stp_path": file_path}, timeout=timeout
|
||||
)
|
||||
|
||||
lod0 = mesh_result.get("lods", {}).get("0", {})
|
||||
@@ -524,7 +509,7 @@ class ProcessingService:
|
||||
"bounding_box": bbox,
|
||||
}
|
||||
|
||||
await self.storage_service.save_mesh_data(
|
||||
await self.analysis_storage.save_mesh_data(
|
||||
db_session,
|
||||
stp_file_id=stp_file_id,
|
||||
mesh_json=mesh_json,
|
||||
@@ -544,97 +529,31 @@ class ProcessingService:
|
||||
return mesh_result
|
||||
|
||||
async def _step_generate_cavity(
|
||||
self, shape, selected_material: dict, is_foam_material: bool, process_params: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""生成多方案分模结果。
|
||||
self, file_path: str, selected_material: dict, is_foam_material: bool,
|
||||
process_params: Dict[str, Any], task_id: str, timeout: float = 600,
|
||||
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
|
||||
"""生成多方案分模结果(方案 B:子进程内完成分模 + 方案形状 STEP 导出)。
|
||||
|
||||
D8:型腔是任务的核心产出,生成失败必须让任务 failed——
|
||||
此前异常在此被吞掉置 plan_result=None 继续主流程,最终任务
|
||||
completed,"完成"状态不可信。异常直接向编排层传播。
|
||||
异常直接向编排层传播。返回 (plan_result, export_manifest)。
|
||||
"""
|
||||
if not shape:
|
||||
raise RuntimeError("无有效几何 shape,无法生成模具型腔")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
plan_result = await loop.run_in_executor(
|
||||
self._occ_executor,
|
||||
lambda: self.multi_scheme_planner.generate_plan(
|
||||
shape=shape,
|
||||
material=selected_material,
|
||||
is_foam_material=is_foam_material,
|
||||
process_params=process_params,
|
||||
),
|
||||
result = await self.run_occ(
|
||||
"generate_cavity",
|
||||
{
|
||||
"stp_path": file_path,
|
||||
"task_id": task_id,
|
||||
"material": selected_material,
|
||||
"is_foam_material": is_foam_material,
|
||||
"process_params": process_params,
|
||||
"export_out_dir": os.path.abspath(self.cad_exporter.output_dir),
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
plan_result = result["plan_result"]
|
||||
logger.info(
|
||||
f"多方案分模完成: 生成 {len(plan_result.get('candidate_schemes', []))} 套方案"
|
||||
)
|
||||
return plan_result
|
||||
|
||||
def _cache_export_shapes(self, task_id: str, export_shapes: Dict[str, Dict[str, Any]]):
|
||||
self._export_shapes_cache[task_id] = export_shapes
|
||||
self._export_shapes_cache.move_to_end(task_id)
|
||||
# 逐出最旧任务的形状缓存(连原生 OCC shape 引用一起释放)
|
||||
while len(self._export_shapes_cache) > self._export_shapes_cache_max:
|
||||
self._export_shapes_cache.popitem(last=False)
|
||||
|
||||
def _persist_step_exports(
|
||||
self,
|
||||
task_id: str,
|
||||
original_filename: str,
|
||||
export_shapes: Dict[str, Dict[str, Any]],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not export_shapes:
|
||||
return None
|
||||
|
||||
base_filename = Path(original_filename).stem or f"mold_{task_id}"
|
||||
manifest = {
|
||||
"version": 1,
|
||||
"task_id": task_id,
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"schemes": {},
|
||||
}
|
||||
components = ["cavity", "core", "parting_surface", "product", "a_plate", "b_plate"]
|
||||
|
||||
for scheme_id, cavity_data in export_shapes.items():
|
||||
try:
|
||||
# 持久化装配体 STEP + 逐组件 STEP(后者是重启后按需
|
||||
# 重导出其他格式的几何来源,见 regenerate_export_from_persisted)
|
||||
result = self.cad_exporter.export_persisted_steps(
|
||||
cavity_data=cavity_data,
|
||||
base_filename=base_filename,
|
||||
components=components,
|
||||
task_id=task_id,
|
||||
scheme_id=scheme_id,
|
||||
)
|
||||
manifest["schemes"][scheme_id] = {
|
||||
"base_filename": result.get("base_filename"),
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"files": result.get("files", []),
|
||||
"errors": result.get("errors", []),
|
||||
"total_files": result.get("total_files", 0),
|
||||
"total_errors": result.get("total_errors", 0),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("持久化 STEP 导出失败: task=%s scheme=%s error=%s", task_id, scheme_id, exc)
|
||||
manifest["schemes"][scheme_id] = {
|
||||
"base_filename": base_filename,
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"files": [],
|
||||
"errors": [str(exc)],
|
||||
"total_files": 0,
|
||||
"total_errors": 1,
|
||||
}
|
||||
|
||||
return manifest
|
||||
|
||||
def get_export_shapes(self, task_id: str, scheme_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
scheme_map = self._export_shapes_cache.get(task_id)
|
||||
if not scheme_map:
|
||||
return None
|
||||
self._export_shapes_cache.move_to_end(task_id) # LRU 热点保活
|
||||
if scheme_id:
|
||||
return scheme_map.get(scheme_id)
|
||||
return next(iter(scheme_map.values()), None)
|
||||
return plan_result, result["export_manifest"]
|
||||
|
||||
async def regenerate_export_from_persisted(
|
||||
self,
|
||||
@@ -645,11 +564,12 @@ class ProcessingService:
|
||||
base_filename: str,
|
||||
scheme_files: List[Dict[str, Any]],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""内存导出缓存失效(如服务重启)后,从持久化 STEP 重建导出文件。
|
||||
"""持久化 STEP 缓存失效(如服务重启)后,从持久化 STEP 重建导出文件。
|
||||
|
||||
分析期已为每个方案持久化装配体 + 逐组件 STEP;
|
||||
缺失格式(STL/IGES/BRep)读回单组件 STEP 现场转换,
|
||||
用户无需重新分析。所有组件均不可用时返回 None。
|
||||
方案 B 后主进程不再持有 TopoDS 形状(导出持久化发生在子进程),
|
||||
内存缓存路径已随旧 _cache_export_shapes/get_export_shapes 一并删除;
|
||||
本方法读回单组件 STEP 现场转换缺失格式,用户无需重新分析。
|
||||
所有组件均不可用时返回 None。
|
||||
"""
|
||||
format_list = list(dict.fromkeys(formats or ["step", "stl"]))
|
||||
step_files = {
|
||||
@@ -687,7 +607,8 @@ class ProcessingService:
|
||||
os.path.dirname(step_path), f"{base_filename}_{comp}.{fmt}"
|
||||
)
|
||||
ok = await self.run_occ(
|
||||
self.cad_exporter.convert_component_step, step_path, out_path, fmt
|
||||
"convert_component_step",
|
||||
{"step_path": str(step_path), "out_path": str(out_path), "fmt": fmt},
|
||||
)
|
||||
if ok:
|
||||
files.append(
|
||||
@@ -732,7 +653,7 @@ class ProcessingService:
|
||||
logger.info("FreeCAD验证已禁用(设置 ENABLE_FREECAD_VERIFICATION=true 启用)")
|
||||
return {"status": "disabled", "reason": "FreeCAD验证已禁用"}
|
||||
|
||||
await self.storage_service.update_task_status(
|
||||
await self.task_storage.update_task_status(
|
||||
db_session, task_id, "processing", 90, "FreeCAD几何验证"
|
||||
)
|
||||
|
||||
@@ -757,8 +678,13 @@ class ProcessingService:
|
||||
stp_filename: str,
|
||||
pointcloud_data: Optional[Dict[str, Any]] = None,
|
||||
lod_data: Optional[Dict[str, Any]] = None,
|
||||
html_generator: HTMLGenerator = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""为候选分模方案生成轻量摘要链接(完整HTML仅最优方案按需生成)"""
|
||||
if html_generator is None:
|
||||
raise ValueError(
|
||||
"html_generator 不能为空(D11:摘要产物统一经任务临时目录上传 RustFS)"
|
||||
)
|
||||
candidate_schemes = detailed_cavity_json.get("candidate_schemes", [])
|
||||
if not candidate_schemes:
|
||||
return detailed_cavity_json
|
||||
@@ -771,10 +697,15 @@ class ProcessingService:
|
||||
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 = self.html_generator.generate_3d_viewer_summary(
|
||||
summary_content = html_generator.generate_3d_viewer_summary(
|
||||
geometry_data, cavity_data
|
||||
)
|
||||
self.html_generator.save_data_file(summary_content, summary_name)
|
||||
# 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}"
|
||||
|
||||
best_scheme = CalculationService.get_best_scheme(detailed_cavity_json)
|
||||
@@ -783,11 +714,31 @@ class ProcessingService:
|
||||
|
||||
return detailed_cavity_json
|
||||
|
||||
async def _upload_report_artifacts(self, html_file_path: Path):
|
||||
"""D11:任务临时目录中的可视化产物(.html / _summary.json / _data.json)
|
||||
上传 RustFS 报告键(html/reports/{filename})。
|
||||
|
||||
HTML 内嵌相对 DATA_URL/SUMMARY_URL 引用两个 JSON,
|
||||
三者必须同键前缀可达(读侧 /html/{filename} 直取)。
|
||||
"""
|
||||
stem = html_file_path.with_suffix("")
|
||||
artifacts = [
|
||||
(html_file_path, "text/html; charset=utf-8"),
|
||||
(stem.with_name(stem.name + "_summary.json"), "application/json"),
|
||||
(stem.with_name(stem.name + "_data.json"), "application/json"),
|
||||
]
|
||||
for path, content_type in artifacts:
|
||||
if not path.exists():
|
||||
raise RuntimeError(f"可视化产物缺失: {path.name}")
|
||||
await rustfs_manager.upload_report_artifact(
|
||||
path.name, path.read_bytes(), content_type=content_type
|
||||
)
|
||||
|
||||
# ─── 指标持久化 ───
|
||||
|
||||
async def _save_analysis_metrics(self, session: AsyncSession, stp_file_id: int, analysis_result: dict):
|
||||
"""保存分析指标到数据库"""
|
||||
from shared.models.database import AnalysisMetrics
|
||||
from moldinsight.models import AnalysisMetrics
|
||||
|
||||
quality_metrics = analysis_result.get("quality_metrics", {})
|
||||
analysis_summary = analysis_result.get("analysis_summary", "")
|
||||
@@ -807,7 +758,7 @@ class ProcessingService:
|
||||
|
||||
async def _save_verification_metrics(self, session: AsyncSession, stp_file_id: int, verification_result: dict):
|
||||
"""保存验证指标到数据库"""
|
||||
from shared.models.database import AnalysisMetrics
|
||||
from moldinsight.models import AnalysisMetrics
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
# services/shape_loader.py
|
||||
"""按 task_id 从持久化存储重建 OCC 几何形状。
|
||||
|
||||
任务完成后 TopoDS_Shape 不驻留内存/Redis(原生内存与体积原因),
|
||||
需要几何的端点(倒扣检测、按需重导出等)通过 STP 原件重建:
|
||||
PG(object_key) -> RustFS 下载 -> 临时文件 -> OCC 单线程 executor 解析。
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.models.database import ProcessingTask, STPFile
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ShapeLoader:
|
||||
"""任务几何重建器"""
|
||||
|
||||
def __init__(self):
|
||||
from moldinsight.core.stp_parser import STPParser
|
||||
from moldinsight.services.processing_service import processing_service
|
||||
|
||||
self._parser = STPParser()
|
||||
self._processing = processing_service
|
||||
|
||||
async def load_shape_for_task(
|
||||
self, db_session: AsyncSession, task_id: str
|
||||
) -> Optional["object"]:
|
||||
"""重建任务的产品几何。任务不存在或 STP 原件不可用时返回 None。"""
|
||||
result = await db_session.execute(
|
||||
select(ProcessingTask, STPFile)
|
||||
.join(STPFile, ProcessingTask.stp_file_id == STPFile.id)
|
||||
.where(ProcessingTask.task_id == task_id)
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
logger.warning(f"几何重建失败:任务不存在 {task_id}")
|
||||
return None
|
||||
|
||||
_, stp_file = row
|
||||
if not stp_file.object_key:
|
||||
logger.warning(f"几何重建失败:任务缺少 object_key {task_id}")
|
||||
return None
|
||||
|
||||
from moldinsight.storage.rustfs_storage import rustfs_manager
|
||||
|
||||
try:
|
||||
data = await rustfs_manager.download_file(
|
||||
file_type="stp_files", object_key=stp_file.object_key
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"几何重建失败:STP 原件下载失败 {task_id}: {exc}")
|
||||
return None
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".stp", delete=False) as tmp:
|
||||
tmp.write(data)
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
try:
|
||||
shape = await self._processing.run_occ(
|
||||
self._parser.load_step_file, tmp_path
|
||||
)
|
||||
return shape
|
||||
except Exception as exc:
|
||||
logger.error(f"几何重建失败:STP 解析失败 {task_id}: {exc}")
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# 惰性单例:__init__ 会实例化 STPParser 并校验 OCC 可用性,
|
||||
# 延迟到首次真实使用,避免模块导入期失败拖垮路由加载
|
||||
_shape_loader: Optional[ShapeLoader] = None
|
||||
|
||||
|
||||
def get_shape_loader() -> ShapeLoader:
|
||||
global _shape_loader
|
||||
if _shape_loader is None:
|
||||
_shape_loader = ShapeLoader()
|
||||
return _shape_loader
|
||||
@@ -0,0 +1,76 @@
|
||||
# services/stp_materializer.py
|
||||
"""按 task_id 把 STP 原件从持久化存储落盘为临时文件(方案 B)。
|
||||
|
||||
任务完成后 TopoDS_Shape 不驻留内存/Redis;需要几何的端点(倒扣检测、
|
||||
按需重导出等)通过 STP 原件重建:PG(object_key) -> RustFS 下载 -> 临时文件,
|
||||
OCC 解析在常驻子进程内完成(occ_worker 的 detect_undercuts 等操作,见
|
||||
occ_process_pool)。本模块只负责把原件落到调用方可传路径的临时文件。
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from moldinsight.models import ProcessingTask, STPFile
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class STPMaterializer:
|
||||
"""任务 STP 原件落盘器"""
|
||||
|
||||
async def materialize_stp_for_task(
|
||||
self, db_session: AsyncSession, task_id: str
|
||||
) -> Optional[Path]:
|
||||
"""重建任务的产品几何原件为临时文件。
|
||||
|
||||
任务不存在或 STP 原件不可用时返回 None;返回的临时文件由调用方
|
||||
finally 清理(unlink)。
|
||||
"""
|
||||
result = await db_session.execute(
|
||||
select(ProcessingTask, STPFile)
|
||||
.join(STPFile, ProcessingTask.stp_file_id == STPFile.id)
|
||||
.where(ProcessingTask.task_id == task_id)
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
logger.warning(f"STP 原件落盘失败:任务不存在 {task_id}")
|
||||
return None
|
||||
|
||||
_, stp_file = row
|
||||
if not stp_file.object_key:
|
||||
logger.warning(f"STP 原件落盘失败:任务缺少 object_key {task_id}")
|
||||
return None
|
||||
|
||||
from moldinsight.storage.rustfs_storage import rustfs_manager
|
||||
|
||||
try:
|
||||
data = await rustfs_manager.download_file(
|
||||
file_type="stp_files", object_key=stp_file.object_key
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"STP 原件落盘失败:下载失败 {task_id}: {exc}")
|
||||
return None
|
||||
|
||||
original_name = Path(stp_file.original_filename or "model.stp").name or "model.stp"
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
suffix=Path(original_name).suffix or ".stp", prefix=f"mold_{task_id}_", delete=False
|
||||
)
|
||||
tmp.write(data)
|
||||
tmp.close()
|
||||
logger.debug(f"STP 原件已落盘: {tmp.name}")
|
||||
return Path(tmp.name)
|
||||
|
||||
|
||||
# 惰性单例
|
||||
_stp_materializer: Optional[STPMaterializer] = None
|
||||
|
||||
|
||||
def get_stp_materializer() -> STPMaterializer:
|
||||
global _stp_materializer
|
||||
if _stp_materializer is None:
|
||||
_stp_materializer = STPMaterializer()
|
||||
return _stp_materializer
|
||||
@@ -10,9 +10,9 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
|
||||
from moldinsight.services.analysis_storage_service import AnalysisStorageService
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from shared.models.database import ProcessingTask, STPFile, MeshData, HTMLFile
|
||||
from moldinsight.models import ProcessingTask, STPFile, MeshData, HTMLFile
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -103,7 +103,7 @@ class TaskQueryService:
|
||||
return cached
|
||||
|
||||
# 3. 持久化任务(已完成/失败,或服务重启后的任务)
|
||||
storage_service = StorageIntegrationService()
|
||||
storage_service = AnalysisStorageService()
|
||||
|
||||
# 查询任务和文件元数据(预加载 html_file 关联)
|
||||
result = await db_session.execute(
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# services/task_storage_service.py
|
||||
"""任务与源文件生命周期存储——PostgreSQL(+ 源文件 RustFS 上传)。
|
||||
|
||||
批次 3 自 storage_integration_rustfs.py 按职责拆分(原 867 行混杂
|
||||
写入/查询/历史三类职责):
|
||||
- 本模块:STPFile 生命周期 + ProcessingTask 创建/状态/参数
|
||||
- 分析结果数据:analysis_storage_service.AnalysisStorageService
|
||||
- 历史查询视图:file_history_service.FileHistoryService
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import update, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from moldinsight.models import STPFile, ProcessingTask
|
||||
from moldinsight.storage.rustfs_storage import rustfs_manager
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class TaskStorageService:
|
||||
"""STP 文件与处理任务的生命周期存储"""
|
||||
|
||||
async def save_stp_file(self, session: AsyncSession,
|
||||
file_path: Path,
|
||||
original_filename: str,
|
||||
user_id: Optional[int] = None,
|
||||
upload_batch: Optional[str] = None) -> STPFile:
|
||||
"""保存STP文件到PostgreSQL元数据 + RustFS对象存储
|
||||
|
||||
支持同一文件多次上传,每次上传都会创建新记录
|
||||
"""
|
||||
|
||||
# 1. 上传到RustFS
|
||||
upload_result = await rustfs_manager.upload_file(
|
||||
file_type='stp_files',
|
||||
file_path=file_path,
|
||||
original_filename=original_filename,
|
||||
metadata={
|
||||
'original_filename': original_filename,
|
||||
'user_id': str(user_id) if user_id else 'anonymous',
|
||||
'upload_batch': upload_batch or str(uuid.uuid4())
|
||||
}
|
||||
)
|
||||
|
||||
file_hash = upload_result['file_hash']
|
||||
batch_id = upload_batch or str(uuid.uuid4())
|
||||
|
||||
# 2. 创建新PostgreSQL记录(每次上传都创建新记录)
|
||||
stp_file = STPFile(
|
||||
user_id=user_id,
|
||||
object_key=upload_result['object_key'],
|
||||
storage_bucket=upload_result['bucket'],
|
||||
original_filename=original_filename,
|
||||
file_size=upload_result['file_size'],
|
||||
file_hash=file_hash,
|
||||
upload_batch=batch_id,
|
||||
status="uploaded",
|
||||
file_path=str(file_path),
|
||||
upload_time=datetime.now()
|
||||
)
|
||||
|
||||
session.add(stp_file)
|
||||
# D9:仅 flush,与 ProcessingTask 由路由层一并原子提交(避免孤儿文件记录)
|
||||
await session.flush()
|
||||
await session.refresh(stp_file)
|
||||
|
||||
logger.info(f"STP文件保存成功 RustFS: {stp_file.id}, 批次: {batch_id}")
|
||||
return stp_file
|
||||
|
||||
async def create_processing_task(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
task_id: str,
|
||||
stp_file_id: int,
|
||||
task_type: str = "stp_parsing",
|
||||
parameters: Optional[Dict[str, Any]] = None,
|
||||
batch_id: Optional[str] = None,
|
||||
) -> ProcessingTask:
|
||||
"""创建处理任务记录(D9:仅 flush 不 commit,事务由调用方收口——
|
||||
与 STPFile 记录同批提交,避免留下无任务的孤儿文件记录;batch_id 用于批量任务聚合查询)
|
||||
"""
|
||||
try:
|
||||
task = ProcessingTask(
|
||||
task_id=task_id,
|
||||
stp_file_id=stp_file_id,
|
||||
task_type=task_type,
|
||||
status="pending",
|
||||
started_time=datetime.now(),
|
||||
parameters=parameters or {},
|
||||
batch_id=batch_id,
|
||||
)
|
||||
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
|
||||
logger.info(f"处理任务创建成功: {task_id}")
|
||||
return task
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"创建处理任务失败: {e}")
|
||||
raise
|
||||
|
||||
async def update_task_status(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
task_id: str,
|
||||
status: str,
|
||||
progress: Optional[int] = None,
|
||||
current_step: Optional[str] = None,
|
||||
error_message: Optional[str] = None
|
||||
):
|
||||
"""更新任务状态(保留即时 commit:进度/状态需跨事务对外可见,
|
||||
处理链路中的各阶段进度依赖它落库——D9 收口仅针对数据本体写方法)"""
|
||||
try:
|
||||
update_data = {
|
||||
"status": status,
|
||||
"completed_time": datetime.now() if status in ["completed", "failed"] else None,
|
||||
"error_message": error_message
|
||||
}
|
||||
|
||||
if progress is not None:
|
||||
update_data["progress"] = progress
|
||||
if current_step is not None:
|
||||
update_data["current_step"] = current_step
|
||||
|
||||
await session.execute(
|
||||
update(ProcessingTask)
|
||||
.where(ProcessingTask.task_id == task_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"任务状态更新: {task_id} -> {status}")
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"更新任务状态失败: {e}")
|
||||
raise
|
||||
|
||||
async def update_task_parameters(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
task_id: str,
|
||||
parameters: Dict[str, Any],
|
||||
):
|
||||
"""合并更新任务参数,便于保存阶段耗时等元数据。(D9:flush 不 commit,事务由调用方收口)"""
|
||||
try:
|
||||
task = await session.execute(
|
||||
select(ProcessingTask).where(ProcessingTask.task_id == task_id)
|
||||
)
|
||||
task = task.scalar_one_or_none()
|
||||
if task is None:
|
||||
return
|
||||
|
||||
merged = dict(task.parameters or {})
|
||||
merged.update(parameters or {})
|
||||
task.parameters = merged
|
||||
await session.flush()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"更新任务参数失败: {e}")
|
||||
raise
|
||||
|
||||
async def update_stp_file_status(self, session: AsyncSession, stp_file_id: int, status: str):
|
||||
"""更新STP文件状态(保留即时 commit,理由同 update_task_status)"""
|
||||
try:
|
||||
await session.execute(
|
||||
update(STPFile)
|
||||
.where(STPFile.id == stp_file_id)
|
||||
.values(
|
||||
status=status,
|
||||
processed_time=datetime.now() if status in ["completed", "failed"] else None
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"STP文件状态更新: ID {stp_file_id} -> {status}")
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"更新STP文件状态失败: {e}")
|
||||
raise
|
||||
|
||||
async def update_stp_file_analysis_summary(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
stp_file_id: int,
|
||||
volume: Optional[float] = None,
|
||||
surface_area: Optional[float] = None,
|
||||
product_weight: Optional[float] = None
|
||||
):
|
||||
"""更新STP文件的分析摘要字段(用于快速查询)"""
|
||||
try:
|
||||
update_data = {}
|
||||
if volume is not None:
|
||||
update_data['volume'] = volume
|
||||
if surface_area is not None:
|
||||
update_data['surface_area'] = surface_area
|
||||
if product_weight is not None:
|
||||
update_data['product_weight'] = product_weight
|
||||
|
||||
if update_data:
|
||||
await session.execute(
|
||||
update(STPFile)
|
||||
.where(STPFile.id == stp_file_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
# D9:flush 不 commit,随结果包由编排层统一提交
|
||||
await session.flush()
|
||||
logger.info(f"STP文件分析摘要更新: ID {stp_file_id}")
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"更新STP文件分析摘要失败: {e}")
|
||||
raise
|
||||
@@ -33,6 +33,8 @@ class RustFSManager:
|
||||
'html_files': 'html',
|
||||
'user_files': 'user-files'
|
||||
}
|
||||
# 可视化报告产物固定前缀(D11:文件名寻址,读侧 /html/{filename} 代理按键直取)
|
||||
self.report_prefix = "html/reports"
|
||||
|
||||
async def connect(self, endpoint: str, access_key: str, secret_key: str, timeout: int = 30):
|
||||
"""连接到 RustFS 服务"""
|
||||
@@ -223,6 +225,71 @@ class RustFSManager:
|
||||
logger.error(f"RustFS JSON上传失败: {e}")
|
||||
raise
|
||||
|
||||
async def upload_report_artifact(self, filename: str, data: bytes,
|
||||
content_type: str = "application/octet-stream") -> str:
|
||||
"""可视化报告产物上传(D11:RustFS 为报告唯一持久来源)。
|
||||
|
||||
固定键 html/reports/{filename}——文件名寻址(非生成键),
|
||||
读侧 /html/{filename} 代理按键直取,无需元数据表参与。
|
||||
filename 由调用方保证全局唯一(命名含源文件 stem + 时间戳);
|
||||
同源同秒重复分析覆盖旧对象(重新分析语义即报告刷新)。
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("RustFS 未连接")
|
||||
|
||||
object_key = self._report_object_key(filename)
|
||||
|
||||
def _upload():
|
||||
return self.client.put_object(
|
||||
self.bucket_name,
|
||||
object_key,
|
||||
BytesIO(data),
|
||||
length=len(data),
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_upload)
|
||||
logger.info(f"报告产物上传成功 RustFS: {self.bucket_name}/{object_key} ({len(data)} bytes)")
|
||||
return object_key
|
||||
except S3Error as e:
|
||||
logger.error(f"RustFS 报告产物上传失败: {e}")
|
||||
raise
|
||||
|
||||
async def download_report_artifact(self, filename: str) -> bytes:
|
||||
"""按文件名读取报告产物;对象不存在/未连接时抛异常,由调用方决定回退。"""
|
||||
import asyncio
|
||||
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("RustFS 未连接")
|
||||
|
||||
object_key = self._report_object_key(filename)
|
||||
|
||||
def _download():
|
||||
resp = self.client.get_object(self.bucket_name, object_key)
|
||||
try:
|
||||
return resp.read()
|
||||
finally:
|
||||
resp.close()
|
||||
resp.release_conn()
|
||||
|
||||
try:
|
||||
data = await asyncio.to_thread(_download)
|
||||
logger.debug(f"报告产物下载成功: {self.bucket_name}/{object_key}")
|
||||
return data
|
||||
except S3Error as e:
|
||||
logger.error(f"RustFS 报告产物下载失败: {e}")
|
||||
raise
|
||||
|
||||
def _report_object_key(self, filename: str) -> str:
|
||||
"""报告产物键构造:防路径穿越,键必须落在 html/reports/ 一级之下。"""
|
||||
safe_name = Path(filename).name
|
||||
if safe_name != filename or filename.startswith("."):
|
||||
raise ValueError(f"非法报告文件名: {filename!r}")
|
||||
return f"{self.report_prefix}/{safe_name}"
|
||||
|
||||
async def download_file(self, file_type: str, object_key: str) -> bytes:
|
||||
"""从 RustFS 下载文件"""
|
||||
import asyncio
|
||||
|
||||
@@ -9,7 +9,7 @@ sys.path.insert(0, str(src_root))
|
||||
|
||||
from sqlalchemy import select
|
||||
from shared.database.database import db_manager
|
||||
from shared.models.database import User, Role, UserRole
|
||||
from shared.models.identity import User, Role, UserRole
|
||||
from shared.services.auth_service import get_password_hash
|
||||
from shared.config.settings import settings
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
@@ -27,7 +27,7 @@ def create_app(
|
||||
title: str,
|
||||
service_name: str,
|
||||
version: str = "4.0.0",
|
||||
mount_html: bool = False,
|
||||
connect_rustfs: bool = False,
|
||||
serve_frontend_static: bool = False,
|
||||
startup_hooks: Optional[List[Callable[[], Awaitable[None]]]] = None,
|
||||
register_routers: Optional[Callable[[FastAPI], None]] = None,
|
||||
@@ -38,7 +38,9 @@ def create_app(
|
||||
title: 应用标题
|
||||
service_name: 服务名(用于 /health 响应)
|
||||
version: 版本号
|
||||
mount_html: 是否挂载 /html 静态目录(moldinsight 需要)
|
||||
connect_rustfs: 是否连接 RustFS 对象存储(moldinsight 需要)。
|
||||
D11:/html 报告读取由 moldinsight.api.html_report_router 代理
|
||||
(RustFS 报告键直取 + 遗留对象/本地卷兜底),不再挂本地 StaticFiles。
|
||||
serve_frontend_static: 是否由后端托管 /static 与 SPA fallback(默认关闭,前端独立部署)
|
||||
startup_hooks: 额外的 startup 钩子列表(在数据库/RustFS/Redis 初始化后执行)
|
||||
register_routers: 回调函数,用于注册业务路由
|
||||
@@ -96,8 +98,6 @@ def create_app(
|
||||
Path("uploads").mkdir(exist_ok=True)
|
||||
if serve_frontend_static:
|
||||
Path("static").mkdir(exist_ok=True)
|
||||
if mount_html:
|
||||
Path("html_output").mkdir(exist_ok=True)
|
||||
|
||||
# ── 静态文件挂载 ─────────────────────────────────────────────
|
||||
if serve_frontend_static:
|
||||
@@ -106,12 +106,6 @@ def create_app(
|
||||
StaticFiles(directory=os.path.join(os.getcwd(), "static")),
|
||||
name="static",
|
||||
)
|
||||
if mount_html:
|
||||
app.mount(
|
||||
"/html",
|
||||
StaticFiles(directory=os.path.join(os.getcwd(), "html_output")),
|
||||
name="html",
|
||||
)
|
||||
|
||||
# ── Startup ──────────────────────────────────────────────────
|
||||
@app.on_event("startup")
|
||||
@@ -122,7 +116,7 @@ def create_app(
|
||||
print(f"[{'OK' if success else 'FAIL'}] 数据库初始化")
|
||||
|
||||
# RustFS(仅 moldinsight 需要)
|
||||
if mount_html:
|
||||
if connect_rustfs:
|
||||
try:
|
||||
from moldinsight.storage.rustfs_storage import rustfs_manager
|
||||
await rustfs_manager.connect(
|
||||
|
||||
@@ -101,6 +101,13 @@ class Settings:
|
||||
def allowed_extensions_set(self) -> set:
|
||||
return set(ext.strip() for ext in self.ALLOWED_EXTENSIONS.split(","))
|
||||
|
||||
@property
|
||||
def redis_url(self) -> str:
|
||||
"""Redis 连接串(Celery broker/backend 使用;RedisTaskManager 走分参数连接,不经此处)"""
|
||||
if self.REDIS_PASSWORD:
|
||||
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
||||
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
||||
|
||||
@staticmethod
|
||||
def _parse_cors_origins(raw: str) -> List[str]:
|
||||
"""解析 CORS_ORIGINS 环境变量,逗号分隔。
|
||||
|
||||
@@ -116,18 +116,6 @@ class DatabaseManager:
|
||||
|
||||
return self.async_session()
|
||||
|
||||
async def create_tables(self):
|
||||
"""创建数据库表"""
|
||||
from shared.models.database import Base
|
||||
|
||||
try:
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
logger.info("数据库表创建成功")
|
||||
except Exception as e:
|
||||
logger.error(f"数据库表创建失败: {e}")
|
||||
raise
|
||||
|
||||
# 全局数据库管理器实例
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import sys
|
||||
from pathlib import Path
|
||||
from sqlalchemy import text, select
|
||||
from shared.database.database import db_manager
|
||||
from shared.models.database import User, Role, Permission, UserRole, RolePermission
|
||||
from shared.models.identity import User, Role, Permission, UserRole, RolePermission
|
||||
from shared.services.auth_service import get_password_hash
|
||||
from shared.config.settings import settings
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""ORM Base——全项目唯一的 declarative base。
|
||||
|
||||
模型归属(D3 拆分,2026-09-17):
|
||||
- shared.models.identity 用户/角色/权限/审计(平台层,所有部署形态共用)
|
||||
- moldinsight.models STEP 分析域模型
|
||||
- inventory.models 进销存域模型
|
||||
|
||||
约定:
|
||||
- 各模块模型只 import 本文件拿 Base,模型间跨模块只允许裸 FK(字符串表名),
|
||||
不建跨模块 ORM relationship(单模块部署下另一模块的模型类可能未注册,
|
||||
relationship 会让 mapper 配置直接失败;历史上三条跨模块 relationship 均无使用方,已删除)。
|
||||
- 全量模型注册点(create_all / alembic autogenerate 前 import 全部三包):
|
||||
migrations/env.py 与 tests/conftest.py。
|
||||
"""
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
@@ -1,891 +0,0 @@
|
||||
# models/database.py
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Date, JSON, LargeBinary, Boolean, Float, ForeignKey, UniqueConstraint, Numeric, CheckConstraint
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, date
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class User(Base):
|
||||
"""用户表"""
|
||||
__tablename__ = "users"
|
||||
__excluded_fields__ = {'hashed_password'}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
full_name = Column(String(100))
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
last_login = Column(DateTime, nullable=True)
|
||||
|
||||
stp_files = relationship("STPFile", back_populates="user")
|
||||
user_roles = relationship("UserRole", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
@property
|
||||
def roles(self):
|
||||
return [ur.role for ur in self.user_roles]
|
||||
|
||||
@property
|
||||
def is_superuser(self):
|
||||
return any(r.code == 'admin' for r in self.roles)
|
||||
|
||||
def has_permission(self, permission_code: str) -> bool:
|
||||
if self.is_superuser:
|
||||
return True
|
||||
for role in self.roles:
|
||||
for perm in role.permissions:
|
||||
if perm.code == permission_code:
|
||||
return True
|
||||
return False
|
||||
|
||||
def safe_dict(self):
|
||||
return {k: v for k, v in self.__dict__.items()
|
||||
if not k.startswith('_') and k not in self.__excluded_fields__}
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, username='{self.username}')>"
|
||||
|
||||
|
||||
class Role(Base):
|
||||
"""角色表"""
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, index=True, nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
is_system = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user_roles = relationship("UserRole", back_populates="role", cascade="all, delete-orphan")
|
||||
role_permissions = relationship("RolePermission", back_populates="role", cascade="all, delete-orphan")
|
||||
|
||||
@property
|
||||
def permissions(self):
|
||||
return [rp.permission for rp in self.role_permissions]
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Role(code='{self.code}', name='{self.name}')>"
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
"""权限表"""
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(100), unique=True, index=True, nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
module = Column(String(50), nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
role_permissions = relationship("RolePermission", back_populates="permission", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Permission(code='{self.code}', name='{self.name}')>"
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
"""用户角色关联表"""
|
||||
__tablename__ = "user_roles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user = relationship("User", back_populates="user_roles")
|
||||
role = relationship("Role", back_populates="user_roles")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserRole(user_id={self.user_id}, role_id={self.role_id})>"
|
||||
|
||||
|
||||
class RolePermission(Base):
|
||||
"""角色权限关联表"""
|
||||
__tablename__ = "role_permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False, index=True)
|
||||
permission_id = Column(Integer, ForeignKey("permissions.id"), nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
role = relationship("Role", back_populates="role_permissions")
|
||||
permission = relationship("Permission", back_populates="role_permissions")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<RolePermission(role_id={self.role_id}, permission_id={self.permission_id})>"
|
||||
|
||||
class STPFile(Base):
|
||||
"""STP源文件元数据表 - 支持同一文件多次上传"""
|
||||
__tablename__ = "stp_files"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||
# 关联进销存成品(P2-1:分析结果可一键创建为成品并回写)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=True, index=True)
|
||||
|
||||
# 对象存储信息
|
||||
object_key = Column(String(500), nullable=False, index=True) # MinIO对象键
|
||||
storage_bucket = Column(String(100), nullable=False) # 存储桶名称
|
||||
object_url = Column(String(1000), nullable=True) # 预签名URL(可选)
|
||||
|
||||
# 文件信息
|
||||
original_filename = Column(String(255), nullable=False, index=True) # 添加索引支持按文件名查询
|
||||
file_size = Column(Integer, nullable=False)
|
||||
file_hash = Column(String(64), index=True) # 移除unique约束,允许同一文件多次上传
|
||||
mime_type = Column(String(50), default="application/octet-stream")
|
||||
|
||||
# 上传批次标识 - 用于区分同一文件的多次上传
|
||||
upload_batch = Column(String(36), index=True) # UUID批次号
|
||||
|
||||
# 时间戳
|
||||
upload_time = Column(DateTime, default=func.now())
|
||||
processed_time = Column(DateTime, nullable=True)
|
||||
|
||||
# 状态
|
||||
status = Column(String(20), default="pending", index=True) # pending, processing, completed, failed
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
# 分析摘要 - 快速查询字段
|
||||
volume = Column(Float, nullable=True) # 体积 mm³
|
||||
surface_area = Column(Float, nullable=True) # 表面积 mm²
|
||||
product_weight = Column(Float, nullable=True) # 产品重量 g
|
||||
|
||||
# 保留旧字段以兼容
|
||||
file_path = Column(String(500), nullable=True) # 本地路径(已弃用)
|
||||
file_content = Column(LargeBinary, nullable=True) # 本地存储(已弃用)
|
||||
filename = Column(String(255), nullable=True) # 已弃用
|
||||
|
||||
# 关联关系
|
||||
user = relationship("User", back_populates="stp_files")
|
||||
product = relationship("Product") # P2-1: 关联的进销存成品
|
||||
geometry_data = relationship("GeometryData", back_populates="stp_file", uselist=False)
|
||||
mesh_data = relationship("MeshData", back_populates="stp_file", uselist=False)
|
||||
mold_cavity_data = relationship("MoldCavityData", back_populates="stp_file", uselist=False)
|
||||
html_file = relationship("HTMLFile", back_populates="stp_file", uselist=False)
|
||||
analysis_metrics = relationship("AnalysisMetrics", back_populates="stp_file", uselist=False)
|
||||
feature_detections = relationship("FeatureDetection", back_populates="stp_file")
|
||||
design_recommendations = relationship("DesignRecommendation", back_populates="stp_file")
|
||||
processing_tasks = relationship("ProcessingTask", back_populates="stp_file")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<STPFile(id={self.id}, original_filename='{self.original_filename}', status='{self.status}')>"
|
||||
|
||||
class GeometryData(Base):
|
||||
"""几何数据JSON元数据表"""
|
||||
__tablename__ = "geometry_data"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 对象存储信息
|
||||
object_key = Column(String(500), nullable=False)
|
||||
storage_bucket = Column(String(100), nullable=False)
|
||||
object_url = Column(String(1000), nullable=True)
|
||||
|
||||
# 分析方法
|
||||
analysis_method = Column(String(50), default="pythonocc") # pythonocc, simulated
|
||||
|
||||
# 时间戳
|
||||
created_time = Column(DateTime, default=func.now())
|
||||
|
||||
# 几何属性摘要(便于快速查询)
|
||||
volume = Column(Float, nullable=True)
|
||||
surface_area = Column(Float, nullable=True)
|
||||
bounding_box_min = Column(JSON, nullable=True)
|
||||
bounding_box_max = Column(JSON, nullable=True)
|
||||
center_of_mass = Column(JSON, nullable=True)
|
||||
|
||||
# 拓扑信息
|
||||
topology_faces = Column(Integer, nullable=True)
|
||||
topology_edges = Column(Integer, nullable=True)
|
||||
topology_vertices = Column(Integer, nullable=True)
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="geometry_data")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<GeometryData(id={self.id}, stp_file_id={self.stp_file_id})>"
|
||||
|
||||
|
||||
class MeshData(Base):
|
||||
"""网格数据JSON元数据表(详细网格存 RustFS,PostgreSQL 存摘要)"""
|
||||
__tablename__ = "mesh_data"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 对象存储信息
|
||||
object_key = Column(String(500), nullable=False)
|
||||
storage_bucket = Column(String(100), nullable=False)
|
||||
object_url = Column(String(1000), nullable=True)
|
||||
|
||||
# 生成设置
|
||||
quality = Column(String(20), default="medium") # low / medium / high
|
||||
|
||||
# 网格规模信息
|
||||
vertex_count = Column(Integer, nullable=True)
|
||||
face_count = Column(Integer, nullable=True)
|
||||
point_count = Column(Integer, nullable=True) # 采样点云数量
|
||||
|
||||
# 网格边界框(便于快速查询)
|
||||
bounding_box_min = Column(JSON, nullable=True)
|
||||
bounding_box_max = Column(JSON, nullable=True)
|
||||
|
||||
# 时间戳
|
||||
created_time = Column(DateTime, default=func.now())
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="mesh_data")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MeshData(id={self.id}, stp_file_id={self.stp_file_id}, quality='{self.quality}')>"
|
||||
|
||||
class HTMLFile(Base):
|
||||
"""网页文件元数据表"""
|
||||
__tablename__ = "html_files"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 对象存储信息
|
||||
object_key = Column(String(500), nullable=False)
|
||||
storage_bucket = Column(String(100), nullable=False)
|
||||
object_url = Column(String(1000), nullable=True)
|
||||
|
||||
# 文件信息
|
||||
filename = Column(String(255), nullable=False)
|
||||
generated_time = Column(DateTime, default=func.now())
|
||||
|
||||
# 可视化相关元数据
|
||||
visualization_type = Column(String(50), default="3d_viewer")
|
||||
has_interactive_elements = Column(Boolean, default=True)
|
||||
|
||||
# 保留旧字段以兼容
|
||||
file_path = Column(String(500), nullable=True)
|
||||
html_content = Column(Text, nullable=True)
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="html_file")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<HTMLFile(id={self.id}, stp_file_id={self.stp_file_id}, object_key='{self.object_key}')>"
|
||||
|
||||
class ProcessingTask(Base):
|
||||
"""处理任务记录表"""
|
||||
__tablename__ = "processing_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
task_id = Column(String(36), unique=True, index=True, nullable=False)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 批量上传聚合 ID(批次 2:批量元数据入库——PG 为单一事实源,
|
||||
# 同批任务经此列聚合查询,不再依赖 Redis/进程内存存批量元数据)
|
||||
batch_id = Column(String(36), nullable=True, index=True)
|
||||
|
||||
# 任务类型和状态
|
||||
task_type = Column(String(50), default="stp_parsing") # stp_parsing, geometry_analysis, mold_generation
|
||||
status = Column(String(20), default="pending") # pending, processing, completed, failed
|
||||
|
||||
# 时间戳
|
||||
created_time = Column(DateTime, default=func.now())
|
||||
started_time = Column(DateTime, nullable=True)
|
||||
completed_time = Column(DateTime, nullable=True)
|
||||
|
||||
# 处理进度
|
||||
progress = Column(Integer, default=0) # 0-100
|
||||
current_step = Column(String(100), nullable=True)
|
||||
|
||||
# 错误信息
|
||||
error_message = Column(Text, nullable=True)
|
||||
error_stack = Column(Text, nullable=True)
|
||||
|
||||
# 处理参数
|
||||
parameters = Column(JSON, nullable=True) # 任务参数
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="processing_tasks")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ProcessingTask(id={self.id}, task_id='{self.task_id}', status='{self.status}')>"
|
||||
|
||||
class MoldCavityData(Base):
|
||||
"""模具型腔数据元数据表"""
|
||||
__tablename__ = "mold_cavity_data"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 对象存储信息
|
||||
detailed_object_key = Column(String(500), nullable=False) # 完整三维数据
|
||||
storage_bucket = Column(String(100), nullable=False)
|
||||
|
||||
# 模具类型和材料
|
||||
mold_material = Column(String(100), default="Aluminum Alloy 7075")
|
||||
mold_type = Column(String(50), default="single_cavity") # single_cavity, multi_cavity
|
||||
|
||||
# 工艺参数
|
||||
shrinkage_rate = Column(Float, nullable=False)
|
||||
draft_angle = Column(Float, nullable=False)
|
||||
parting_line_length = Column(Float, nullable=True)
|
||||
|
||||
# 生成时间
|
||||
generated_time = Column(DateTime, default=func.now())
|
||||
|
||||
# 关键信息摘要(快速查询字段)
|
||||
cavity_key_info = Column(JSON, nullable=True) # 完整关键信息
|
||||
|
||||
# 提取的字段(便于查询和排序)
|
||||
mold_size_length = Column(Float, nullable=True)
|
||||
mold_size_width = Column(Float, nullable=True)
|
||||
mold_size_height = Column(Float, nullable=True)
|
||||
estimated_clamping_force = Column(String(50), nullable=True)
|
||||
product_weight = Column(String(50), nullable=True)
|
||||
product_volume = Column(Float, nullable=True)
|
||||
wall_thickness_range = Column(String(50), nullable=True)
|
||||
complexity_score = Column(Float, nullable=True)
|
||||
|
||||
# 质量评估
|
||||
weld_line_risk = Column(String(50), nullable=True) # 熔接痕风险
|
||||
sink_mark_risk = Column(String(50), nullable=True) # 缩痕风险
|
||||
warpage_risk = Column(String(50), nullable=True) # 翘曲风险
|
||||
|
||||
# 多方案可信化摘要(第1周阶段1)
|
||||
best_scheme_id = Column(String(64), nullable=True, index=True)
|
||||
confidence_score = Column(Float, nullable=True)
|
||||
is_fallback = Column(Boolean, nullable=True, index=True)
|
||||
fallback_reason = Column(Text, nullable=True)
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="mold_cavity_data")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MoldCavityData(stp_file_id={self.stp_file_id}, mold_material='{self.mold_material}')>"
|
||||
|
||||
|
||||
class FeatureDetection(Base):
|
||||
"""特征检测结果表"""
|
||||
__tablename__ = "feature_detections"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 特征信息
|
||||
feature_type = Column(String(50), nullable=False, index=True) # thin_wall, thick_wall, wall_non_uniform, rib, boss, draft_angle, high_curvature, fillet
|
||||
confidence = Column(Float, nullable=False) # 0.0 - 1.0
|
||||
|
||||
# 位置和尺寸
|
||||
location = Column(JSON, nullable=True) # [x, y, z]
|
||||
dimensions = Column(JSON, nullable=True) # [length, width, height]
|
||||
|
||||
# 特征参数
|
||||
parameters = Column(JSON, nullable=True) # 自定义参数
|
||||
|
||||
# 检测时间
|
||||
detected_at = Column(DateTime, default=func.now())
|
||||
|
||||
# 关联的几何数据
|
||||
geometry_data_id = Column(Integer, ForeignKey("geometry_data.id"), nullable=True)
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="feature_detections")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<FeatureDetection(id={self.id}, feature_type='{self.feature_type}', confidence={self.confidence})>"
|
||||
|
||||
|
||||
class DesignRecommendation(Base):
|
||||
"""设计建议表"""
|
||||
__tablename__ = "design_recommendations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 建议信息
|
||||
rec_type = Column(String(50), nullable=False) # wall_thickness, draft_angle, etc.
|
||||
priority = Column(String(20), nullable=False) # high, medium, low
|
||||
description = Column(String(500), nullable=False)
|
||||
reason = Column(Text, nullable=True)
|
||||
|
||||
# 建议参数
|
||||
parameters = Column(JSON, nullable=True)
|
||||
|
||||
# 状态
|
||||
status = Column(String(20), default="pending") # pending, accepted, rejected
|
||||
user_notes = Column(Text, nullable=True)
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, nullable=True)
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="design_recommendations")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DesignRecommendation(id={self.id}, rec_type='{self.rec_type}', priority='{self.priority}')>"
|
||||
|
||||
|
||||
class UserActivity(Base):
|
||||
"""用户活动日志表"""
|
||||
__tablename__ = "user_activities"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
# 活动信息
|
||||
activity_type = Column(String(50), nullable=False, index=True) # upload, view, download, delete, export
|
||||
resource_type = Column(String(50), nullable=True) # stp_file, geometry_data, mold_cavity
|
||||
resource_id = Column(Integer, nullable=True)
|
||||
|
||||
# 活动详情
|
||||
description = Column(Text, nullable=True)
|
||||
meta_data = Column(JSON, nullable=True)
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
# IP和设备信息
|
||||
ip_address = Column(String(45), nullable=True)
|
||||
user_agent = Column(String(500), nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserActivity(id={self.id}, user_id={self.user_id}, activity_type='{self.activity_type}')>"
|
||||
|
||||
|
||||
class SystemLog(Base):
|
||||
"""系统日志表(重要操作和错误)"""
|
||||
__tablename__ = "system_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# 日志级别
|
||||
level = Column(String(20), nullable=False, index=True) # INFO, WARNING, ERROR, CRITICAL
|
||||
|
||||
# 日志信息
|
||||
message = Column(Text, nullable=False)
|
||||
module = Column(String(100), nullable=True) # 模块名
|
||||
function_name = Column(String(100), nullable=True)
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
# 用户信息(如果有关联用户)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# 额外信息
|
||||
request_id = Column(String(100), nullable=True) # 关联的请求ID
|
||||
execution_time_ms = Column(Integer, nullable=True) # 执行时间
|
||||
|
||||
# 关联数据
|
||||
resource_type = Column(String(50), nullable=True)
|
||||
resource_id = Column(Integer, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SystemLog(id={self.id}, level='{self.level}', module='{self.module}')>"
|
||||
|
||||
|
||||
class Product(Base):
|
||||
"""产品表"""
|
||||
__tablename__ = "products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
sku = Column(String(50), unique=True, index=True, nullable=False)
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
category = Column(String(100), nullable=True)
|
||||
unit = Column(String(20), default="件")
|
||||
item_type = Column(String(20), default="finished", index=True)
|
||||
cost_price = Column(Numeric(12, 2), default=0)
|
||||
sale_price = Column(Numeric(12, 2), default=0)
|
||||
min_stock = Column(Integer, default=0)
|
||||
max_stock = Column(Integer, default=1000)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
inventory = relationship("Inventory", back_populates="product", uselist=False)
|
||||
stock_movements = relationship("StockMovement", back_populates="product")
|
||||
bom_materials = relationship(
|
||||
"ProductMaterial",
|
||||
foreign_keys="ProductMaterial.finished_product_id",
|
||||
back_populates="finished_product",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
used_in_products = relationship(
|
||||
"ProductMaterial",
|
||||
foreign_keys="ProductMaterial.material_product_id",
|
||||
back_populates="material_product"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Product(id={self.id}, sku='{self.sku}', name='{self.name}')>"
|
||||
|
||||
|
||||
class ProductMaterial(Base):
|
||||
__tablename__ = "product_materials"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("finished_product_id", "material_product_id", name="uq_product_material_unique"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
finished_product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
material_product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
quantity = Column(Numeric(12, 4), nullable=False)
|
||||
loss_rate = Column(Numeric(5, 4), default=0)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
finished_product = relationship(
|
||||
"Product",
|
||||
foreign_keys=[finished_product_id],
|
||||
back_populates="bom_materials"
|
||||
)
|
||||
material_product = relationship(
|
||||
"Product",
|
||||
foreign_keys=[material_product_id],
|
||||
back_populates="used_in_products"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ProductMaterial(finished_product_id={self.finished_product_id}, material_product_id={self.material_product_id})>"
|
||||
|
||||
|
||||
class MaterialPriceHistory(Base):
|
||||
"""物料价格历史表"""
|
||||
__tablename__ = "material_price_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
price = Column(Numeric(12, 2), nullable=False)
|
||||
effective_date = Column(DateTime, default=func.now(), index=True)
|
||||
supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=True, index=True)
|
||||
remark = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
product = relationship("Product", backref="price_history")
|
||||
supplier = relationship("Supplier", backref="price_history")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MaterialPriceHistory(product_id={self.product_id}, price={self.price}, date={self.effective_date})>"
|
||||
|
||||
|
||||
class MaterialSupplier(Base):
|
||||
"""物料供应商关联表"""
|
||||
__tablename__ = "material_suppliers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=False, index=True)
|
||||
is_primary = Column(Boolean, default=False)
|
||||
contact_person = Column(String(100), nullable=True)
|
||||
contact_phone = Column(String(50), nullable=True)
|
||||
lead_time = Column(Integer, nullable=True) # 交货周期(天)
|
||||
min_order_quantity = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
product = relationship("Product", backref="suppliers")
|
||||
supplier = relationship("Supplier", backref="materials")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MaterialSupplier(product_id={self.product_id}, supplier_id={self.supplier_id}, primary={self.is_primary})>"
|
||||
|
||||
|
||||
class Supplier(Base):
|
||||
"""供应商表"""
|
||||
__tablename__ = "suppliers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
contact_person = Column(String(100), nullable=True)
|
||||
phone = Column(String(50), nullable=True)
|
||||
email = Column(String(100), nullable=True)
|
||||
address = Column(Text, nullable=True)
|
||||
bank_name = Column(String(100), nullable=True)
|
||||
bank_account = Column(String(50), nullable=True)
|
||||
tax_number = Column(String(50), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
purchase_orders = relationship("PurchaseOrder", back_populates="supplier")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Supplier(id={self.id}, name='{self.name}')>"
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
"""客户表"""
|
||||
__tablename__ = "customers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
contact_person = Column(String(100), nullable=True)
|
||||
phone = Column(String(50), nullable=True)
|
||||
email = Column(String(100), nullable=True)
|
||||
address = Column(Text, nullable=True)
|
||||
bank_name = Column(String(100), nullable=True)
|
||||
bank_account = Column(String(50), nullable=True)
|
||||
tax_number = Column(String(50), nullable=True)
|
||||
credit_limit = Column(Numeric(12, 2), default=0)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
sales_orders = relationship("SalesOrder", back_populates="customer")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Customer(id={self.id}, name='{self.name}')>"
|
||||
|
||||
|
||||
class Warehouse(Base):
|
||||
"""仓库表"""
|
||||
__tablename__ = "warehouses"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
address = Column(Text, nullable=True)
|
||||
manager = Column(String(100), nullable=True)
|
||||
phone = Column(String(50), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_default = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
inventories = relationship("Inventory", back_populates="warehouse")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Warehouse(id={self.id}, name='{self.name}')>"
|
||||
|
||||
|
||||
class Inventory(Base):
|
||||
"""库存表"""
|
||||
__tablename__ = "inventory"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("product_id", "warehouse_id", name="uq_inventory_product_warehouse"),
|
||||
CheckConstraint("quantity >= 0 AND locked_quantity >= 0 AND locked_quantity <= quantity", name="ck_inventory_qty_nonnegative"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False, index=True)
|
||||
quantity = Column(Numeric(12, 4), default=0)
|
||||
locked_quantity = Column(Numeric(12, 4), default=0)
|
||||
batch_number = Column(String(50), nullable=True)
|
||||
location = Column(String(100), nullable=True)
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
product = relationship("Product", back_populates="inventory")
|
||||
warehouse = relationship("Warehouse", back_populates="inventories")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Inventory(product_id={self.product_id}, quantity={self.quantity})>"
|
||||
|
||||
@property
|
||||
def available_quantity(self):
|
||||
return self.quantity - self.locked_quantity
|
||||
|
||||
|
||||
class StockMovement(Base):
|
||||
"""库存变动记录表"""
|
||||
__tablename__ = "stock_movements"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False)
|
||||
movement_type = Column(String(20), nullable=False)
|
||||
quantity = Column(Numeric(12, 4), nullable=False)
|
||||
before_quantity = Column(Numeric(12, 4), default=0)
|
||||
after_quantity = Column(Numeric(12, 4), default=0)
|
||||
reference_type = Column(String(50), nullable=True)
|
||||
reference_id = Column(Integer, nullable=True)
|
||||
reference_no = Column(String(50), nullable=True)
|
||||
unit_price = Column(Numeric(12, 2), nullable=True)
|
||||
total_amount = Column(Numeric(12, 2), nullable=True)
|
||||
remark = Column(Text, nullable=True)
|
||||
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
product = relationship("Product", back_populates="stock_movements")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<StockMovement(id={self.id}, type='{self.movement_type}', qty={self.quantity})>"
|
||||
|
||||
|
||||
class PurchaseOrder(Base):
|
||||
"""采购订单表"""
|
||||
__tablename__ = "purchase_orders"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_no = Column(String(50), unique=True, index=True, nullable=False)
|
||||
supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=False, index=True)
|
||||
order_date = Column(DateTime, default=func.now())
|
||||
expected_date = Column(Date, nullable=True)
|
||||
status = Column(String(20), default="draft")
|
||||
total_amount = Column(Numeric(12, 2), default=0)
|
||||
paid_amount = Column(Numeric(12, 2), default=0)
|
||||
remark = Column(Text, nullable=True)
|
||||
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
# 状态变更时间
|
||||
received_date = Column(DateTime, nullable=True) # 已收货时间
|
||||
paid_date = Column(DateTime, nullable=True) # 已付款时间
|
||||
|
||||
supplier = relationship("Supplier", back_populates="purchase_orders")
|
||||
items = relationship("PurchaseOrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PurchaseOrder(order_no='{self.order_no}', status='{self.status}')>"
|
||||
|
||||
|
||||
class PurchaseOrderItem(Base):
|
||||
"""采购订单明细表"""
|
||||
__tablename__ = "purchase_order_items"
|
||||
__table_args__ = (
|
||||
CheckConstraint("quantity > 0 AND received_quantity >= 0 AND received_quantity <= quantity", name="ck_purchase_order_items_qty"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("purchase_orders.id"), nullable=False)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
quantity = Column(Integer, nullable=False)
|
||||
received_quantity = Column(Integer, default=0)
|
||||
unit_price = Column(Numeric(12, 2), nullable=False)
|
||||
amount = Column(Numeric(12, 2), nullable=False)
|
||||
remark = Column(Text, nullable=True)
|
||||
|
||||
order = relationship("PurchaseOrder", back_populates="items")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PurchaseOrderItem(order_id={self.order_id}, product_id={self.product_id})>"
|
||||
|
||||
|
||||
class SalesOrder(Base):
|
||||
"""销售订单表"""
|
||||
__tablename__ = "sales_orders"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_no = Column(String(50), unique=True, index=True, nullable=False)
|
||||
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False, index=True)
|
||||
order_date = Column(DateTime, default=func.now())
|
||||
delivery_date = Column(Date, nullable=True)
|
||||
manufacturing_date = Column(DateTime, nullable=True)
|
||||
actual_delivery_date = Column(DateTime, nullable=True)
|
||||
actual_payment_date = Column(DateTime, nullable=True)
|
||||
status = Column(String(20), default="draft")
|
||||
production_status = Column(String(20), default="not_started", index=True)
|
||||
production_no = Column(String(50), nullable=True, index=True)
|
||||
planned_material_cost = Column(Numeric(12, 2), default=0)
|
||||
actual_material_cost = Column(Numeric(12, 2), default=0)
|
||||
total_amount = Column(Numeric(12, 2), default=0)
|
||||
received_amount = Column(Numeric(12, 2), default=0)
|
||||
remark = Column(Text, nullable=True)
|
||||
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
customer = relationship("Customer", back_populates="sales_orders")
|
||||
items = relationship("SalesOrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SalesOrder(order_no='{self.order_no}', status='{self.status}')>"
|
||||
|
||||
|
||||
class FinanceTransaction(Base):
|
||||
__tablename__ = "finance_transactions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
txn_no = Column(String(50), unique=True, index=True, nullable=False)
|
||||
txn_type = Column(String(20), nullable=False, index=True)
|
||||
partner_type = Column(String(20), nullable=False, index=True)
|
||||
partner_id = Column(Integer, nullable=False, index=True)
|
||||
amount = Column(Numeric(12, 2), nullable=False)
|
||||
txn_date = Column(DateTime, default=func.now(), index=True)
|
||||
method = Column(String(30), default="bank")
|
||||
account_name = Column(String(100), nullable=True)
|
||||
status = Column(String(20), default="confirmed", index=True)
|
||||
remark = Column(Text, nullable=True)
|
||||
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
allocations = relationship("FinanceAllocation", back_populates="transaction", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<FinanceTransaction(txn_no='{self.txn_no}', txn_type='{self.txn_type}', amount={self.amount})>"
|
||||
|
||||
|
||||
class FinanceAllocation(Base):
|
||||
__tablename__ = "finance_allocations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
transaction_id = Column(Integer, ForeignKey("finance_transactions.id"), nullable=False, index=True)
|
||||
order_type = Column(String(20), nullable=False, index=True)
|
||||
order_id = Column(Integer, nullable=False, index=True)
|
||||
allocated_amount = Column(Numeric(12, 2), nullable=False)
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
transaction = relationship("FinanceTransaction", back_populates="allocations")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<FinanceAllocation(transaction_id={self.transaction_id}, order_type='{self.order_type}', amount={self.allocated_amount})>"
|
||||
|
||||
|
||||
class AnalysisMetrics(Base):
|
||||
"""分析指标表"""
|
||||
__tablename__ = "analysis_metrics"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 质量指标
|
||||
volume_utilization = Column(Float, default=0) # 体积利用率
|
||||
topology_complexity = Column(Float, default=0) # 拓扑复杂度
|
||||
wall_uniformity = Column(Float, default=0) # 壁厚均匀性
|
||||
|
||||
# 分析摘要
|
||||
analysis_summary = Column(Text, nullable=True)
|
||||
|
||||
# FreeCAD 验证结果
|
||||
verification_status = Column(String(20), nullable=True) # passed, failed, pending, error
|
||||
verification_volume_diff = Column(Float, nullable=True) # 体积差异百分比
|
||||
verification_area_diff = Column(Float, nullable=True) # 表面积差异百分比
|
||||
verification_details = Column(JSON, nullable=True) # 完整验证结果
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="analysis_metrics")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AnalysisMetrics(stp_file_id={self.stp_file_id}, volume_utilization={self.volume_utilization})>"
|
||||
|
||||
|
||||
class SalesOrderItem(Base):
|
||||
"""销售订单明细表"""
|
||||
__tablename__ = "sales_order_items"
|
||||
__table_args__ = (
|
||||
CheckConstraint("quantity > 0 AND delivered_quantity >= 0 AND delivered_quantity <= quantity", name="ck_sales_order_items_qty"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("sales_orders.id"), nullable=False)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
quantity = Column(Integer, nullable=False)
|
||||
delivered_quantity = Column(Integer, default=0)
|
||||
unit_price = Column(Numeric(12, 2), nullable=False)
|
||||
amount = Column(Numeric(12, 2), nullable=False)
|
||||
remark = Column(Text, nullable=True)
|
||||
|
||||
order = relationship("SalesOrder", back_populates="items")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SalesOrderItem(order_id={self.order_id}, product_id={self.product_id})>"
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""身份与权限模型(平台层,三种部署形态共用)。
|
||||
|
||||
从旧 shared/models/database.py 拆出(D3,2026-09-17)。
|
||||
原 User.stp_files ↔ STPFile.user 跨模块 relationship 已删除(无使用方):
|
||||
用户与 STP 文件的关联走 STPFile.user_id 裸 FK,查询由 moldinsight 侧显式 select。
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey, JSON
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from shared.models.base import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""用户表"""
|
||||
__tablename__ = "users"
|
||||
__excluded_fields__ = {'hashed_password'}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
full_name = Column(String(100))
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
last_login = Column(DateTime, nullable=True)
|
||||
|
||||
user_roles = relationship("UserRole", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
@property
|
||||
def roles(self):
|
||||
return [ur.role for ur in self.user_roles]
|
||||
|
||||
@property
|
||||
def is_superuser(self):
|
||||
return any(r.code == 'admin' for r in self.roles)
|
||||
|
||||
def has_permission(self, permission_code: str) -> bool:
|
||||
if self.is_superuser:
|
||||
return True
|
||||
for role in self.roles:
|
||||
for perm in role.permissions:
|
||||
if perm.code == permission_code:
|
||||
return True
|
||||
return False
|
||||
|
||||
def safe_dict(self):
|
||||
return {k: v for k, v in self.__dict__.items()
|
||||
if not k.startswith('_') and k not in self.__excluded_fields__}
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, username='{self.username}')>"
|
||||
|
||||
|
||||
class Role(Base):
|
||||
"""角色表"""
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, index=True, nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
is_system = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user_roles = relationship("UserRole", back_populates="role", cascade="all, delete-orphan")
|
||||
role_permissions = relationship("RolePermission", back_populates="role", cascade="all, delete-orphan")
|
||||
|
||||
@property
|
||||
def permissions(self):
|
||||
return [rp.permission for rp in self.role_permissions]
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Role(code='{self.code}', name='{self.name}')>"
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
"""权限表"""
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(100), unique=True, index=True, nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
module = Column(String(50), nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
role_permissions = relationship("RolePermission", back_populates="permission", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Permission(code='{self.code}', name='{self.name}')>"
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
"""用户角色关联表"""
|
||||
__tablename__ = "user_roles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user = relationship("User", back_populates="user_roles")
|
||||
role = relationship("Role", back_populates="user_roles")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserRole(user_id={self.user_id}, role_id={self.role_id})>"
|
||||
|
||||
|
||||
class RolePermission(Base):
|
||||
"""角色权限关联表"""
|
||||
__tablename__ = "role_permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False, index=True)
|
||||
permission_id = Column(Integer, ForeignKey("permissions.id"), nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
role = relationship("Role", back_populates="role_permissions")
|
||||
permission = relationship("Permission", back_populates="role_permissions")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<RolePermission(role_id={self.role_id}, permission_id={self.permission_id})>"
|
||||
|
||||
|
||||
class UserActivity(Base):
|
||||
"""用户活动日志表"""
|
||||
__tablename__ = "user_activities"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
# 活动信息
|
||||
activity_type = Column(String(50), nullable=False, index=True) # upload, view, download, delete, export
|
||||
resource_type = Column(String(50), nullable=True) # stp_file, geometry_data, mold_cavity
|
||||
resource_id = Column(Integer, nullable=True)
|
||||
|
||||
# 活动详情
|
||||
description = Column(Text, nullable=True)
|
||||
meta_data = Column(JSON, nullable=True)
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
# IP和设备信息
|
||||
ip_address = Column(String(45), nullable=True)
|
||||
user_agent = Column(String(500), nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserActivity(id={self.id}, user_id={self.user_id}, activity_type='{self.activity_type}')>"
|
||||
|
||||
|
||||
class SystemLog(Base):
|
||||
"""系统日志表(重要操作和错误)"""
|
||||
__tablename__ = "system_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# 日志级别
|
||||
level = Column(String(20), nullable=False, index=True) # INFO, WARNING, ERROR, CRITICAL
|
||||
|
||||
# 日志信息
|
||||
message = Column(Text, nullable=False)
|
||||
module = Column(String(100), nullable=True) # 模块名
|
||||
function_name = Column(String(100), nullable=True)
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
# 用户信息(如果有关联用户)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# 额外信息
|
||||
request_id = Column(String(100), nullable=True) # 关联的请求ID
|
||||
execution_time_ms = Column(Integer, nullable=True) # 执行时间
|
||||
|
||||
# 关联数据
|
||||
resource_type = Column(String(50), nullable=True)
|
||||
resource_id = Column(Integer, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SystemLog(id={self.id}, level='{self.level}', module='{self.module}')>"
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import timedelta
|
||||
from sqlalchemy import select
|
||||
@@ -14,7 +14,7 @@ from shared.services.auth_service import (
|
||||
get_current_active_user,
|
||||
get_password_hash
|
||||
)
|
||||
from shared.models.database import User, Role, Permission, UserRole, RolePermission
|
||||
from shared.models.identity import User, Role, Permission, UserRole, RolePermission
|
||||
from shared.config.settings import settings
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
@@ -97,6 +97,13 @@ class UserUpdate(BaseModel):
|
||||
role_ids: Optional[List[int]] = None
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
# 新密码走 JSON body(与前端 api-client.ts 的 { new_password } 结构一致)。
|
||||
# 此前声明为裸 str 参数被 FastAPI 解析为 query param,前端发 body 必然 422,
|
||||
# 重置密码功能端到端断裂;最短 6 位对齐 UsersView 前端校验。
|
||||
new_password: str = Field(min_length=6)
|
||||
|
||||
|
||||
def check_admin(user: User) -> bool:
|
||||
if not user.is_superuser:
|
||||
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||
@@ -307,7 +314,7 @@ async def delete_user(
|
||||
@router.put("/users/{user_id}/reset-password")
|
||||
async def reset_user_password(
|
||||
user_id: int,
|
||||
new_password: str,
|
||||
body: ResetPasswordRequest,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
@@ -319,7 +326,7 @@ async def reset_user_password(
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
try:
|
||||
user.hashed_password = get_password_hash(new_password)
|
||||
user.hashed_password = get_password_hash(body.new_password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
await db_session.commit()
|
||||
|
||||
@@ -10,7 +10,7 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from shared.config.settings import settings
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.database import User, UserRole
|
||||
from shared.models.identity import User, UserRole
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
+4
-4
@@ -17,10 +17,10 @@ from sqlalchemy import select
|
||||
|
||||
from fastapi import FastAPI, APIRouter
|
||||
from inventory.api import inventory_router
|
||||
from shared.models.database import (
|
||||
Base, User, Customer, Warehouse, Supplier, Product, ProductMaterial,
|
||||
Inventory, MaterialSupplier, SalesOrder, SalesOrderItem,
|
||||
)
|
||||
from shared.models.base import Base
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Customer, Warehouse, Supplier, Product, ProductMaterial, Inventory, MaterialSupplier, SalesOrder, SalesOrderItem
|
||||
import moldinsight.models # noqa: F401 # 全量注册:create_all 需含 moldinsight 表(stp_files 等)
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""批次 3(D1)回归:advanced_router 拆分 + Pydantic 请求模型契约测试。
|
||||
|
||||
- 拆分后全部原端点路径保持不变(design / cost / machining / export 四个子路由)
|
||||
- 请求体校验统一 422(原 request.json() 手动解析的 400/静默默认值退役)
|
||||
- 纯 Python 计算端点(optimize-layout)经 to_thread 仍返回原响应形态
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
|
||||
|
||||
# design/export 路由的导入链含 processing_service / cad_exporter(OCC)
|
||||
pytest.importorskip("OCC")
|
||||
|
||||
from moldinsight.api.design_router import router as design_router
|
||||
from moldinsight.api.cost_router import router as cost_router
|
||||
from moldinsight.api.machining_router import router as machining_router
|
||||
from moldinsight.api.export_router import router as export_router
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.identity import User
|
||||
|
||||
EXPECTED_PATHS = {
|
||||
"/optimize-layout", "/design-cooling", "/design-gating", "/design-mold-system",
|
||||
"/detect-undercuts", "/cost-estimate", "/design-cam", "/check-collision",
|
||||
"/optimize-toolpath", "/design-electrodes", "/simulate-machining",
|
||||
"/export-mold", "/export-download/{filepath:path}", "/export-recommendations",
|
||||
}
|
||||
|
||||
|
||||
def _build_app(async_engine):
|
||||
test_app = FastAPI()
|
||||
for r in (design_router, cost_router, machining_router, export_router):
|
||||
test_app.include_router(r)
|
||||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async def override_get_db_session():
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
|
||||
# 生产 get_db_session 在依赖解析期即建连(is_connected→connect),
|
||||
# 裸测试应用必须覆写,否则任何带鉴权链的请求都会先连真实 PG
|
||||
test_app.dependency_overrides[get_db_session] = override_get_db_session
|
||||
return test_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def api_client(async_engine):
|
||||
test_app = _build_app(async_engine)
|
||||
test_app.dependency_overrides[get_current_active_user] = lambda: User(id=1, username="tester")
|
||||
transport = ASGITransport(app=test_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_all_original_paths_registered():
|
||||
"""拆分不丢端点:原 advanced_router 的全部路径必须仍可注册。"""
|
||||
test_app = FastAPI()
|
||||
for r in (design_router, cost_router, machining_router, export_router):
|
||||
test_app.include_router(r)
|
||||
paths = {route.path for route in test_app.routes}
|
||||
missing = EXPECTED_PATHS - paths
|
||||
assert not missing, f"拆分后丢失端点: {missing}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_endpoints_require_auth(async_engine):
|
||||
"""拆分不得丢掉鉴权:未带 token 访问设计/导出端点必须 401。"""
|
||||
test_app = _build_app(async_engine) # 只覆写 db,不覆写鉴权
|
||||
transport = ASGITransport(app=test_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
for path in ("/optimize-layout", "/cost-estimate", "/export-mold", "/design-cam"):
|
||||
resp = await ac.post(path, json={})
|
||||
assert resp.status_code == 401, f"{path} 未鉴权: {resp.status_code}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_optimize_layout_rejects_invalid_cavity_count(api_client):
|
||||
"""原 400「1-64」校验迁移为 Pydantic 422。"""
|
||||
resp = await api_client.post("/optimize-layout", json={"cavity_count": 0})
|
||||
assert resp.status_code == 422
|
||||
resp = await api_client.post("/optimize-layout", json={"cavity_count": 65})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_id_endpoints_reject_missing_task_id(api_client):
|
||||
"""task_id 类端点缺参统一 422(原 detect-undercuts 400 / export-mold 404 语义收敛)。"""
|
||||
for path in ("/detect-undercuts", "/cost-estimate", "/export-mold"):
|
||||
resp = await api_client.post(path, json={})
|
||||
assert resp.status_code == 422, f"{path} 缺 task_id 未返回 422"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_optimize_layout_default_body_succeeds(api_client):
|
||||
"""纯 Python 计算端点经 to_thread 正常返回原响应形态。"""
|
||||
resp = await api_client.post("/optimize-layout", json={"cavity_count": 4})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "success"
|
||||
assert "data" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_simulate_machining_default_body_succeeds(api_client):
|
||||
resp = await api_client.post("/simulate-machining", json={})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "success"
|
||||
@@ -0,0 +1,20 @@
|
||||
"""D2:铝价数据必须显式声明模拟来源。
|
||||
|
||||
数据为模拟走势(aluminum_price_service 模块 docstring 自述),
|
||||
此前接口不声明来源、前端硬编码"数据来源: 上海期货交易所",属虚假来源声明。
|
||||
"""
|
||||
|
||||
|
||||
def test_current_price_declares_simulated_source():
|
||||
from moldinsight.services.aluminum_price_service import get_aluminum_current_price
|
||||
|
||||
data = get_aluminum_current_price()
|
||||
assert data["source"] == "simulated"
|
||||
|
||||
|
||||
def test_history_items_declare_simulated_source():
|
||||
from moldinsight.services.aluminum_price_service import get_aluminum_price_history
|
||||
|
||||
history = get_aluminum_price_history(days=30)
|
||||
assert len(history) > 0
|
||||
assert all(item["source"] == "simulated" for item in history)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""批次 3 回归:管理员重置密码走 JSON body。
|
||||
|
||||
此前后端把 new_password 声明为裸 str 参数(FastAPI 解析为 query param),
|
||||
前端两个调用点均发送 JSON body,重置密码端到端断裂(必 422)。
|
||||
现收敛为 Pydantic 请求模型 { new_password },与 api-client.ts 结构一致。
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.identity import User
|
||||
from shared.services.auth_service import (
|
||||
get_current_active_user,
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
)
|
||||
from shared.services.auth_routes import router as auth_router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def reset_env(async_engine, seeded_db):
|
||||
"""播种被重置目标用户(id=2,已知旧密码);返回 (client, app, session_factory)。"""
|
||||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with session_factory() as session:
|
||||
target = User(
|
||||
id=2, username="resetme", email="resetme@example.com",
|
||||
hashed_password=get_password_hash("oldpass123"), is_active=True,
|
||||
)
|
||||
session.add(target)
|
||||
await session.commit()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(auth_router) # router 自带 /api/auth 前缀
|
||||
|
||||
async def override_get_db_session():
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
|
||||
test_app.dependency_overrides[get_db_session] = override_get_db_session
|
||||
|
||||
transport = ASGITransport(app=test_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac, test_app, session_factory
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _login_as(app: FastAPI, *, superuser: bool):
|
||||
# User.is_superuser 为只读 hybrid property,覆写用户用 SimpleNamespace 承载
|
||||
app.dependency_overrides[get_current_active_user] = lambda: SimpleNamespace(
|
||||
id=500, username="admin", is_superuser=superuser
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_password_with_json_body(reset_env):
|
||||
"""JSON body { new_password } 生效:密码真实更新且可用新口令验证。"""
|
||||
ac, app, session_factory = reset_env
|
||||
_login_as(app, superuser=True)
|
||||
|
||||
resp = await ac.put(
|
||||
"/api/auth/users/2/reset-password",
|
||||
json={"new_password": "brandnew456"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
async with session_factory() as session:
|
||||
user = (await session.execute(select(User).where(User.id == 2))).scalar_one()
|
||||
assert verify_password("brandnew456", user.hashed_password)
|
||||
assert not verify_password("oldpass123", user.hashed_password)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_password_rejects_short_password(reset_env):
|
||||
"""最短 6 位对齐前端校验,违约 422。"""
|
||||
ac, app, _ = reset_env
|
||||
_login_as(app, superuser=True)
|
||||
|
||||
resp = await ac.put(
|
||||
"/api/auth/users/2/reset-password",
|
||||
json={"new_password": "abc"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_password_requires_admin(reset_env):
|
||||
ac, app, _ = reset_env
|
||||
_login_as(app, superuser=False)
|
||||
|
||||
resp = await ac.put(
|
||||
"/api/auth/users/2/reset-password",
|
||||
json={"new_password": "brandnew456"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
@@ -11,7 +11,8 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
|
||||
|
||||
from moldinsight.api.batch_router import router as batch_router
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.database import User, STPFile, ProcessingTask
|
||||
from shared.models.identity import User
|
||||
from moldinsight.models import STPFile, ProcessingTask
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""批次 3(D14)回归:配置治理。
|
||||
|
||||
- settings.redis_url 成为 Redis 连接串唯一拼装点(celery_app 不再自拼)
|
||||
- MAX_FILE_SIZE 不再是死配置:上传处理器接 settings(此前硬编码 50MB)
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from shared.config.settings import Settings
|
||||
|
||||
|
||||
def _fresh_settings(monkeypatch, **env):
|
||||
for key, value in env.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
return Settings()
|
||||
|
||||
|
||||
def test_redis_url_without_password(monkeypatch):
|
||||
s = _fresh_settings(
|
||||
monkeypatch,
|
||||
REDIS_HOST="redis-svc", REDIS_PORT="6380", REDIS_PASSWORD="", REDIS_DB="2",
|
||||
)
|
||||
assert s.redis_url == "redis://redis-svc:6380/2"
|
||||
|
||||
|
||||
def test_redis_url_with_password(monkeypatch):
|
||||
s = _fresh_settings(
|
||||
monkeypatch,
|
||||
REDIS_HOST="redis-svc", REDIS_PORT="6379", REDIS_PASSWORD="sec ret", REDIS_DB="0",
|
||||
)
|
||||
assert s.redis_url == "redis://:sec ret@redis-svc:6379/0"
|
||||
|
||||
|
||||
def test_celery_app_reuses_settings_redis_url():
|
||||
"""celery_app 的 broker/backend 必须等于 settings.redis_url(消除两份拼装实现)。"""
|
||||
pytest.importorskip("celery")
|
||||
import celery_app
|
||||
from shared.config.settings import settings
|
||||
|
||||
assert celery_app.app.conf.broker_url == settings.redis_url
|
||||
assert celery_app.app.conf.result_backend == settings.redis_url
|
||||
|
||||
|
||||
def test_upload_handler_uses_settings_max_file_size(monkeypatch):
|
||||
"""MAX_FILE_SIZE 从 .env 一路生效到上传校验(不再是死配置)。"""
|
||||
pytest.importorskip("minio") # upload_router 导入链含 rustfs_storage
|
||||
from shared.config import settings as settings_module
|
||||
from shared.utils.file_handler import FileHandler
|
||||
|
||||
monkeypatch.setattr(settings_module.settings, "MAX_FILE_SIZE", 10)
|
||||
handler = FileHandler(
|
||||
upload_dir=settings_module.settings.UPLOAD_DIR,
|
||||
max_file_size=settings_module.settings.MAX_FILE_SIZE,
|
||||
)
|
||||
assert handler.max_file_size == 10
|
||||
|
||||
import asyncio
|
||||
|
||||
class _FakeUpload:
|
||||
filename = "big.step"
|
||||
|
||||
@staticmethod
|
||||
async def read():
|
||||
return b"x" * 11
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
asyncio.run(handler.save_uploaded_file(_FakeUpload()))
|
||||
|
||||
|
||||
def test_router_handlers_wired_to_settings():
|
||||
"""路由模块的 file_handler 实例必须接 settings(而非构造默认值)。"""
|
||||
pytest.importorskip("minio")
|
||||
from shared.config.settings import settings
|
||||
from moldinsight.api import upload_router, batch_router
|
||||
|
||||
assert upload_router.file_handler.max_file_size == settings.MAX_FILE_SIZE
|
||||
assert batch_router.file_handler.max_file_size == settings.MAX_FILE_SIZE
|
||||
@@ -0,0 +1,176 @@
|
||||
"""D11:/html/{filename} 报告代理路由。
|
||||
|
||||
解析链:RustFS 报告键(新产物裸文件)→ HTMLFile 遗留记录(JSON 包装)
|
||||
→ 节点本地 html_output(存量兜底)→ 404;路径穿越一律 404。
|
||||
"""
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from moldinsight.api import html_report_router
|
||||
from moldinsight.storage.rustfs_storage import rustfs_manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
application = FastAPI()
|
||||
application.include_router(html_report_router.router)
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(app):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_external_sources(monkeypatch, tmp_path):
|
||||
"""默认隔离:RustFS 未连接、本地兜底目录指向空临时目录,由各测试按需打开。"""
|
||||
monkeypatch.setattr(rustfs_manager, "is_connected", False)
|
||||
monkeypatch.setattr(html_report_router, "LOCAL_HTML_DIR", tmp_path / "html_output")
|
||||
|
||||
|
||||
# ── 链路 1:RustFS 报告键(新产物) ──────────────────────────────
|
||||
|
||||
|
||||
async def test_report_served_from_rustfs_report_key(client, monkeypatch):
|
||||
async def fake_download_report(filename):
|
||||
assert filename == "mold_demo_20260918.html"
|
||||
return b"<html>fresh</html>"
|
||||
|
||||
monkeypatch.setattr(rustfs_manager, "is_connected", True)
|
||||
monkeypatch.setattr(rustfs_manager, "download_report_artifact", fake_download_report)
|
||||
|
||||
resp = await client.get("/html/mold_demo_20260918.html")
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == b"<html>fresh</html>"
|
||||
assert "text/html" in resp.headers["content-type"]
|
||||
|
||||
|
||||
# ── 链路 2:HTMLFile 遗留记录(html/{hash}.json JSON 包装) ──────
|
||||
|
||||
|
||||
class _FakeScalars:
|
||||
def __init__(self, record):
|
||||
self._record = record
|
||||
|
||||
def first(self):
|
||||
return self._record
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, record):
|
||||
self._record = record
|
||||
|
||||
def scalars(self):
|
||||
return _FakeScalars(self._record)
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, record):
|
||||
self._record = record
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
async def execute(self, stmt):
|
||||
return _FakeResult(self._record)
|
||||
|
||||
|
||||
class _FakeDBManager:
|
||||
def __init__(self, record):
|
||||
self._record = record
|
||||
|
||||
def session(self):
|
||||
return _FakeSession(self._record)
|
||||
|
||||
|
||||
async def test_falls_back_to_legacy_json_wrapper(client, monkeypatch):
|
||||
async def fake_download_report(filename):
|
||||
raise RuntimeError("报告键未命中")
|
||||
|
||||
async def fake_download_file(file_type, object_key):
|
||||
assert file_type == "html_files"
|
||||
assert object_key == "html/abc123.json"
|
||||
return json.dumps(
|
||||
{"content": "<html>legacy</html>", "filename": "mold_old.html"}
|
||||
).encode("utf-8")
|
||||
|
||||
record = SimpleNamespace(object_key="html/abc123.json", filename="mold_old.html")
|
||||
monkeypatch.setattr(rustfs_manager, "is_connected", True)
|
||||
monkeypatch.setattr(rustfs_manager, "download_report_artifact", fake_download_report)
|
||||
monkeypatch.setattr(rustfs_manager, "download_file", fake_download_file)
|
||||
monkeypatch.setattr(html_report_router, "db_manager", _FakeDBManager(record))
|
||||
|
||||
resp = await client.get("/html/mold_old.html")
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == b"<html>legacy</html>"
|
||||
|
||||
|
||||
async def test_legacy_record_with_report_key_returns_raw(client, monkeypatch):
|
||||
"""新格式记录(键在报告前缀下)必须按裸文件返回,不能当 JSON 包装解析。"""
|
||||
async def fake_download_report(filename):
|
||||
raise RuntimeError("报告键直取瞬时失败")
|
||||
|
||||
async def fake_download_file(file_type, object_key):
|
||||
assert object_key == "html/reports/mold_new.html"
|
||||
return b"<html>raw-by-report-key</html>"
|
||||
|
||||
record = SimpleNamespace(object_key="html/reports/mold_new.html", filename="mold_new.html")
|
||||
monkeypatch.setattr(rustfs_manager, "is_connected", True)
|
||||
monkeypatch.setattr(rustfs_manager, "download_report_artifact", fake_download_report)
|
||||
monkeypatch.setattr(rustfs_manager, "download_file", fake_download_file)
|
||||
monkeypatch.setattr(html_report_router, "db_manager", _FakeDBManager(record))
|
||||
|
||||
resp = await client.get("/html/mold_new.html")
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == b"<html>raw-by-report-key</html>"
|
||||
|
||||
|
||||
# ── 链路 3:节点本地 html_output(存量兜底) ─────────────────────
|
||||
|
||||
|
||||
async def test_falls_back_to_local_dir(client, tmp_path):
|
||||
local_dir = tmp_path / "html_output"
|
||||
local_dir.mkdir()
|
||||
(local_dir / "mold_local.html").write_text("<html>local</html>", encoding="utf-8")
|
||||
|
||||
resp = await client.get("/html/mold_local.html")
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == b"<html>local</html>"
|
||||
|
||||
|
||||
async def test_json_media_type_from_extension(client, tmp_path):
|
||||
local_dir = tmp_path / "html_output"
|
||||
local_dir.mkdir()
|
||||
(local_dir / "mold_a_data.json").write_bytes(b"{}")
|
||||
|
||||
resp = await client.get("/html/mold_a_data.json")
|
||||
assert resp.status_code == 200
|
||||
assert "application/json" in resp.headers["content-type"]
|
||||
|
||||
|
||||
# ── 未命中与防护 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_404_when_all_sources_miss(client):
|
||||
resp = await client.get("/html/missing.html")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_path_traversal_rejected(client):
|
||||
resp = await client.get("/html/%2e%2e/secret.txt")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_hidden_filename_rejected(client):
|
||||
resp = await client.get("/html/%2e%2eetc%2fpasswd")
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,76 @@
|
||||
"""D3 模型拆分归属保护(批次 4,2026-09-17)。
|
||||
|
||||
锁定三个拆分成果:
|
||||
1. 三包模型全量注册后 mapper 可配置、31 表齐全;
|
||||
2. 单模块部署(inventory-only / moldinsight-only + auth)独立配置 mapper 成功——
|
||||
跨模块 ORM relationship 已清零,任何一侧不注册对方模型也能工作;
|
||||
3. 旧 shared.models.database 模块已删除且无兼容 facade(诚实原则:不留假象)。
|
||||
"""
|
||||
import importlib
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SRC = str(Path(__file__).resolve().parent.parent / "src")
|
||||
|
||||
EXPECTED_TABLES = {
|
||||
# identity(shared.models.identity)
|
||||
"users", "roles", "permissions", "user_roles", "role_permissions",
|
||||
"user_activities", "system_logs",
|
||||
# moldinsight.models
|
||||
"stp_files", "geometry_data", "mesh_data", "html_files", "processing_tasks",
|
||||
"mold_cavity_data", "feature_detections", "design_recommendations", "analysis_metrics",
|
||||
# inventory.models
|
||||
"products", "product_materials", "material_price_history", "material_suppliers",
|
||||
"suppliers", "customers", "warehouses", "inventory", "stock_movements",
|
||||
"purchase_orders", "purchase_order_items", "sales_orders", "sales_order_items",
|
||||
"finance_transactions", "finance_allocations",
|
||||
}
|
||||
|
||||
|
||||
def test_full_registration_covers_all_31_tables():
|
||||
import shared.models.identity # noqa: F401
|
||||
import moldinsight.models # noqa: F401
|
||||
import inventory.models # noqa: F401
|
||||
from sqlalchemy.orm import configure_mappers
|
||||
|
||||
from shared.models.base import Base
|
||||
|
||||
configure_mappers()
|
||||
assert set(Base.metadata.tables) == EXPECTED_TABLES
|
||||
|
||||
|
||||
def test_single_module_deployments_configure_mappers_independently():
|
||||
"""单模块注册子进程验证:inventory-only 与 moldinsight-only(含 auth identity)
|
||||
均可在不 import 对方业务模型的情况下 configure_mappers 成功。
|
||||
用子进程隔离,避免污染本进程的 mapper 注册表。"""
|
||||
code = (
|
||||
"import sys; sys.path.insert(0, r'%s')\n"
|
||||
"from sqlalchemy.orm import configure_mappers\n"
|
||||
"%s\n"
|
||||
"configure_mappers()\n"
|
||||
"print('ok')\n"
|
||||
)
|
||||
cases = [
|
||||
# inventory-only:inventory 模型 + auth 必带的 identity
|
||||
"import inventory.models, shared.models.identity",
|
||||
# moldinsight-only:moldinsight 模型 + auth 必带的 identity
|
||||
"import moldinsight.models, shared.models.identity",
|
||||
]
|
||||
for imports in cases:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-c", code % (SRC, imports)],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, f"{imports} 配置失败:\n{proc.stderr}"
|
||||
assert proc.stdout.strip().endswith("ok")
|
||||
|
||||
|
||||
def test_legacy_database_module_is_gone():
|
||||
"""旧 shared.models.database 已物理删除,无兼容 facade。"""
|
||||
try:
|
||||
importlib.import_module("shared.models.database")
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("shared.models.database 仍可导入——拆分后不允许残留兼容 facade")
|
||||
@@ -0,0 +1,116 @@
|
||||
"""OCC 方案 B(常驻进程池)契约测试,见 docs/topics/performance/OCC_THROUGHPUT.md。
|
||||
|
||||
覆盖:spawn 子进程 + 管道往返、操作错误回传、超时换新补位、真实 OCC 解析
|
||||
(盒体 STP 端到端)。OCC(pythonocc)仅 conda 环境提供,无 OCC 环境自动跳过。
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("OCC", reason="需要 pythonocc 运行 OCC 进程池测试")
|
||||
|
||||
from moldinsight.services.occ_process_pool import OccProcessPool
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _child_pythonpath():
|
||||
"""spawn 子进程不继承 pytest 的 sys.path,需经 PYTHONPATH 传递 src 目录。"""
|
||||
src_dir = str(Path(__file__).resolve().parent.parent / "src")
|
||||
prev = os.environ.get("PYTHONPATH", "")
|
||||
os.environ["PYTHONPATH"] = src_dir if not prev else f"{src_dir}{os.pathsep}{prev}"
|
||||
yield
|
||||
if prev:
|
||||
os.environ["PYTHONPATH"] = prev
|
||||
else:
|
||||
os.environ.pop("PYTHONPATH", None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def pool():
|
||||
_pool = OccProcessPool()
|
||||
try:
|
||||
yield _pool
|
||||
finally:
|
||||
await _pool.shutdown()
|
||||
|
||||
|
||||
async def test_ping_roundtrip(pool):
|
||||
result = await pool.run("ping", {})
|
||||
assert result == {"pong": True}
|
||||
|
||||
|
||||
async def test_unknown_op_raises(pool):
|
||||
with pytest.raises(RuntimeError, match="未知 OCC 操作"):
|
||||
await pool.run("no_such_op", {})
|
||||
|
||||
|
||||
async def test_child_error_surfaces(pool):
|
||||
# parse_stp 指向不存在的文件:子进程内抛错,父进程应收到 RuntimeError
|
||||
with pytest.raises(RuntimeError):
|
||||
await pool.run("parse_stp", {"stp_path": "D:/no/such/file.stp"})
|
||||
|
||||
|
||||
async def test_timeout_replaces_worker(pool):
|
||||
# 子进程挂 15s,父进程 1s 超时 → 换新补位后池仍可用
|
||||
with pytest.raises(Exception, match=".*"):
|
||||
await pool.run("sleep", {"seconds": 15}, timeout=1)
|
||||
# 新 worker 上 ping 正常
|
||||
result = await pool.run("ping", {})
|
||||
assert result == {"pong": True}
|
||||
|
||||
|
||||
def _write_box_stp(path: Path) -> Path:
|
||||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||
from OCC.Core.IFSelect import IFSelect_RetDone
|
||||
from OCC.Core.STEPControl import STEPControl_AsIs, STEPControl_Writer
|
||||
from OCC.Core.gp import gp_Pnt
|
||||
|
||||
box = BRepPrimAPI_MakeBox(gp_Pnt(0, 0, 0), 100.0, 60.0, 40.0).Shape()
|
||||
writer = STEPControl_Writer()
|
||||
writer.Transfer(box, STEPControl_AsIs)
|
||||
assert writer.Write(str(path)) == IFSelect_RetDone
|
||||
return path
|
||||
|
||||
|
||||
async def test_parse_stp_end_to_end(pool, tmp_path):
|
||||
stp_path = _write_box_stp(tmp_path / "box.stp")
|
||||
geometry = await pool.run("parse_stp", {"stp_path": str(stp_path)})
|
||||
assert geometry["volume"] == pytest.approx(100 * 60 * 40, rel=0.01)
|
||||
assert "bounding_box" in geometry
|
||||
|
||||
|
||||
async def test_generate_cavity_persists_export_steps(pool, tmp_path):
|
||||
"""最复杂的迁移点:分模 + 方案形状 STEP 导出全部在子进程内完成,
|
||||
TopoDS 不跨进程传输,export_manifest 携带落盘文件清单。"""
|
||||
from moldinsight.services.material_service import MaterialService
|
||||
|
||||
stp_path = _write_box_stp(tmp_path / "box.stp")
|
||||
export_dir = tmp_path / "exports"
|
||||
result = await pool.run(
|
||||
"generate_cavity",
|
||||
{
|
||||
"stp_path": str(stp_path),
|
||||
"task_id": "test-task",
|
||||
"material": dict(MaterialService.get_material("ABS")),
|
||||
"is_foam_material": False,
|
||||
"process_params": None,
|
||||
"export_out_dir": str(export_dir),
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
plan_result = result["plan_result"]
|
||||
schemes = plan_result["candidate_schemes"]
|
||||
assert 1 <= len(schemes) <= 3
|
||||
assert plan_result["best_scheme_id"] == schemes[0]["scheme_id"]
|
||||
# TopoDS 形状不得跨进程(子进程已 pop 掉 _export_shapes)
|
||||
assert "_export_shapes" not in plan_result
|
||||
|
||||
manifest = result["export_manifest"]
|
||||
assert manifest is not None
|
||||
assert manifest["schemes"]
|
||||
best = manifest["schemes"][plan_result["best_scheme_id"]]
|
||||
assert best["total_files"] >= 1
|
||||
# 落盘文件真实存在于子进程写入的 export_out_dir
|
||||
assert any((export_dir / f.get("relative_path", "")).exists() for f in best["files"])
|
||||
@@ -40,9 +40,9 @@ async def test_storage_writes_flush_but_never_commit():
|
||||
"""D9:数据本体写方法仅 flush;commit 由编排层/请求侧负责。"""
|
||||
pytest.importorskip("minio")
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
|
||||
from moldinsight.services.task_storage_service import TaskStorageService
|
||||
|
||||
svc = StorageIntegrationService()
|
||||
svc = TaskStorageService()
|
||||
session = AsyncMock(spec=AsyncSession)
|
||||
# update_task_parameters:select 返回 None(任务不存在)→ 直接 return
|
||||
result_mock = MagicMock()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user