后端设计治理:批次 0-4 全部完成(安全/部署/一致性/结构/架构)

按 ROADMAP §3.1 治理批次推进的后端设计审查整改:

- 批次 0(安全):/api/status/{task_id} 补 JWT 鉴权与任务归属校验;
  pythonocc_available 真实探测;bcrypt 超 72 字节显式拒绝;
  SECRET_KEY/RUSTFS_* 惰性校验,代码侧弱默认移除
- 批次 1(部署正确性):主处理链路改走 RustFS(分派入参 stp_file_id 化,
  worker 按 object_key 下载);AUTO_MIGRATE 开关 + 迁移目录 alembic/→migrations/
  修复包遮蔽(自动迁移此前从未真正生效);OCC 镜像改 conda 原生执行 +
  基础镜像 tag 锁定;compose 关键项改 ${VAR:?} 强制显式配置
- 批次 2(任务一致性):删除 Redis 进程内存回退,PG 为任务状态单一事实源;
  批量元数据入库(processing_tasks.batch_id,迁移 a3f8c2d91e47);
  型腔失败任务标 failed 不再静默 completed;事务边界收口
  (数据本体写 flush-only、失败先回滚再置 failed、进度更新保留即时 commit)
- 批次 3(API 与代码结构):592 行 advanced_router 拆为 design/cost/machining/
  export 四子路由,请求体全量 Pydantic 化;ROUTE_MODULES + route_registry
  (/api/health 呈现 degraded,DEBUG fail fast);纯计算端点统一 to_thread;
  StorageIntegrationService 按职责三拆;MAX_FILE_SIZE 接线生效、
  celery 复用 Settings.redis_url;管理员重置密码改 JSON body(端到端断裂修复);
  openapi.json 重导出(76 paths)+ 前端 gen:api
- 批次 4(架构演进):共享 ORM 按模块拆分(shared/models/base.py + identity.py、
  moldinsight/models/、inventory/models/,删除三条无使用方的跨模块
  relationship,跨模块桥接收敛为裸 FK 硬规则,无兼容 facade);
  OCC executor 重建补 cancel_futures=True(消除旧队列被慢恢复线程
  并行消化的数据竞争);OCC 吞吐方案设计先行
  (docs/topics/performance/OCC_THROUGHPUT.md);顺手清偿 D15
  (vite.config.ts 未用参数致 npm run build 失败)

测试基线:125 passed, 2 skipped(pytest + sqlite+aiosqlite;归属边界、
路由契约、配置治理、鉴权回归等随批新增)
文档同步:STATUS / TECH_DEBT / ROADMAP / ARCHITECTURE / API_CONTRACT /
OPERATIONS / AGENTS

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-17 16:15:49 +08:00
parent 4537faf2c4
commit 0e6b3b1811
82 changed files with 6513 additions and 3709 deletions
+16 -6
View File
@@ -40,15 +40,20 @@ src/
unified.py # 双模块统一入口:/api 挂 moldinsight + inventory,当前推荐后端 unified.py # 双模块统一入口:/api 挂 moldinsight + inventory,当前推荐后端
moldinsight/ # 【模具分析模块】 moldinsight/ # 【模具分析模块】
api/ api/
__init__.py # router 聚合:_safe_include 按序挂子 router,失败仅 WARNING 跳过;debug_router 仅 settings.DEBUG 挂载 __init__.py # router 聚合:ROUTE_MODULES 清单 + _safe_include 挂载,失败登记 route_registry(/api/health 呈现 degraded,DEBUG 下 fail fast);debug_router 仅 settings.DEBUG 挂载
health_router.py # /api/health 模块健康检查 route_registry.py # 路由装载注册表(loaded / failed / disabled,health_router 引用)
health_router.py # /api/health 模块健康检查(含真实 pythonocc 探测与路由装载状态)
upload_router.py # /api/upload STEP/STP 上传 upload_router.py # /api/upload STEP/STP 上传
batch_router.py # /api/batch-upload 批量上传与分析 batch_router.py # /api/batch-upload 批量上传与分析
task_router.py # /api/status/{task_id} 任务状态查询 task_router.py # /api/status/{task_id} 任务状态查询
history_router.py # /api/history 分析历史与结果文件 history_router.py # /api/history 分析历史与结果文件
cam_router.py # /api/cam/plan CAM 加工方案 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/* 铝价(当前为模拟/参考数据,见 TECH_DEBT D2) aluminum_price_routes.py # /api/aluminum-price/* 铝价(当前为模拟/参考数据,见 TECH_DEBT D2)
advanced_router.py # 导出 / 成本估算 / 设计分析等高级接口(技术债 D1:待拆分 + 请求模型化)
debug_router.py # /api/debug/tasks 全量任务 dump(仅 DEBUG 模式注册,仍需登录) debug_router.py # /api/debug/tasks 全量任务 dump(仅 DEBUG 模式注册,仍需登录)
core/ # 几何与方案核心算法(OCC 重依赖区) core/ # 几何与方案核心算法(OCC 重依赖区)
stp_parser.py # STEP/STP 解析 stp_parser.py # STEP/STP 解析
@@ -79,7 +84,10 @@ src/
material_service.py # 物料价格服务 material_service.py # 物料价格服务
aluminum_price_service.py # 铝价服务(模拟数据) aluminum_price_service.py # 铝价服务(模拟数据)
llm_service.py # LLM 增强分析(可选,OpenAI 兼容) 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/ storage/
rustfs_storage.py # RustFS/MinIO 客户端封装 rustfs_storage.py # RustFS/MinIO 客户端封装
init_storage.py # 存储初始化 init_storage.py # 存储初始化
@@ -87,13 +95,15 @@ src/
api/ # 每域一个 routes 文件:product / supplier / customer / warehouse / inventory / stock_movement / purchase_order / sales_order / purchase_demand / finance / dashboard / material api/ # 每域一个 routes 文件:product / supplier / customer / warehouse / inventory / stock_movement / purchase_order / sales_order / purchase_demand / finance / dashboard / material
schemas/ # 每域一个 Pydantic schema 文件(与 api 一一对应) schemas/ # 每域一个 Pydantic schema 文件(与 api 一一对应)
services/ # 领域服务:inventory / purchase_order / sales_order / finance / purchase_demand / stock_movement services/ # 领域服务:inventory / purchase_order / sales_order / finance / purchase_demand / stock_movement
models/ # inventory 域 ORM(catalog / warehouse / trading / finance 四文件,共 15 表)
utils.py utils.py
shared/ # 【共享平台层:只放真正跨模块复用的基础能力,勿堆业务】 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 / /html mount
config/settings.py # Settings 单例:dotenv + os.getenv;DB_*/SECRET_KEY 惰性校验无默认 config/settings.py # Settings 单例:dotenv + os.getenv;DB_*/SECRET_KEY 惰性校验无默认
database/database.py # async engine / session / get_db_session database/database.py # async engine / session / get_db_session
database/init_db.py # 建表与管理员种子 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 模型 models/schemas.py # 共享 Pydantic 模型
services/auth_routes.py # /api/auth/* 认证用户角色权限路由 services/auth_routes.py # /api/auth/* 认证用户角色权限路由
services/auth_service.py # JWT 签发校验 + get_current_active_user 依赖 services/auth_service.py # JWT 签发校验 + get_current_active_user 依赖
+4 -1
View File
@@ -1,3 +1,6 @@
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"] CMD ["celery", "-A", "celery_app", "worker", "--workdir=/app/src", "--concurrency=2", "--loglevel=info"]
+10 -7
View File
@@ -15,7 +15,7 @@
| moldinsight-only | `/api/*`(moldinsight)+ `/api/auth/*` + `/health` | | moldinsight-only | `/api/*`(moldinsight)+ `/api/auth/*` + `/health` |
| inventory-only | `/api/*`(inventory)+ `/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) 按域静态聚合。 - 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) 挂载,三种形态共用。 - 认证路由来自 [shared/services/auth_routes.py](../src/shared/services/auth_routes.py),由 [app_factory](../src/shared/app_factory.py) 挂载,三种形态共用。
@@ -36,7 +36,7 @@
|---|---|---| |---|---|---|
| 登录 | `/api/auth/login`、`/api/auth/login/json`、`/api/auth/logout` | auth_routes.py | | 登录 | `/api/auth/login`、`/api/auth/login/json`、`/api/auth/logout` | auth_routes.py |
| 当前用户 | `/api/auth/me` | 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 | | 角色权限 | `/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(模具分析) ### 3.2 moldinsight(模具分析)
@@ -47,11 +47,14 @@
| 批量分析 | `/api/batch-upload`、`/api/batch/{batch_id}`(聚合状态以 PG 为准;响应含 `current_step`;他人批次 403、不存在 404) | batch_router.py | | 批量分析 | `/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/status/{task_id}`(需登录;仅任务所有者可访问,他人/无主任务 403,不存在 404) | task_router.py |
| 历史结果 | `/api/history`、`/api/history/{filename}` | history_router.py | | 历史结果 | `/api/history`、`/api/history/{filename}` | history_router.py |
| CAM | `/api/cam/plan` | cam_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/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 | | 调试(仅 DEBUG) | `/api/debug/tasks` | debug_router.py |
| 导出/估算/设计等高级接口 | 见 `openapi.json` 对应路径 | advanced_router.py(技术债 D1:待拆分) |
### 3.3 inventory(进销存) ### 3.3 inventory(进销存)
@@ -93,12 +96,12 @@
- `frontend/src/types/api.ts` 是**生成物,禁止手改**;前端代码类型引用它。 - `frontend/src/types/api.ts` 是**生成物,禁止手改**;前端代码类型引用它。
- 三步缺一即前后端契约漂移(硬约束,见 [AGENTS.md](../AGENTS.md) §2)。 - 三步缺一即前后端契约漂移(硬约束,见 [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. 契约变更规则 ## 5. 契约变更规则
- 新增接口先定模块归属(moldinsight / inventory / shared auth),再写路由;返回结构、路径、鉴权发生变化时,同步更新本文相应表格。 - 新增接口先定模块归属(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) 日志条目中记录,并确认前端同仓同步修改。 - 破坏性变更(删字段 / 改语义)需在 [STATUS.md](STATUS.md) 日志条目中记录,并确认前端同仓同步修改。
## 6. 前端消费约定 ## 6. 前端消费约定
+14 -3
View File
@@ -159,6 +159,8 @@ geMoldInsight/
典型桥接关系示例: 典型桥接关系示例:
- `STPFile.product_id -> Product.id` - `STPFile.product_id -> Product.id`
桥接只允许**裸 FK 列**(字符串表名),**不允许跨模块 ORM relationship**——单模块部署下另一模块的模型类可能未注册,跨模块 relationship 会让 mapper 配置直接失败(2026-09-17 批次 4 起为硬规则,原三条跨模块 relationship 均无使用方,已删除;对象化查询由使用方显式 select)。
### 5.2 模块边界优先于“临时方便” ### 5.2 模块边界优先于“临时方便”
新增逻辑时,应优先放入对应业务模块,而不是继续堆进 `shared`。 新增逻辑时,应优先放入对应业务模块,而不是继续堆进 `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 组合职责偏重 ### 6.2 app factory 组合职责偏重
@@ -199,8 +208,10 @@ geMoldInsight/
- 存储方向: - 存储方向:
- [topics/storage/RUSTFS_STORAGE.md](topics/storage/RUSTFS_STORAGE.md) - [topics/storage/RUSTFS_STORAGE.md](topics/storage/RUSTFS_STORAGE.md)
- [topics/storage/STORAGE_SETUP.md](topics/storage/STORAGE_SETUP.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)。 阶段性任务清单、迁移计划、历史总结等文档会逐步迁入 [archive/README.md](archive/README.md)。
+5 -1
View File
@@ -16,7 +16,8 @@
- `SECRET_KEY`:JWT 签名密钥,**无默认**;生产必须 ≥32 字符强随机。 - `SECRET_KEY`:JWT 签名密钥,**无默认**;生产必须 ≥32 字符强随机。
- `ADMIN_PASSWORD`:初始管理员密码,**无默认**;首次建库前必须设置。 - `ADMIN_PASSWORD`:初始管理员密码,**无默认**;首次建库前必须设置。
- `RUSTFS_*`:对象存储(兼容 `MINIO_*` 别名写法);本地开发缺省值仅为占位,连不上会在用到存储的链路报错。 - `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`:逗号分隔白名单;不设默认放行 `*`,**生产必须显式设置**。 - `CORS_ORIGINS`:逗号分隔白名单;不设默认放行 `*`,**生产必须显式设置**。
- `LOG_FORMAT`:`json`(生产默认,结构化)/ `text`(开发人可读);`LOG_LEVEL`:DEBUG/INFO/WARNING/ERROR。 - `LOG_FORMAT`:`json`(生产默认,结构化)/ `text`(开发人可读);`LOG_LEVEL`:DEBUG/INFO/WARNING/ERROR。
- `DEBUG`:`true` 时额外注册 `/api/debug/*` 调试路由(仍需登录),**生产必须为 false**。 - `DEBUG`:`true` 时额外注册 `/api/debug/*` 调试路由(仍需登录),**生产必须为 false**。
@@ -48,6 +49,9 @@ Celery worker(moldinsight 异步分析链路;本地从 `src` 目录跑,与
cd src && celery -A celery_app worker --concurrency=2 --loglevel=info cd src && celery -A celery_app worker --concurrency=2 --loglevel=info
``` ```
- `--concurrency=N` 即 OCC 并行分析数(每个 prefork 子进程持一个串行 OCC 通道,见 [topics/performance/OCC_THROUGHPUT.md](topics/performance/OCC_THROUGHPUT.md) 方案 A);调大时预算好每子进程内存与 PG 连接数。
- 建议生产加 `--max-tasks-per-child=M`(如 50):子进程定期重启,兜底回收 OCC 超时后滞留的线程。
前端: 前端:
```bash ```bash
+7 -7
View File
@@ -45,7 +45,7 @@ geMoldInsight 已从历史单体逐步演进为“双业务模块 + 共享平台
重点方向: 重点方向:
- `advanced_router` 拆分与请求模型规范化 - ~~`advanced_router` 拆分与请求模型规范化~~(2026-09-17 批次 3 完成)
- 模具分析链路的结构继续收口 - 模具分析链路的结构继续收口
- OCC 依赖场景下的契约测试/集成测试继续补齐 - OCC 依赖场景下的契约测试/集成测试继续补齐
@@ -88,13 +88,13 @@ geMoldInsight 已从历史单体逐步演进为“双业务模块 + 共享平台
### P1:moldinsight API 结构整理 ### P1:moldinsight API 结构整理
- 拆分 `advanced_router` - ~~拆分 `advanced_router`~~(2026-09-17 批次 3 完成)
- 为高频接口引入 Pydantic 请求模型 - ~~为高频接口引入 Pydantic 请求模型~~(2026-09-17 批次 3 完成)
- 继续减少 `request.json()` 风格手动解析 - 继续减少 `request.json()` 风格手动解析(存量端点已清零,新增接口守此约定)
### P2:shared/platform 边界继续收敛 ### P2:shared/platform 边界继续收敛
- 梳理共享 ORM 与业务模型的归属 - ~~梳理共享 ORM 与业务模型的归属~~(2026-09-17 批次 4 完成:ORM 已按模块拆分,跨模块只许裸 FK)
- 继续减少 shared 直接承担业务组合逻辑 - 继续减少 shared 直接承担业务组合逻辑
- 为后续平台层命名与目录调整准备条件 - 为后续平台层命名与目录调整准备条件
@@ -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 | | 批次 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 | | 批次 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 | | 批次 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 的 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 批次计划**全部执行完毕**(遗留:D13 的 pip 全量锁文件随下次镜像构建补齐;D11 留待后续批次,正确性已由批次 1 共享卷兜底;批次 4 遗留中期项——OCC 进程池方案 B 实施待独立排期,见 [TECH_DEBT.md](TECH_DEBT.md) D10 与 [topics/performance/OCC_THROUGHPUT.md](topics/performance/OCC_THROUGHPUT.md))。完成明细见 [STATUS.md](STATUS.md) 与 [TECH_DEBT.md](TECH_DEBT.md) §2.5–2.8。后续优先项回到 §3 P2 / P3 与主线方向。
--- ---
+4
View File
@@ -3,6 +3,10 @@
> 文档定位:**唯一的「现在到哪了」**。README / AGENTS / 各主文档只链接到这里,不复制状态内容。 > 文档定位:**唯一的「现在到哪了」**。README / AGENTS / 各主文档只链接到这里,不复制状态内容。
> 维护规则:每完整完成一个需求,**倒序在本文顶部加一条**(日期 + 主题 + 关键事实);其余主文档(架构 / 规划 / 技术债 / 部署)维护各自的"当前有效说法",本文只记录"什么时候做到了哪一步"。维护规则出处见根目录 [AGENTS.md](../AGENTS.md)。 > 维护规则:每完整完成一个需求,**倒序在本文顶部加一条**(日期 + 主题 + 关键事实);其余主文档(架构 / 规划 / 技术债 / 部署)维护各自的"当前有效说法",本文只记录"什么时候做到了哪一步"。维护规则出处见根目录 [AGENTS.md](../AGENTS.md)。
> 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(**批次 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)。) > 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)。)
+51 -50
View File
@@ -55,29 +55,35 @@
- 持久化事务边界收口:数据本体分阶段原子提交、失败先回滚再置 failed(原 D9) - 持久化事务边界收口:数据本体分阶段原子提交、失败先回滚再置 failed(原 D9)
- D11(HTML 双写双读)本批未动:正确性已由共享卷兜底,RustFS 单一来源留待后续批次 - 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. 当前活跃技术债 ## 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 可读性差、参数校验不统一。
- 路由边界不清晰
- OpenAPI 可读性差
- 接口参数校验不统一
- 后续继续扩展时维护成本高
建议:
- 拆分为 export / design / cost 等子路由
- 高优先级请求体改为 Pydantic 模型
优先级:**P1**
### D2. 铝价模拟数据未显式标注来源 ### D2. 铝价模拟数据未显式标注来源
@@ -93,21 +99,17 @@
优先级:**P2** 优先级:**P2**
### D3. shared/platform 边界仍需继续收敛 ### D3. shared/platform 边界仍需继续收敛 —— ORM 归属已清偿(2026-09-17,批次 4)
现状: 已完成部分:
- `shared` 同时承担平台基础能力与部分历史耦合职责 - 共享 ORM(原最强耦合点)按模块拆分:base / identity(shared)+ moldinsight/models + inventory/models;跨模块只允许裸 FK,单模块部署 mapper 可独立配置(详见 §2.8 与 [ARCHITECTURE.md](ARCHITECTURE.md) §6.1)
- 共享 ORM 与 app factory 仍是主要耦合点 - 旧 `shared/models/database.py` 物理删除,无兼容 facade;归属边界由 [tests/test_model_ownership.py](../tests/test_model_ownership.py) 锁定
影响: 仍保留的收敛方向(低优先级,随实际重构推进):
- 模块边界认知成本较高 - [app_factory.py](../src/shared/app_factory.py) 组合职责偏重(ARCHITECTURE §6.2)
- 新增逻辑容易继续堆入 shared - identity / platform 的边界语义(ROADMAP §2.1)
建议: 优先级:**P3**(剩余部分)
- 继续从文档、目录语义、职责边界上推进收敛
- 在后续实际重构中优先避免把业务逻辑继续沉入 shared
优先级:**P2**
### D4. 文档现状 / 规划 / 历史混放 ### D4. 文档现状 / 规划 / 历史混放
@@ -171,19 +173,17 @@
~~原现状 / 影响~~:各存储方法内部自行 commit,型腔保存失败留半成品数据且任务仍 completed。 ~~原现状 / 影响~~:各存储方法内部自行 commit,型腔保存失败留半成品数据且任务仍 completed。
### D10. OCC 全局单线程串行 + 超时重建泄漏线程 ### D10. OCC 全局单线程串行 + 超时重建泄漏线程 —— 泄漏治理已落地,吞吐方案设计先行(2026-09-17,批次 4)
现状: 已完成:
- 所有 OCC 操作经 `max_workers=1` executor 串行([processing_service.py](../src/moldinsight/services/processing_service.py)),celery 并发无法扩展 OCC 吞吐 - `_reset_occ_executor` 补 `cancel_futures=True`:排队任务随重建丢弃——旧实现下"慢恢复"的旧线程会继续消化旧队列,与新 executor 并发操作非线程安全的 OCC;修复后残留收敛为"运行中线程滞留 1 个"(C++ 栈 Python 层不可杀,属客观边界)
- 超时重建 executor 每次泄漏 1 个线程,长期运行只涨不降 - 吞吐与隔离方案定稿:[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` 改操作名+payload 契约、常驻 OCC 进程池 kill-on-timeout 根治泄漏,待独立批次)
影响: 保留为已知约束(非待修缺陷):
- 一个长耗时型腔生成阻塞全部几何处理;线程随故障累积 - 单进程内 OCC 串行是正确性要求(OCC 非线程安全),吞吐扩展走多进程(方案 A/B)
- 线程级超时的滞留线程由进程边界回收,根治依赖方案 B 落地
建议: 优先级:**P3**(中期方案 B 实施前维持观察)
- 记录吞吐上限为已知约束;线程泄漏治理方案设计先行(见 [ROADMAP.md](ROADMAP.md) §3.1 批次 4)
优先级:**P2**
### D11. HTML 报告本地磁盘与 RustFS 双写双读 ### D11. HTML 报告本地磁盘与 RustFS 双写双读
@@ -216,20 +216,21 @@
优先级:**P2**(剩余锁文件部分) 优先级:**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 显式报错) - ~~RUSTFS_* 弱默认~~(批次 0 代码侧去除);~~compose 侧 SECRET_KEY / ADMIN_PASSWORD 弱默认~~(批次 1 改 `${VAR:?}` 强制显式配置)
- MAX_FILE_SIZE 配置项未被使用([file_handler.py](../src/shared/utils/file_handler.py) 硬编码 50MB) - ~~MAX_FILE_SIZE 死配置~~(批次 3):upload/batch 路由的 `FileHandler` 接 `settings.UPLOAD_DIR / settings.MAX_FILE_SIZE`(此前处理器硬编码 50MB;接线后默认上限变为 100MB,以 .env 为准)
- [celery_app.py](../src/celery_app.py) 重新 load_dotenv 并手拼 REDIS URL,与 settings 两份实现 - ~~celery_app 重复拼装~~(批次 3):删除自行 load_dotenv + 手拼 REDIS URL,broker/backend 复用 `Settings.redis_url`(新增 property,连接串唯一拼装点)
影响: 优先级:已清偿
- 违背"关键项不兜底"硬约束;配置行为与文档不一致
建议: ### D15. 前端 `npm run build` 因既有 TS 错误失败(批次 3 连带发现)—— 已清偿(2026-09-17,批次 4)
- 去掉弱默认、对齐或删除死配置、celery_app 复用 settings
优先级:**P2** 修复内容:
- [vite.config.ts](../frontend/vite.config.ts) 删除未使用的回调参数 `mode`(TS6133 源头,一行修复);`vue-tsc -b` 实测通过,生产构建链路恢复
~~原现状 / 影响~~:`vue-tsc -b`(`npm run build` 的类型检查步)因既有 TS6133 失败,前端无法出生产包(与批次 3 改动无关的既有问题)。
--- ---
@@ -238,14 +239,14 @@
> 注:2026-09-15 后端设计审查后,治理**执行顺序**以 [ROADMAP.md](ROADMAP.md) §3.1 批次计划为准(批次 0–4);D5–D14 的批次归属见该表。本节保留原有优先项作为补充说明。 > 注:2026-09-15 后端设计审查后,治理**执行顺序**以 [ROADMAP.md](ROADMAP.md) §3.1 批次计划为准(批次 0–4);D5–D14 的批次归属见该表。本节保留原有优先项作为补充说明。
### 第一优先级 ### 第一优先级
1. `advanced_router` 拆分 1. ~~`advanced_router` 拆分~~(2026-09-17 批次 3 完成,见 D1)
2. 高优先级接口补 Pydantic 请求模型 2. ~~高优先级接口补 Pydantic 请求模型~~(2026-09-17 批次 3 完成)
3. 文档主骨架收口并减少重复说明 3. 文档主骨架收口并减少重复说明
### 第二优先级 ### 第二优先级
4. 铝价模拟数据来源显式化 4. 铝价模拟数据来源显式化
5. 部署历史文档归档 5. 部署历史文档归档
6. shared/platform 语义继续收敛 6. shared/platform 语义继续收敛(共享 ORM 归属已于批次 4 清偿,剩余为 app_factory 组合职责等,见 D3)
--- ---
+70
View File
@@ -0,0 +1,70 @@
# 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 解析 / 布尔运算 / 三角化 / 倒扣检测等)统一经 [processing_service.py](../../../src/moldinsight/services/processing_service.py) 的 `run_occ` 投入 **进程内 `ThreadPoolExecutor(max_workers=1)`** 串行执行——OCC 非线程安全,串行是正确性要求,不是实现偷懒。
- Celery worker 为 prefork 模式,`processing_service` 是模块级单例:**每个 worker 子进程各持一个串行 OCC 通道**。因此 OCC 并行度 = worker 子进程数,与 web 进程数无关(web 侧 `run_occ` 仅服务于轻量同步调用,如倒扣检测)。
- 型腔生成超时后 `_reset_occ_executor` 重建 executor;已在运行的 C++ 线程在 Python 层**不可杀**,每次超时滞留 1 个线程(2026-09-17 起排队任务随 `cancel_futures=True` 丢弃,见 §4)。
### 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-17 完成;部署参数随下次 compose/镜像评审落地 |
| 中期 | 方案 B:`run_occ(op_name, payload)` 接口演进 + 常驻进程池,kill-on-timeout 根治泄漏 | 待排期(独立批次,工作量集中在调用点迁移与序列化设计) |
| 长期 | 方案 C:sidecar,仅在出现独立伸缩需求时启动 | 暂不启动 |
## 4. 本次已落地的缓解(2026-09-17,批次 4)
`_reset_occ_executor` 的 `shutdown(wait=False)` 补 `cancel_futures=True`。这不只是卫生问题:旧实现下旧 executor 的**排队任务不会消失**——若挂死线程后来"慢恢复",旧线程会继续消化旧队列,与新 executor **并发操作非线程安全的 OCC**(数据竞争 / 崩溃风险)。补参后排队任务即被丢弃,残留问题收敛为"运行中线程滞留 1 个",由方案 A 的进程回收兜底。
+1 -1
View File
@@ -240,7 +240,7 @@ const resetPassword = async (user: UserItem) => {
try { try {
await apiRequest(`/api/auth/users/${user.id}/reset-password`, { await apiRequest(`/api/auth/users/${user.id}/reset-password`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(newPassword), body: JSON.stringify({ new_password: newPassword }),
}) })
addNotification('密码已重置', 'success') addNotification('密码已重置', 'success')
} catch (e) { } catch (e) {
+1665 -959
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { resolve } from 'path' import { resolve } from 'path'
export default defineConfig(({ mode }) => ({ export default defineConfig(() => ({
plugins: [vue()], plugins: [vue()],
resolve: { resolve: {
alias: { alias: {
+6 -3
View File
@@ -2,7 +2,7 @@
- 从 shared.config.settings 读取 DB 配置,构造同步 URL(psycopg2)供 alembic 使用 - 从 shared.config.settings 读取 DB 配置,构造同步 URL(psycopg2)供 alembic 使用
(项目运行时用 asyncpg,但 alembic 是同步库,需 psycopg2) (项目运行时用 asyncpg,但 alembic 是同步库,需 psycopg2)
- target_metadata 指向 shared.models.database.Base.metadata - target_metadata 指向 shared.models.base.Base.metadata(全量模型注册见下方 import)
- 支持 ALEMBIC_URL 环境变量覆盖(用于离线/空库生成初始迁移,如 sqlite:///empty.db) - 支持 ALEMBIC_URL 环境变量覆盖(用于离线/空库生成初始迁移,如 sqlite:///empty.db)
""" """
from logging.config import fileConfig from logging.config import fileConfig
@@ -18,8 +18,11 @@ project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root / "src")) sys.path.insert(0, str(project_root / "src"))
from shared.config.settings import settings # noqa: E402 from shared.config.settings import settings # noqa: E402
from shared.models.database import Base # noqa: E402 from shared.models.base import Base # noqa: E402
import shared.models.database # noqa: E402,F401 # 导入所有模型,确保 metadata 注册 # 导入全部三包模型,确保 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 config = context.config
+1824 -613
View File
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 sqlalchemy import select
from database.database import db_manager from database.database import db_manager
from models.database import MoldCavityData from moldinsight.models import MoldCavityData
from storage.rustfs_storage import rustfs_manager from storage.rustfs_storage import rustfs_manager
from config.settings import settings from config.settings import settings
from services.storage_integration_rustfs import StorageIntegrationService from services.storage_integration_rustfs import StorageIntegrationService
+1 -1
View File
@@ -14,7 +14,7 @@ sys.path.insert(0, str(project_root / "src"))
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy import text, select from sqlalchemy import text, select
from config.settings import settings from config.settings import settings
from models.database import SalesOrder, Customer from inventory.models import SalesOrder, Customer
async def check(): async def check():
+8 -15
View File
@@ -1,23 +1,16 @@
import os """Celery 应用入口。
from dotenv import load_dotenv
D14:broker/backend 复用 settings 的 Redis 配置——此前本模块自行
load_dotenv 并手拼 REDIS URL,与 settings 两份实现、行为可能漂移。
"""
from celery import Celery from celery import Celery
load_dotenv() from shared.config.settings import settings
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}"
app = Celery( app = Celery(
"moldinsight", "moldinsight",
broker=broker_url, broker=settings.redis_url,
backend=broker_url, backend=settings.redis_url,
include=["celery_tasks"], include=["celery_tasks"],
) )
+2 -1
View File
@@ -17,7 +17,8 @@ from datetime import datetime
from shared.database.database import get_db_session from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user, get_current_admin_user 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 from ..schemas import CustomerCreate, CustomerResponse
router = APIRouter(prefix="/customers", tags=["客户管理"]) router = APIRouter(prefix="/customers", tags=["客户管理"])
+2 -4
View File
@@ -15,10 +15,8 @@ from sqlalchemy import select, func
from shared.database.database import get_db_session from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user from shared.services.auth_service import get_current_active_user
from shared.models.database import ( from shared.models.identity import User
User, Product, Supplier, Customer, Warehouse, from inventory.models import Product, Supplier, Customer, Warehouse, Inventory, PurchaseOrder, SalesOrder
Inventory, PurchaseOrder, SalesOrder
)
router = APIRouter(prefix="/dashboard", tags=["仪表盘"]) router = APIRouter(prefix="/dashboard", tags=["仪表盘"])
+1 -1
View File
@@ -8,7 +8,7 @@ from typing import Optional, List
from shared.database.database import get_db_session from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user 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 ( from ..schemas import (
ReceiptCreate, ReceiptCreate,
PaymentCreate, PaymentCreate,
+1 -1
View File
@@ -9,7 +9,7 @@ from typing import Optional
from shared.database.database import get_db_session from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user 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 ..schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse
from ..services.inventory_service import inventory_service from ..services.inventory_service import inventory_service
+2 -1
View File
@@ -15,7 +15,8 @@ from typing import Optional, List
from shared.database.database import get_db_session from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user 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 ( from ..schemas import (
MaterialPriceHistoryCreate, MaterialPriceHistoryCreate,
MaterialPriceHistoryResponse, MaterialPriceHistoryResponse,
+3 -1
View File
@@ -18,7 +18,9 @@ from pathlib import Path
from shared.database.database import get_db_session from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user, get_current_admin_user 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 ( from ..schemas import (
ProductCreate, ProductCreate,
ProductResponse, ProductResponse,
+1 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from shared.database.database import get_db_session from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user 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 ( from ..schemas import (
PurchaseDemandCalculateRequest, PurchaseDemandCalculateRequest,
PurchaseDemandResponse, PurchaseDemandResponse,
+1 -1
View File
@@ -9,7 +9,7 @@ from typing import Optional
from shared.database.database import get_db_session from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user 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 ( from ..schemas import (
PurchaseOrderCreate, PurchaseOrderCreate,
PurchaseOrderResponse, PurchaseOrderResponse,
+1 -1
View File
@@ -9,7 +9,7 @@ from typing import Optional
from shared.database.database import get_db_session from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user 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 ( from ..schemas import (
SalesOrderCreate, SalesOrderCreate,
SalesOrderResponse, SalesOrderResponse,
+1 -1
View File
@@ -9,7 +9,7 @@ from typing import Optional
from shared.database.database import get_db_session from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user 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 ..schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse
from ..services.stock_movement_service import stock_movement_service from ..services.stock_movement_service import stock_movement_service
+2 -1
View File
@@ -17,7 +17,8 @@ from datetime import datetime
from shared.database.database import get_db_session from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user, get_current_admin_user 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 from ..schemas import SupplierCreate, SupplierResponse
router = APIRouter(prefix="/suppliers", tags=["供应商管理"]) router = APIRouter(prefix="/suppliers", tags=["供应商管理"])
+2 -1
View File
@@ -15,7 +15,8 @@ from datetime import datetime
from shared.database.database import get_db_session from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user 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 from ..schemas import WarehouseCreate, WarehouseResponse
router = APIRouter(prefix="/warehouses", tags=["仓库管理"]) router = APIRouter(prefix="/warehouses", tags=["仓库管理"])
+46
View File
@@ -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",
]
+166
View File
@@ -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}')>"
+45
View File
@@ -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})>"
+108
View File
@@ -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})>"
+80
View File
@@ -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})>"
+2 -12
View File
@@ -11,18 +11,8 @@ from typing import Optional, List, Dict, Tuple
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
from shared.models.database import ( from shared.models.identity import User
User, from inventory.models import Customer, Supplier, Product, SalesOrder, SalesOrderItem, PurchaseOrder, PurchaseOrderItem, FinanceTransaction, FinanceAllocation
Customer,
Supplier,
Product,
SalesOrder,
SalesOrderItem,
PurchaseOrder,
PurchaseOrderItem,
FinanceTransaction,
FinanceAllocation,
)
from ..schemas import ( from ..schemas import (
ReceiptCreate, ReceiptCreate,
PaymentCreate, PaymentCreate,
+2 -1
View File
@@ -10,7 +10,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.exc import IntegrityError 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 from ..schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse
@@ -10,18 +10,8 @@ from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
from shared.models.database import ( from shared.models.identity import User
User, from inventory.models import Product, ProductMaterial, SalesOrder, SalesOrderItem, Inventory, MaterialSupplier, Supplier, PurchaseOrder, PurchaseOrderItem
Product,
ProductMaterial,
SalesOrder,
SalesOrderItem,
Inventory,
MaterialSupplier,
Supplier,
PurchaseOrder,
PurchaseOrderItem,
)
from ..utils import generate_order_no from ..utils import generate_order_no
from ..schemas.purchase_demand_schemas import ( from ..schemas.purchase_demand_schemas import (
PurchaseDemandItemResponse, PurchaseDemandItemResponse,
@@ -10,16 +10,8 @@ from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, update from sqlalchemy import select, func, update
from shared.models.database import ( from shared.models.identity import User
User, from inventory.models import Supplier, Product, Warehouse, Inventory, StockMovement, PurchaseOrder, PurchaseOrderItem
Supplier,
Product,
Warehouse,
Inventory,
StockMovement,
PurchaseOrder,
PurchaseOrderItem,
)
from ..schemas import ( from ..schemas import (
PurchaseOrderCreate, PurchaseOrderCreate,
PurchaseOrderResponse, PurchaseOrderResponse,
+2 -11
View File
@@ -12,17 +12,8 @@ from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, delete, update from sqlalchemy import select, func, delete, update
from shared.models.database import ( from shared.models.identity import User
User, from inventory.models import Customer, Product, ProductMaterial, Warehouse, Inventory, StockMovement, SalesOrder, SalesOrderItem
Customer,
Product,
ProductMaterial,
Warehouse,
Inventory,
StockMovement,
SalesOrder,
SalesOrderItem,
)
from ..schemas import ( from ..schemas import (
SalesOrderCreate, SalesOrderCreate,
SalesOrderResponse, SalesOrderResponse,
@@ -9,7 +9,8 @@ from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, func 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 ..schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse
from ..utils import generate_order_no from ..utils import generate_order_no
+36 -14
View File
@@ -3,32 +3,54 @@ import importlib
from shared.config.settings import settings from shared.config.settings import settings
from shared.utils.logger import get_logger from shared.utils.logger import get_logger
from moldinsight.api.route_registry import route_load_status
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter() 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: try:
module = importlib.import_module(module_path) module = importlib.import_module(module_path)
router_obj = getattr(module, "router", None) router_obj = getattr(module, "router", None)
if router_obj is None: if router_obj is None:
raise ValueError("未找到 router 对象") raise ValueError("未找到 router 对象")
router.include_router(router_obj) router.include_router(router_obj)
route_load_status["loaded"].append({"label": label, "module": module_path})
logger.info(f"{label} 路由加载成功") logger.info(f"{label} 路由加载成功")
except Exception as exc: 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", "健康检查") for _label, _module_path, _debug_only in ROUTE_MODULES:
_safe_include("moldinsight.api.upload_router", "上传") _safe_include(_label, _module_path, _debug_only)
_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", "调试")
-592
View File
@@ -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}
+7 -4
View File
@@ -18,19 +18,22 @@ from sqlalchemy.orm import joinedload
from shared.database.database import get_db_session from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user 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.models.schemas import ProcessingStatus, create_task_info
from shared.services.redis_task_manager import redis_task_manager from shared.services.redis_task_manager import redis_task_manager
from shared.utils.file_handler import FileHandler from shared.utils.file_handler import FileHandler
from shared.utils.logger import get_logger 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 from moldinsight.services.task_dispatcher import dispatch_processing
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter() 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") @router.post("/batch-upload")
@@ -60,7 +63,7 @@ async def batch_upload(
batch_id = str(uuid.uuid4()) batch_id = str(uuid.uuid4())
tasks: List[Dict[str, Any]] = [] tasks: List[Dict[str, Any]] = []
storage_service = StorageIntegrationService() storage_service = TaskStorageService()
for file in files: for file in files:
# 文件类型检查 # 文件类型检查
+41 -26
View File
@@ -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 import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime
from shared.database.database import get_db_session 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 shared.services.auth_service import get_current_active_user
from moldinsight.services.cam_bundle_service import cam_bundle_service from moldinsight.services.cam_bundle_service import cam_bundle_service
from moldinsight.services.task_query_service import TaskQueryService 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") @router.post("/cam/plan")
async def generate_cam_plan( async def generate_cam_plan(
request: Request, body: CamPlanRequest,
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
): ):
"""基于任务分模结果生成 CAM 准备包(MVP)。""" """基于任务分模结果生成 CAM 准备包(MVP)。"""
_ = current_user _ = current_user
body = await request.json() task_id = body.task_id
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_result = await db_session.execute( task_result = await db_session.execute(
select(ProcessingTask).where(ProcessingTask.task_id == task_id) select(ProcessingTask).where(ProcessingTask.task_id == task_id)
@@ -47,23 +57,26 @@ async def generate_cam_plan(
processing_task.parameters.get("cam_preferences", {}) or {} processing_task.parameters.get("cam_preferences", {}) or {}
) )
mold_steel = body.get( # 注意 include_gcode 显式判 None:False 是有效值,不能走 or 回落
"mold_steel", mold_steel = (
persisted_preferences.get("mold_steel", DEFAULT_CAM_PREFERENCES["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 = (
"surface_quality", body.surface_quality
persisted_preferences.get("surface_quality", DEFAULT_CAM_PREFERENCES["surface_quality"]), if body.surface_quality is not None
else persisted_preferences.get("surface_quality", DEFAULT_CAM_PREFERENCES["surface_quality"])
) )
controller = body.get( controller = (
"controller", body.controller
persisted_preferences.get("controller", DEFAULT_CAM_PREFERENCES["controller"]), if body.controller is not None
else persisted_preferences.get("controller", DEFAULT_CAM_PREFERENCES["controller"])
) )
include_gcode = bool( include_gcode = (
body.get( body.include_gcode
"include_gcode", if body.include_gcode is not None
persisted_preferences.get("include_gcode", DEFAULT_CAM_PREFERENCES["include_gcode"]), else persisted_preferences.get("include_gcode", DEFAULT_CAM_PREFERENCES["include_gcode"])
)
) )
task_view = await TaskQueryService.get_task_view(db_session, task_id) 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计划") raise HTTPException(status_code=400, detail="任务尚未完成,无法生成CAM计划")
try: try:
data = cam_bundle_service.build_bundle( # CAM 刀路计算为纯 Python 重计算,投放线程池避免阻塞事件循环
data = await asyncio.to_thread(
cam_bundle_service.build_bundle,
task_view=task_view, task_view=task_view,
scheme_id=scheme_id, scheme_id=body.scheme_id,
mold_steel=mold_steel, mold_steel=mold_steel,
surface_quality=surface_quality, surface_quality=surface_quality,
controller=controller, controller=controller,
+47
View File
@@ -0,0 +1,47 @@
"""核心计算模块的惰性装载器(原 advanced_router._get_cached_import,D1 拆分时上提共用)。
- 惰性导入:避免路由模块级加载核心包(含 OCC 重模块)的导入开销与循环依赖
- 装载失败返回 None 且不缓存失败(与原实现一致,端点统一 503「服务不可用」)
- 实例缓存:设计/加工模块为纯 Python 计算(构造后无 self 突变,方法仅读入参),
可安全地被 asyncio.to_thread 并发调用;OCC 相关的 side_action_designer
必须经 processing_service.run_occ 的单线程 executor 使用
"""
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
+51
View File
@@ -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}
+1 -1
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends
from shared.services.auth_service import get_current_active_user from shared.services.auth_service import get_current_active_user
from shared.services.redis_task_manager import redis_task_manager from shared.services.redis_task_manager import redis_task_manager
from shared.models.database import User from shared.models.identity import User
router = APIRouter() router = APIRouter()
+181
View File
@@ -0,0 +1,181 @@
# api/design_router.py
"""模具结构设计类接口(批次 3 自 advanced_router 拆分,D1)。
- 请求体一律 Pydantic 模型(原 request.json() 手动解析退役,校验失败统一 422)
- 纯 Python 设计计算统一经 asyncio.to_thread 投放线程池,不阻塞事件循环;
OCC 相关的倒扣检测仍走 processing_service.run_occ 的单线程 executor
(PythonOCC 非线程安全)
"""
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, "服务不可用:核心模块未加载")
# 从持久化 STP 原件重建几何(此前传 shape=None 会被兜底吞掉,永远返回"无倒扣")
from moldinsight.services.shape_loader import get_shape_loader
shape = await get_shape_loader().load_shape_for_task(db_session, body.task_id)
if shape is None:
raise HTTPException(410, "任务几何不可用:无法从存储重建 STP 形状,请重新上传分析")
# OCC 操作必须走单线程 executor(PythonOCC 非线程安全)
result = await processing_service.run_occ(
sd.analyze_and_design,
shape,
body.parting_direction,
body.mold_size.model_dump(),
)
return {"status": "success", "data": result}
+302
View File
@@ -0,0 +1,302 @@
# 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",
},
}
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 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,
"导出缓存已失效或任务尚未完成,请重新分析后再导出以保证方案一致性",
)
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 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) # 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),
):
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}
+18 -5
View File
@@ -1,7 +1,11 @@
# api/v1/health_router.py # api/v1/health_router.py
import asyncio
from fastapi import APIRouter from fastapi import APIRouter
from shared.services.redis_task_manager import redis_task_manager 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() router = APIRouter()
@@ -9,10 +13,19 @@ router = APIRouter()
@router.get("/health") @router.get("/health")
@router.post("/health") @router.post("/health")
async def 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 { return {
"status": "healthy", # 有业务路由装载失败即 degraded:进程活着但功能残缺,监控必须可感知
"pythonocc": True, "status": "degraded" if failed_routes else "healthy",
"total_tasks": task_count, # 真实探测 PythonOCC(此前硬编码 True,与上传预检的诚实化同源)
"redis_connected": redis_task_manager.is_connected "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"]],
},
} }
+4 -4
View File
@@ -2,10 +2,10 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
import urllib.parse 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.database.database import get_db_session
from shared.services.auth_service import get_current_active_user 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 shared.utils.logger import get_logger
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -21,7 +21,7 @@ async def get_file_history(
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
): ):
"""获取当前用户按文件名分组的文件历史记录(支持多上传)""" """获取当前用户按文件名分组的文件历史记录(支持多上传)"""
storage_service = StorageIntegrationService() storage_service = FileHistoryService()
file_groups = await storage_service.get_all_file_groups( file_groups = await storage_service.get_all_file_groups(
db_session, user_id=current_user.id db_session, user_id=current_user.id
) )
@@ -42,7 +42,7 @@ async def get_file_records(
"""获取当前用户指定文件名的所有上传记录(支持多上传历史)""" """获取当前用户指定文件名的所有上传记录(支持多上传历史)"""
decoded_filename = urllib.parse.unquote(filename) decoded_filename = urllib.parse.unquote(filename)
storage_service = StorageIntegrationService() storage_service = FileHistoryService()
file_records = await storage_service.get_file_history_by_filename( file_records = await storage_service.get_file_history_by_filename(
db_session, db_session,
decoded_filename, decoded_filename,
+167
View File
@@ -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}
+18
View File
@@ -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": [],
}
+1 -1
View File
@@ -7,7 +7,7 @@ from moldinsight.services.task_query_service import TaskQueryService
from shared.database.database import get_db_session from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user from shared.services.auth_service import get_current_active_user
from shared.utils.logger import get_logger from shared.utils.logger import get_logger
from shared.models.database import User from shared.models.identity import User
logger = get_logger(__name__) logger = get_logger(__name__)
+8 -18
View File
@@ -4,34 +4,24 @@ import uuid
from datetime import datetime from datetime import datetime
from shared.models.schemas import ProcessingStatus, create_task_info from shared.models.schemas import ProcessingStatus, create_task_info
from shared.config.settings import settings
from shared.utils.file_handler import FileHandler 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 moldinsight.services.task_dispatcher import dispatch_processing
from shared.services.redis_task_manager import redis_task_manager from shared.services.redis_task_manager import redis_task_manager
from shared.database.database import get_db_session from shared.database.database import get_db_session
from shared.utils.logger import get_logger from shared.utils.logger import get_logger
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from shared.services.auth_service import get_current_active_user 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__) logger = get_logger(__name__)
router = APIRouter() 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)
def _occ_available() -> bool:
"""真实检测 PythonOCC 可用性(惰性导入,缺失时不影响本路由加载)。
此前该字段硬编码 True,响应不诚实;几何处理依赖 OCC,
不可用时任务会在处理阶段以明确错误失败。
"""
try:
import OCC.Core.STEPControl # noqa: F401
return True
except Exception:
return False
@router.post("/upload") @router.post("/upload")
@@ -72,7 +62,7 @@ async def upload_stp(
raise HTTPException(400, str(exc)) from exc raise HTTPException(400, str(exc)) from exc
logger.info(f"[UPLOAD] 文件已保存: {file_path} ({file_size} bytes), task_id={task_id}") 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( stp_file = await storage_service.save_stp_file(
session=db_session, session=db_session,
@@ -114,7 +104,7 @@ async def upload_stp(
"file_info": { "file_info": {
"filename": file.filename, "filename": file.filename,
"size": file_size, "size": file_size,
"pythonocc_available": _occ_available(), "pythonocc_available": is_pythonocc_available(),
"database_file_id": stp_file.id, "database_file_id": stp_file.id,
"sha256": file_meta["sha256"], "sha256": file_meta["sha256"],
}, },
+13
View File
@@ -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
+28
View File
@@ -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",
]
+352
View File
@@ -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})>"
@@ -1,27 +1,25 @@
# services/storage_integration_rustfs.py # services/analysis_storage_service.py
"""存储集成服务 - 协调 PostgreSQL 和 RustFS""" """分析结果数据存储——几何/网格/型腔/HTML/特征的 RustFS 上传与 PG 元数据,
from sqlalchemy.ext.asyncio import AsyncSession 以及任务完整数据视图的组装。
from sqlalchemy import select, update
from pathlib import Path
from typing import Optional, Dict, Any
import json
from datetime import datetime
import uuid
from shared.models.database import ( 批次 3 自 storage_integration_rustfs.py 按职责拆分(见 task_storage_service.py 头注)。
STPFile, GeometryData, MeshData, MoldCavityData, """
HTMLFile, ProcessingTask, User, import json
FeatureDetection, DesignRecommendation, from typing import Optional, Dict, Any
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 moldinsight.storage.rustfs_storage import rustfs_manager
from shared.utils.logger import get_logger from shared.utils.logger import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
class StorageIntegrationService: class AnalysisStorageService:
"""存储集成服务 - PostgreSQL + RustFS""" """分析结果数据(几何/网格/型腔/HTML/特征)存储与视图组装"""
@staticmethod @staticmethod
def _resolve_best_scheme_payload(cavity_json: Dict[str, Any]) -> Dict[str, Any]: def _resolve_best_scheme_payload(cavity_json: Dict[str, Any]) -> Dict[str, Any]:
@@ -80,169 +78,6 @@ class StorageIntegrationService:
except (TypeError, ValueError): except (TypeError, ValueError):
return None 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, async def save_geometry_data(self, session: AsyncSession,
stp_file_id: int, stp_file_id: int,
geometry_json: Dict[str, Any], geometry_json: Dict[str, Any],
@@ -517,37 +352,9 @@ class StorageIntegrationService:
await session.flush() await session.flush()
logger.info(f"保存了 {len(features)} 个特征和 {len(recommendations)} 个建议") 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, async def get_stp_file_with_data(self, session: AsyncSession,
stp_file_id: int) -> Dict[str, Any]: stp_file_id: int) -> Dict[str, Any]:
"""获取STP文件及其所有关联数据""" """获取STP文件及其所有关联数据"""
from sqlalchemy.orm import joinedload
try: try:
# 1. 获取STP文件记录(使用 joinedload 预加载关联数据) # 1. 获取STP文件记录(使用 joinedload 预加载关联数据)
result = await session.execute( result = await session.execute(
@@ -666,157 +473,6 @@ class StorageIntegrationService:
return result 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, async def delete_stp_file_cascade(self, session: AsyncSession,
stp_file_id: int): stp_file_id: int):
"""级联删除STP文件及其所有关联数据""" """级联删除STP文件及其所有关联数据"""
@@ -861,7 +517,3 @@ class StorageIntegrationService:
await session.commit() await session.commit()
logger.info(f"STP文件及其关联数据已删除: {stp_file_id}") 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
+34 -28
View File
@@ -20,14 +20,15 @@ from moldinsight.core.geometry_analyzer import GeometryAnalyzer
from moldinsight.core.mesh_generator import MeshGenerator from moldinsight.core.mesh_generator import MeshGenerator
from moldinsight.core.multi_scheme_planner import MultiSchemeMoldPlanner from moldinsight.core.multi_scheme_planner import MultiSchemeMoldPlanner
from moldinsight.core.cad_exporter import CADExporter from moldinsight.core.cad_exporter import CADExporter
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService 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 moldinsight.storage.rustfs_storage import rustfs_manager
from shared.services.redis_task_manager import redis_task_manager from shared.services.redis_task_manager import redis_task_manager
from moldinsight.services.material_service import MaterialService from moldinsight.services.material_service import MaterialService
from moldinsight.services.calculation_service import CalculationService from moldinsight.services.calculation_service import CalculationService
from moldinsight.services.llm_service import llm_service from moldinsight.services.llm_service import llm_service
from shared.models.schemas import ProcessingStatus 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.database.database import db_manager
from shared.utils.html_generator import HTMLGenerator from shared.utils.html_generator import HTMLGenerator
from shared.utils.logger import get_logger from shared.utils.logger import get_logger
@@ -43,7 +44,9 @@ class ProcessingService:
self.geometry_analyzer = GeometryAnalyzer() self.geometry_analyzer = GeometryAnalyzer()
self.mesh_generator = MeshGenerator(quality="medium") self.mesh_generator = MeshGenerator(quality="medium")
self.html_generator = HTMLGenerator() self.html_generator = HTMLGenerator()
self.storage_service = StorageIntegrationService() # 批次 3 按职责拆分:任务/文件生命周期 与 分析结果数据(原 StorageIntegrationService)
self.task_storage = TaskStorageService()
self.analysis_storage = AnalysisStorageService()
self.multi_scheme_planner = MultiSchemeMoldPlanner() self.multi_scheme_planner = MultiSchemeMoldPlanner()
self.cad_exporter = CADExporter() self.cad_exporter = CADExporter()
# TopoDS_Shape 为 C++ 原生内存对象,LRU 上限防止长期运行内存只涨不降 # TopoDS_Shape 为 C++ 原生内存对象,LRU 上限防止长期运行内存只涨不降
@@ -59,12 +62,15 @@ class ProcessingService:
asyncio.wait_for 只能取消协程,正在执行 OCC 布尔运算的线程无法中断; asyncio.wait_for 只能取消协程,正在执行 OCC 布尔运算的线程无法中断;
单 worker executor 中一个挂死线程会让后续任务永久排队直至重启。 单 worker executor 中一个挂死线程会让后续任务永久排队直至重启。
代价是泄漏 1 个线程,收益是恢复服务可用性。 cancel_futures=True 丢弃旧 executor 中尚未开跑的排队任务(否则旧线程
恢复后仍会继续消化旧队列,与新 executor 并发操作 OCC 必然崩溃)。
已在运行中的 C++ 线程在 Python 层不可杀,仍会滞留——这是已知残留
泄漏(每次超时 1 线程),根治需进程级 OCC 隔离,见 docs/OCC_THROUGHPUT.md。
""" """
old = self._occ_executor old = self._occ_executor
self._occ_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="occ") self._occ_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="occ")
old.shutdown(wait=False) old.shutdown(wait=False, cancel_futures=True)
logger.warning("OCC executor 已因处理超时重建(放弃等待旧线程,可能泄漏 1 个线程)") logger.warning("OCC executor 已因处理超时重建(排队任务已丢弃,运行中线程可能滞留 1 个)")
async def run_occ(self, fn, *args): async def run_occ(self, fn, *args):
"""在 OCC 单线程 executor 中执行同步几何操作。 """在 OCC 单线程 executor 中执行同步几何操作。
@@ -169,8 +175,8 @@ class ProcessingService:
# 避免 failed 更新把半成品 flush 数据一起带上 # 避免 failed 更新把半成品 flush 数据一起带上
await db_session.rollback() await db_session.rollback()
await self.storage_service.update_stp_file_status(db_session, stp_file_id, "failed") await self.task_storage.update_stp_file_status(db_session, stp_file_id, "failed")
await self.storage_service.update_task_status( await self.task_storage.update_task_status(
db_session, task_id, "failed", error_message=str(e) db_session, task_id, "failed", error_message=str(e)
) )
@@ -203,7 +209,7 @@ class ProcessingService:
stage_timings: Dict[str, float] = {} stage_timings: Dict[str, float] = {}
# 1. 解析STP文件 # 1. 解析STP文件
await self.storage_service.update_task_status( await self.task_storage.update_task_status(
db_session, task_id, "processing", 20, "解析STP文件" db_session, task_id, "processing", 20, "解析STP文件"
) )
@@ -218,7 +224,7 @@ class ProcessingService:
stage_timings["parse_stp"] = round(time.perf_counter() - stage_started, 3) stage_timings["parse_stp"] = round(time.perf_counter() - stage_started, 3)
# 2. 生成网格数据并持久化 # 2. 生成网格数据并持久化
await self.storage_service.update_task_status( await self.task_storage.update_task_status(
db_session, task_id, "processing", 30, "生成网格数据" db_session, task_id, "processing", 30, "生成网格数据"
) )
@@ -229,7 +235,7 @@ class ProcessingService:
stage_timings["generate_mesh"] = round(time.perf_counter() - stage_started, 3) stage_timings["generate_mesh"] = round(time.perf_counter() - stage_started, 3)
# 3. 生成模具型腔 # 3. 生成模具型腔
await self.storage_service.update_task_status( await self.task_storage.update_task_status(
db_session, task_id, "processing", 40, "生成模具型腔" db_session, task_id, "processing", 40, "生成模具型腔"
) )
@@ -257,7 +263,7 @@ class ProcessingService:
) )
# 4. 生成详细JSON数据 — 委托 CalculationService # 4. 生成详细JSON数据 — 委托 CalculationService
await self.storage_service.update_task_status( await self.task_storage.update_task_status(
db_session, task_id, "processing", 60, "生成型腔详细数据" db_session, task_id, "processing", 60, "生成型腔详细数据"
) )
@@ -284,12 +290,12 @@ class ProcessingService:
cavity_key_info = best_key_info cavity_key_info = best_key_info
# 6. 保存几何数据到数据库 # 6. 保存几何数据到数据库
await self.storage_service.update_task_status( await self.task_storage.update_task_status(
db_session, task_id, "processing", 70, "保存几何数据" db_session, task_id, "processing", 70, "保存几何数据"
) )
stage_started = time.perf_counter() stage_started = time.perf_counter()
await self.storage_service.save_geometry_data( await self.analysis_storage.save_geometry_data(
db_session, db_session,
stp_file_id, stp_file_id,
geometry_data, geometry_data,
@@ -301,7 +307,7 @@ class ProcessingService:
await db_session.commit() await db_session.commit()
# 7. 生成HTML可视化 # 7. 生成HTML可视化
await self.storage_service.update_task_status( await self.task_storage.update_task_status(
db_session, task_id, "processing", 85, "生成可视化报告" db_session, task_id, "processing", 85, "生成可视化报告"
) )
@@ -337,7 +343,7 @@ class ProcessingService:
best_key_info = best_scheme.get("key_info", {}) if best_scheme else best_key_info best_key_info = best_scheme.get("key_info", {}) if best_scheme else best_key_info
# 8. 保存模具型腔数据(包含方案级预览链接) # 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 db_session, stp_file_id, detailed_cavity_json
) )
@@ -349,7 +355,7 @@ class ProcessingService:
lod_data=lod_data, lod_data=lod_data,
) )
await self.storage_service.save_html_file( await self.analysis_storage.save_html_file(
db_session, db_session,
stp_file_id, stp_file_id,
Path(html_file_path).name, Path(html_file_path).name,
@@ -370,7 +376,7 @@ class ProcessingService:
) )
if analysis_result: if analysis_result:
await self.storage_service.save_features_and_recommendations( await self.analysis_storage.save_features_and_recommendations(
db_session, db_session,
stp_file_id, stp_file_id,
analysis_result.get("detected_features", []), analysis_result.get("detected_features", []),
@@ -381,7 +387,7 @@ class ProcessingService:
stage_timings["analyze_design"] = round(time.perf_counter() - stage_started, 3) stage_timings["analyze_design"] = round(time.perf_counter() - stage_started, 3)
# 9.6 更新STP文件的分析摘要字段 # 9.6 更新STP文件的分析摘要字段
await self.storage_service.update_stp_file_analysis_summary( await self.task_storage.update_stp_file_analysis_summary(
db_session, db_session,
stp_file_id, stp_file_id,
volume=geometry_data.get("volume", 0), volume=geometry_data.get("volume", 0),
@@ -419,7 +425,7 @@ class ProcessingService:
stage_timings["generate_llm_report"] = round(time.perf_counter() - stage_started, 3) stage_timings["generate_llm_report"] = round(time.perf_counter() - stage_started, 3)
# 10. 完成处理——先 flush 任务参数,完成状态提交时一并原子落库(D9) # 10. 完成处理——先 flush 任务参数,完成状态提交时一并原子落库(D9)
await self.storage_service.update_task_parameters( await self.task_storage.update_task_parameters(
db_session, db_session,
task_id, task_id,
{ {
@@ -431,8 +437,8 @@ class ProcessingService:
**process_params, **process_params,
}, },
) )
await self.storage_service.update_stp_file_status(db_session, stp_file_id, "completed") await self.task_storage.update_stp_file_status(db_session, stp_file_id, "completed")
await self.storage_service.update_task_status( await self.task_storage.update_task_status(
db_session, task_id, "completed", 100, "模具型腔生成完成" db_session, task_id, "completed", 100, "模具型腔生成完成"
) )
@@ -464,8 +470,8 @@ class ProcessingService:
# D9:先丢弃未提交的数据本体再置失败(同外层说明) # D9:先丢弃未提交的数据本体再置失败(同外层说明)
await db_session.rollback() await db_session.rollback()
await self.storage_service.update_stp_file_status(db_session, stp_file_id, "failed") await self.task_storage.update_stp_file_status(db_session, stp_file_id, "failed")
await self.storage_service.update_task_status( await self.task_storage.update_task_status(
db_session, task_id, "failed", error_message=str(e) db_session, task_id, "failed", error_message=str(e)
) )
@@ -524,7 +530,7 @@ class ProcessingService:
"bounding_box": bbox, "bounding_box": bbox,
} }
await self.storage_service.save_mesh_data( await self.analysis_storage.save_mesh_data(
db_session, db_session,
stp_file_id=stp_file_id, stp_file_id=stp_file_id,
mesh_json=mesh_json, mesh_json=mesh_json,
@@ -732,7 +738,7 @@ class ProcessingService:
logger.info("FreeCAD验证已禁用(设置 ENABLE_FREECAD_VERIFICATION=true 启用)") logger.info("FreeCAD验证已禁用(设置 ENABLE_FREECAD_VERIFICATION=true 启用)")
return {"status": "disabled", "reason": "FreeCAD验证已禁用"} 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几何验证" db_session, task_id, "processing", 90, "FreeCAD几何验证"
) )
@@ -787,7 +793,7 @@ class ProcessingService:
async def _save_analysis_metrics(self, session: AsyncSession, stp_file_id: int, analysis_result: dict): 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", {}) quality_metrics = analysis_result.get("quality_metrics", {})
analysis_summary = analysis_result.get("analysis_summary", "") analysis_summary = analysis_result.get("analysis_summary", "")
@@ -807,7 +813,7 @@ class ProcessingService:
async def _save_verification_metrics(self, session: AsyncSession, stp_file_id: int, verification_result: dict): 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 from sqlalchemy import select
result = await session.execute( result = await session.execute(
+1 -1
View File
@@ -12,7 +12,7 @@ from typing import Optional
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.database import ProcessingTask, STPFile from moldinsight.models import ProcessingTask, STPFile
from shared.utils.logger import get_logger from shared.utils.logger import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -10,9 +10,9 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload 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.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 from shared.utils.logger import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -103,7 +103,7 @@ class TaskQueryService:
return cached return cached
# 3. 持久化任务(已完成/失败,或服务重启后的任务) # 3. 持久化任务(已完成/失败,或服务重启后的任务)
storage_service = StorageIntegrationService() storage_service = AnalysisStorageService()
# 查询任务和文件元数据(预加载 html_file 关联) # 查询任务和文件元数据(预加载 html_file 关联)
result = await db_session.execute( 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
+1 -1
View File
@@ -9,7 +9,7 @@ sys.path.insert(0, str(src_root))
from sqlalchemy import select from sqlalchemy import select
from shared.database.database import db_manager 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.services.auth_service import get_password_hash
from shared.config.settings import settings from shared.config.settings import settings
from shared.utils.logger import get_logger from shared.utils.logger import get_logger
+7
View File
@@ -101,6 +101,13 @@ class Settings:
def allowed_extensions_set(self) -> set: def allowed_extensions_set(self) -> set:
return set(ext.strip() for ext in self.ALLOWED_EXTENSIONS.split(",")) 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 @staticmethod
def _parse_cors_origins(raw: str) -> List[str]: def _parse_cors_origins(raw: str) -> List[str]:
"""解析 CORS_ORIGINS 环境变量,逗号分隔。 """解析 CORS_ORIGINS 环境变量,逗号分隔。
-12
View File
@@ -115,18 +115,6 @@ class DatabaseManager:
await self.connect() await self.connect()
return self.async_session() 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() db_manager = DatabaseManager()
+1 -1
View File
@@ -3,7 +3,7 @@ import sys
from pathlib import Path from pathlib import Path
from sqlalchemy import text, select from sqlalchemy import text, select
from shared.database.database import db_manager 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.services.auth_service import get_password_hash
from shared.config.settings import settings from shared.config.settings import settings
from shared.utils.logger import get_logger from shared.utils.logger import get_logger
+17
View File
@@ -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()
-891
View File
@@ -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})>"
+182
View File
@@ -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}')>"
+11 -4
View File
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional, List from typing import Optional, List
from datetime import timedelta from datetime import timedelta
from sqlalchemy import select from sqlalchemy import select
@@ -14,7 +14,7 @@ from shared.services.auth_service import (
get_current_active_user, get_current_active_user,
get_password_hash 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.config.settings import settings
from shared.utils.logger import get_logger from shared.utils.logger import get_logger
@@ -97,6 +97,13 @@ class UserUpdate(BaseModel):
role_ids: Optional[List[int]] = None 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: def check_admin(user: User) -> bool:
if not user.is_superuser: if not user.is_superuser:
raise HTTPException(status_code=403, detail="需要管理员权限") raise HTTPException(status_code=403, detail="需要管理员权限")
@@ -307,7 +314,7 @@ async def delete_user(
@router.put("/users/{user_id}/reset-password") @router.put("/users/{user_id}/reset-password")
async def reset_user_password( async def reset_user_password(
user_id: int, user_id: int,
new_password: str, body: ResetPasswordRequest,
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
@@ -319,7 +326,7 @@ async def reset_user_password(
raise HTTPException(status_code=404, detail="用户不存在") raise HTTPException(status_code=404, detail="用户不存在")
try: try:
user.hashed_password = get_password_hash(new_password) user.hashed_password = get_password_hash(body.new_password)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
await db_session.commit() await db_session.commit()
+1 -1
View File
@@ -10,7 +10,7 @@ from sqlalchemy.orm import selectinload
from shared.config.settings import settings from shared.config.settings import settings
from shared.database.database import get_db_session 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 from shared.utils.logger import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
+4 -4
View File
@@ -17,10 +17,10 @@ from sqlalchemy import select
from fastapi import FastAPI, APIRouter from fastapi import FastAPI, APIRouter
from inventory.api import inventory_router from inventory.api import inventory_router
from shared.models.database import ( from shared.models.base import Base
Base, User, Customer, Warehouse, Supplier, Product, ProductMaterial, from shared.models.identity import User
Inventory, MaterialSupplier, SalesOrder, SalesOrderItem, 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.database.database import get_db_session
from shared.services.auth_service import get_current_active_user from shared.services.auth_service import get_current_active_user
+109
View File
@@ -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"
+101
View File
@@ -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
+2 -1
View File
@@ -11,7 +11,8 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
from moldinsight.api.batch_router import router as batch_router from moldinsight.api.batch_router import router as batch_router
from shared.database.database import get_db_session 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 from shared.services.auth_service import get_current_active_user
+76
View File
@@ -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
+76
View File
@@ -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")
+2 -2
View File
@@ -40,9 +40,9 @@ async def test_storage_writes_flush_but_never_commit():
"""D9:数据本体写方法仅 flush;commit 由编排层/请求侧负责。""" """D9:数据本体写方法仅 flush;commit 由编排层/请求侧负责。"""
pytest.importorskip("minio") pytest.importorskip("minio")
from sqlalchemy.ext.asyncio import AsyncSession 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) session = AsyncMock(spec=AsyncSession)
# update_task_parameters:select 返回 None(任务不存在)→ 直接 return # update_task_parameters:select 返回 None(任务不存在)→ 直接 return
result_mock = MagicMock() result_mock = MagicMock()
+77
View File
@@ -0,0 +1,77 @@
"""批次 3 回归:路由装载失败显式化(_safe_include 不再静默跳过)。
- 非 DEBUG:失败记录进 route_registry,/api/health 呈现 degraded
- DEBUG:装载失败直接抛错(fail fast,开发期当场暴露)
- 注册表状态在测试间隔离(真实 app 在导入期已登记 loaded 列表)
"""
import pytest
from fastapi import FastAPI
from httpx import AsyncClient, ASGITransport
from shared.config.settings import settings
from moldinsight.api import _safe_include
from moldinsight.api.route_registry import route_load_status
from moldinsight.api.health_router import router as health_router
@pytest.fixture(autouse=True)
def clean_registry():
"""清空注册表并在测试后还原真实 app 导入期登记的状态。"""
snapshot = {k: list(v) for k, v in route_load_status.items()}
for items in route_load_status.values():
items.clear()
yield
for items in route_load_status.values():
items.clear()
for key, items in snapshot.items():
route_load_status[key].extend(items)
def test_failed_route_recorded_not_silent(monkeypatch):
"""非 DEBUG:装载失败必须留下显式记录(此前仅 WARNING 日志后静默跳过)。"""
monkeypatch.setattr(settings, "DEBUG", False)
_safe_include("不存在", "moldinsight.api.definitely_missing_module")
failed = route_load_status["failed"]
assert len(failed) == 1
assert failed[0]["label"] == "不存在"
assert failed[0]["module"] == "moldinsight.api.definitely_missing_module"
assert "error" in failed[0]
def test_failed_route_raises_in_debug(monkeypatch):
"""DEBUG:装载失败直接抛错,禁止带病启动。"""
monkeypatch.setattr(settings, "DEBUG", True)
with pytest.raises(ModuleNotFoundError):
_safe_include("不存在", "moldinsight.api.definitely_missing_module")
def test_debug_only_route_disabled_when_debug_off(monkeypatch):
monkeypatch.setattr(settings, "DEBUG", False)
_safe_include("调试", "moldinsight.api.debug_router", debug_only=True)
assert route_load_status["disabled"][0]["module"] == "moldinsight.api.debug_router"
assert route_load_status["failed"] == []
@pytest.mark.asyncio
async def test_health_reports_degraded_and_failed_routes(monkeypatch):
"""health 必须暴露失败路由清单并将 status 置为 degraded。"""
monkeypatch.setattr(settings, "DEBUG", False)
_safe_include("不存在", "moldinsight.api.definitely_missing_module")
test_app = FastAPI()
test_app.include_router(health_router, prefix="/api")
transport = ASGITransport(app=test_app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
resp = await ac.get("/api/health")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "degraded"
assert body["routes"]["failed"][0]["module"] == "moldinsight.api.definitely_missing_module"
# 真实探测而非硬编码(布尔类型即可,具体值取决于环境是否安装 OCC)
assert isinstance(body["pythonocc"], bool)
+2 -1
View File
@@ -12,7 +12,8 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
from moldinsight.api.task_router import router as task_router from moldinsight.api.task_router import router as task_router
from shared.database.database import get_db_session 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 ( from shared.services.auth_service import (
get_current_active_user, get_current_active_user,
get_password_hash, get_password_hash,