diff --git a/AGENTS.md b/AGENTS.md index 2e8dcbc..d10bcc5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,15 +40,20 @@ src/ unified.py # 双模块统一入口:/api 挂 moldinsight + inventory,当前推荐后端 moldinsight/ # 【模具分析模块】 api/ - __init__.py # router 聚合:_safe_include 按序挂子 router,失败仅 WARNING 跳过;debug_router 仅 settings.DEBUG 挂载 - health_router.py # /api/health 模块健康检查 + __init__.py # router 聚合:ROUTE_MODULES 清单 + _safe_include 挂载,失败登记 route_registry(/api/health 呈现 degraded,DEBUG 下 fail fast);debug_router 仅 settings.DEBUG 挂载 + route_registry.py # 路由装载注册表(loaded / failed / disabled,health_router 引用) + health_router.py # /api/health 模块健康检查(含真实 pythonocc 探测与路由装载状态) upload_router.py # /api/upload STEP/STP 上传 batch_router.py # /api/batch-upload 批量上传与分析 task_router.py # /api/status/{task_id} 任务状态查询 history_router.py # /api/history 分析历史与结果文件 - cam_router.py # /api/cam/plan CAM 加工方案 + 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) - advanced_router.py # 导出 / 成本估算 / 设计分析等高级接口(技术债 D1:待拆分 + 请求模型化) debug_router.py # /api/debug/tasks 全量任务 dump(仅 DEBUG 模式注册,仍需登录) core/ # 几何与方案核心算法(OCC 重依赖区) stp_parser.py # STEP/STP 解析 @@ -79,7 +84,10 @@ src/ material_service.py # 物料价格服务 aluminum_price_service.py # 铝价服务(模拟数据) llm_service.py # LLM 增强分析(可选,OpenAI 兼容) - storage_integration_rustfs.py # RustFS 存储集成 + task_storage_service.py # STP 文件与处理任务生命周期存储(D9:数据写 flush-only,状态更新即时 commit) + analysis_storage_service.py # 分析结果数据存储(几何/网格/型腔/HTML/特征)与任务数据视图组装 + file_history_service.py # 按文件名聚合的上传历史查询视图 + models/ # moldinsight 域 ORM(stp_analysis.py:stp_files 及各阶段产物 + processing_tasks,共 9 表) storage/ rustfs_storage.py # RustFS/MinIO 客户端封装 init_storage.py # 存储初始化 @@ -87,13 +95,15 @@ src/ api/ # 每域一个 routes 文件:product / supplier / customer / warehouse / inventory / stock_movement / purchase_order / sales_order / purchase_demand / finance / dashboard / material schemas/ # 每域一个 Pydantic schema 文件(与 api 一一对应) services/ # 领域服务:inventory / purchase_order / sales_order / finance / purchase_demand / stock_movement + models/ # inventory 域 ORM(catalog / warehouse / trading / finance 四文件,共 15 表) utils.py shared/ # 【共享平台层:只放真正跨模块复用的基础能力,勿堆业务】 app_factory.py # create_app:request_id 日志中间件 / auth_router / /health / SPA fallback / /html mount config/settings.py # Settings 单例:dotenv + os.getenv;DB_*/SECRET_KEY 惰性校验无默认 database/database.py # async engine / session / get_db_session database/init_db.py # 建表与管理员种子 - models/database.py # 全量 ORM(identity + moldinsight + inventory 三类同居一处——当前最强耦合点,见 ARCHITECTURE §6) + models/base.py # 唯一 ORM Base + 模型归属约定(跨模块只许裸 FK,禁跨模块 relationship) + models/identity.py # 身份与权限 ORM:User/Role/Permission/UserRole/RolePermission/UserActivity/SystemLog models/schemas.py # 共享 Pydantic 模型 services/auth_routes.py # /api/auth/* 认证用户角色权限路由 services/auth_service.py # JWT 签发校验 + get_current_active_user 依赖 diff --git a/deploy/Dockerfile.celery b/deploy/Dockerfile.celery index 31b2f4f..6357a09 100644 --- a/deploy/Dockerfile.celery +++ b/deploy/Dockerfile.celery @@ -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"] diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index ca6ab65..d62d754 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -15,7 +15,7 @@ | moldinsight-only | `/api/*`(moldinsight)+ `/api/auth/*` + `/health` | | inventory-only | `/api/*`(inventory)+ `/api/auth/*` + `/health` | -- moldinsight 路由在 [moldinsight/api/\_\_init\_\_.py](../src/moldinsight/api/__init__.py) 经 `_safe_include` 聚合(子 router 加载失败仅 WARNING 跳过;`debug_router` 仅 `DEBUG=true` 注册)。 +- moldinsight 路由在 [moldinsight/api/\_\_init\_\_.py](../src/moldinsight/api/__init__.py) 按 `ROUTE_MODULES` 清单经 `_safe_include` 聚合(装载失败登记 [route_registry.py](../src/moldinsight/api/route_registry.py),`/api/health` 呈现 `degraded` 并列出失败模块;`DEBUG=true` 下失败直接抛错;`debug_router` 仅 `DEBUG=true` 注册)。 - inventory 路由在 [inventory/api/\_\_init\_\_.py](../src/inventory/api/__init__.py) 按域静态聚合。 - 认证路由来自 [shared/services/auth_routes.py](../src/shared/services/auth_routes.py),由 [app_factory](../src/shared/app_factory.py) 挂载,三种形态共用。 @@ -36,7 +36,7 @@ |---|---|---| | 登录 | `/api/auth/login`、`/api/auth/login/json`、`/api/auth/logout` | auth_routes.py | | 当前用户 | `/api/auth/me` | auth_routes.py | -| 用户管理 | `/api/auth/users`、`/api/auth/users/{user_id}`、`/api/auth/users/{user_id}/reset-password` | auth_routes.py | +| 用户管理 | `/api/auth/users`、`/api/auth/users/{user_id}`、`/api/auth/users/{user_id}/reset-password`(管理员;JSON body `{ new_password }`,最短 6 位) | auth_routes.py | | 角色权限 | `/api/auth/roles`、`/api/auth/roles/{role_id}`、`/api/auth/roles/{role_id}/permissions`、`/api/auth/permissions`、`/api/auth/permissions/{permission_id}` | auth_routes.py | ### 3.2 moldinsight(模具分析) @@ -47,11 +47,14 @@ | 批量分析 | `/api/batch-upload`、`/api/batch/{batch_id}`(聚合状态以 PG 为准;响应含 `current_step`;他人批次 403、不存在 404) | batch_router.py | | 任务状态 | `/api/status/{task_id}`(需登录;仅任务所有者可访问,他人/无主任务 403,不存在 404) | task_router.py | | 历史结果 | `/api/history`、`/api/history/{filename}` | history_router.py | -| CAM | `/api/cam/plan` | cam_router.py | +| CAM | `/api/cam/plan`(Pydantic 请求模型;未提供的偏好回落任务持久化偏好再回落默认) | cam_router.py | +| 设计 | `/api/optimize-layout`、`/api/design-cooling`、`/api/design-gating`、`/api/design-mold-system`、`/api/detect-undercuts` | design_router.py | +| 成本估算 | `/api/cost-estimate` | cost_router.py | +| 加工 | `/api/design-cam`、`/api/check-collision`、`/api/optimize-toolpath`、`/api/design-electrodes`、`/api/simulate-machining` | machining_router.py | +| 导出 | `/api/export-mold`、`/api/export-download/{filepath}`、`/api/export-recommendations` | export_router.py | | 铝价(模拟数据) | `/api/aluminum-price/current`、`/api/aluminum-price/history` | aluminum_price_routes.py | -| 健康检查 | `/api/health` | health_router.py | +| 健康检查 | `/api/health`(有路由装载失败时 `status: degraded` 并列出失败清单;`pythonocc` 为真实探测) | health_router.py | | 调试(仅 DEBUG) | `/api/debug/tasks` | debug_router.py | -| 导出/估算/设计等高级接口 | 见 `openapi.json` 对应路径 | advanced_router.py(技术债 D1:待拆分) | ### 3.3 inventory(进销存) @@ -93,12 +96,12 @@ - `frontend/src/types/api.ts` 是**生成物,禁止手改**;前端代码类型引用它。 - 三步缺一即前后端契约漂移(硬约束,见 [AGENTS.md](../AGENTS.md) §2)。 -- **当前已知滞后**:checked-in `openapi.json`(2026-07-27)落后当前代码(实际 76 paths vs 文件内 70),下次接口变更时按上述流程重导出。 +- 当前 `openapi.json` 于 2026-09-17 随批次 3 重导出(76 paths),前端 `src/types/api.ts` 同步再生。 ## 5. 契约变更规则 - 新增接口先定模块归属(moldinsight / inventory / shared auth),再写路由;返回结构、路径、鉴权发生变化时,同步更新本文相应表格。 -- 路由文件过大按职责拆分(现状债务:`advanced_router` 待拆分,见 [TECH_DEBT.md](TECH_DEBT.md) D1)。 +- 路由文件过大按职责拆分(参照批次 3 的 design / cost / machining / export 拆分先例;新增路由须登记 [moldinsight/api/\_\_init\_\_.py](../src/moldinsight/api/__init__.py) 的 `ROUTE_MODULES`)。 - 破坏性变更(删字段 / 改语义)需在 [STATUS.md](STATUS.md) 日志条目中记录,并确认前端同仓同步修改。 ## 6. 前端消费约定 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 11bc54c..74083f3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -159,6 +159,8 @@ geMoldInsight/ 典型桥接关系示例: - `STPFile.product_id -> Product.id` +桥接只允许**裸 FK 列**(字符串表名),**不允许跨模块 ORM relationship**——单模块部署下另一模块的模型类可能未注册,跨模块 relationship 会让 mapper 配置直接失败(2026-09-17 批次 4 起为硬规则,原三条跨模块 relationship 均无使用方,已删除;对象化查询由使用方显式 select)。 + ### 5.2 模块边界优先于“临时方便” 新增逻辑时,应优先放入对应业务模块,而不是继续堆进 `shared`。 @@ -178,9 +180,16 @@ geMoldInsight/ 虽然模块化已经成型,但仍有几个关键耦合点需要持续关注: -### 6.1 共享 ORM 模型 +### 6.1 共享 ORM 模型 —— 已按模块拆分(2026-09-17,批次 4) -当前 [src/shared/models/database.py](../src/shared/models/database.py) 同时承载 identity、moldinsight、inventory 三类模型,是当前最强耦合点之一。 +历史上的 `shared/models/database.py`(31 个模型类三类同居)已拆除,现为按归属分置: + +- [src/shared/models/base.py](../src/shared/models/base.py):唯一 `Base` + 归属约定与全量注册点说明 +- [src/shared/models/identity.py](../src/shared/models/identity.py):用户/角色/权限/审计(平台层,所有部署形态共用) +- [src/moldinsight/models/](../src/moldinsight/models/):STEP 分析域 9 表(stp_files 及各阶段产物、processing_tasks) +- [src/inventory/models/](../src/inventory/models/):进销存 15 表(catalog / warehouse / trading / finance 四域文件) + +跨模块只允许裸 FK(规则见 §5.1);全量模型注册点收敛为 `migrations/env.py` 与 `tests/conftest.py`;归属边界由 [tests/test_model_ownership.py](../tests/test_model_ownership.py) 锁定(含单模块独立 mapper 配置与旧模块无 facade 断言)。 ### 6.2 app factory 组合职责偏重 @@ -199,8 +208,10 @@ geMoldInsight/ - 存储方向: - [topics/storage/RUSTFS_STORAGE.md](topics/storage/RUSTFS_STORAGE.md) - [topics/storage/STORAGE_SETUP.md](topics/storage/STORAGE_SETUP.md) +- 性能方向: + - [topics/performance/OCC_THROUGHPUT.md](topics/performance/OCC_THROUGHPUT.md)(OCC 吞吐与隔离方案设计,TECH_DEBT D10 归属) -AI、性能、铝泡沫等更偏历史设计/规划性质的专题材料已迁入 [archive/README.md](archive/README.md)。 +AI、铝泡沫等更偏历史设计/规划性质的专题材料已迁入 [archive/README.md](archive/README.md)。 阶段性任务清单、迁移计划、历史总结等文档会逐步迁入 [archive/README.md](archive/README.md)。 diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 374b131..1f9c2b5 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -16,7 +16,8 @@ - `SECRET_KEY`:JWT 签名密钥,**无默认**;生产必须 ≥32 字符强随机。 - `ADMIN_PASSWORD`:初始管理员密码,**无默认**;首次建库前必须设置。 - `RUSTFS_*`:对象存储(兼容 `MINIO_*` 别名写法);本地开发缺省值仅为占位,连不上会在用到存储的链路报错。 - - `REDIS_*`:默认 `localhost:6379` 无密码(本地开发语义),生产必须显式覆盖。 + - `REDIS_*`:默认 `localhost:6379` 无密码(本地开发语义),生产必须显式覆盖;连接串唯一拼装点为 `Settings.redis_url`(Celery broker/backend 复用)。 + - `MAX_FILE_SIZE`:上传文件大小上限(字节),默认 `104857600`(100MB);此前为死配置(处理器硬编码 50MB),2026-09-17 起真实生效,收紧上限需同步调整该值。 - `CORS_ORIGINS`:逗号分隔白名单;不设默认放行 `*`,**生产必须显式设置**。 - `LOG_FORMAT`:`json`(生产默认,结构化)/ `text`(开发人可读);`LOG_LEVEL`:DEBUG/INFO/WARNING/ERROR。 - `DEBUG`:`true` 时额外注册 `/api/debug/*` 调试路由(仍需登录),**生产必须为 false**。 @@ -48,6 +49,9 @@ Celery worker(moldinsight 异步分析链路;本地从 `src` 目录跑,与 cd src && celery -A celery_app worker --concurrency=2 --loglevel=info ``` +- `--concurrency=N` 即 OCC 并行分析数(每个 prefork 子进程持一个串行 OCC 通道,见 [topics/performance/OCC_THROUGHPUT.md](topics/performance/OCC_THROUGHPUT.md) 方案 A);调大时预算好每子进程内存与 PG 连接数。 +- 建议生产加 `--max-tasks-per-child=M`(如 50):子进程定期重启,兜底回收 OCC 超时后滞留的线程。 + 前端: ```bash diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 94489bd..a40ce63 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -45,7 +45,7 @@ geMoldInsight 已从历史单体逐步演进为“双业务模块 + 共享平台 重点方向: -- `advanced_router` 拆分与请求模型规范化 +- ~~`advanced_router` 拆分与请求模型规范化~~(2026-09-17 批次 3 完成) - 模具分析链路的结构继续收口 - OCC 依赖场景下的契约测试/集成测试继续补齐 @@ -88,13 +88,13 @@ geMoldInsight 已从历史单体逐步演进为“双业务模块 + 共享平台 ### P1:moldinsight API 结构整理 -- 拆分 `advanced_router` -- 为高频接口引入 Pydantic 请求模型 -- 继续减少 `request.json()` 风格手动解析 +- ~~拆分 `advanced_router`~~(2026-09-17 批次 3 完成) +- ~~为高频接口引入 Pydantic 请求模型~~(2026-09-17 批次 3 完成) +- 继续减少 `request.json()` 风格手动解析(存量端点已清零,新增接口守此约定) ### P2:shared/platform 边界继续收敛 -- 梳理共享 ORM 与业务模型的归属 +- ~~梳理共享 ORM 与业务模型的归属~~(2026-09-17 批次 4 完成:ORM 已按模块拆分,跨模块只许裸 FK) - 继续减少 shared 直接承担业务组合逻辑 - 为后续平台层命名与目录调整准备条件 @@ -117,11 +117,11 @@ geMoldInsight 已从历史单体逐步演进为“双业务模块 + 共享平台 | 批次 1 | 部署正确性(1–2 天) | 主链路改走 RustFS(分派入参`file_path` → `stp_file_id`,worker 按 object_key 下载解析);compose 共享卷兜底(过渡);alembic 移出 startup(`AUTO_MIGRATE` 开关);OCC 镜像引入方式修正 + 依赖锁文件 | D6、D12、D13 | | 批次 2 | 任务一致性模型(2–4 天) | PG 为单一事实源、Redis 仅热缓存;去掉多进程内存回退;批量元数据入库;型腔失败标 failed;持久化事务边界收口 | D7、D8、D9、D11 | | 批次 3 | API 与代码结构(3–5 天) | `_safe_include` 失败显式化(/health 暴露缺失路由);advanced_router 拆分 + Pydantic 请求模型;async 重计算统一 executor;StorageIntegrationService 拆分;配置治理 | D1、D14 | -| 批次 4 | 架构演进(5 天+) | 共享 ORM 按模块拆分;OCC 吞吐方案设计先行;文档 / 契约同步 | D3、D10 | +| 批次 4 | 架构演进(5 天+) | ~~共享 ORM 按模块拆分;OCC 吞吐方案设计先行;文档 / 契约同步~~(2026-09-17 完成) | D3、D10 | **执行顺序建议**:批次 0 与批次 1 的 D6(RustFS 主链路)先行——前者是确认的安全漏洞,后者是部署根本性缺陷,两者互不依赖、改动可控。其余按批次顺序推进,每批完成同步 STATUS / TECH_DEBT / API_CONTRACT。 -> 进度:批次 0 / 1 / 2 已于 2026-09-16 完成(D13 的 pip 全量锁文件为批次 1 遗留项,随下次镜像构建补齐;D11 留待后续批次,正确性已由批次 1 共享卷兜底);完成明细见 [STATUS.md](STATUS.md) 与 [TECH_DEBT.md](TECH_DEBT.md) §2.5–2.6。 +> 进度:批次 0 / 1 / 2 已于 2026-09-16 完成、批次 3 / 4 已于 2026-09-17 完成,§3.1 批次计划**全部执行完毕**(遗留: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 与主线方向。 --- diff --git a/docs/STATUS.md b/docs/STATUS.md index 64cfb42..390aaec 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -3,6 +3,10 @@ > 文档定位:**唯一的「现在到哪了」**。README / AGENTS / 各主文档只链接到这里,不复制状态内容。 > 维护规则:每完整完成一个需求,**倒序在本文顶部加一条**(日期 + 主题 + 关键事实);其余主文档(架构 / 规划 / 技术债 / 部署)维护各自的"当前有效说法",本文只记录"什么时候做到了哪一步"。维护规则出处见根目录 [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(**批次 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)。) diff --git a/docs/TECH_DEBT.md b/docs/TECH_DEBT.md index 5545dcc..9445c3f 100644 --- a/docs/TECH_DEBT.md +++ b/docs/TECH_DEBT.md @@ -55,29 +55,35 @@ - 持久化事务边界收口:数据本体分阶段原子提交、失败先回滚再置 failed(原 D9) - D11(HTML 双写双读)本批未动:正确性已由共享卷兜底,RustFS 单一来源留待后续批次 +### 2.7 API 与代码结构(2026-09-17,批次 3) +- `advanced_router` 按职责拆为 design / cost / machining / export 四个子路由,端点路径不变,请求体全量 Pydantic 化(原 D1) +- 路由装载失败显式化:`ROUTE_MODULES` 清单 + route_registry,失败经 `/api/health` 呈现 degraded(含真实 pythonocc 探测),DEBUG 下 fail fast +- 纯 Python 重计算端点(设计/加工/CAM 打包)统一 `asyncio.to_thread` 投放线程池,不再阻塞事件循环;OCC 操作仍走单线程 executor(D10 不变,批次 4) +- `StorageIntegrationService`(867 行)按职责拆为 TaskStorage / AnalysisStorage / FileHistory 三服务;无调用方的 `log_user_activity` 死代码删除 +- 配置治理收尾:`MAX_FILE_SIZE` 接线生效、celery_app 复用 `Settings.redis_url`(原 D14) +- 连带修复:管理员重置密码改 JSON body(原裸 str 参数被解析为 query param,前端发 body 必 422,功能端到端断裂);Dockerfile.celery 的 FROM tag 与 compose/build.sh 实际构建的 `gemold-backend:latest` 对齐(此前干净环境 celery 镜像必构建失败) +- 接口变更三件套随批完成:openapi.json 重导出(76 paths)+ 前端 `gen:api` + +### 2.8 架构演进(2026-09-17,批次 4) +- 共享 ORM 按模块拆分(原 D3 主体):891 行 `shared/models/database.py`(31 模型类三类同居)拆为 `shared/models/base.py`(唯一 Base + 归属约定)/ `shared/models/identity.py`(7 表)/ `moldinsight/models/`(9 表)/ `inventory/models/`(catalog/warehouse/trading/finance 15 表);**三条跨模块 ORM relationship(`User.stp_files`、`STPFile.user`、`STPFile.product`)经全仓核实均无使用方,直接删除**——跨模块桥接收敛为裸 FK 硬规则(ARCHITECTURE §5.1),单模块部署不再依赖另一侧模型注册;约 45 处 import 全量改写,无兼容 facade;全量注册点收敛为 migrations/env.py 与 tests/conftest.py;零调用方的死方法 `db_manager.create_tables` 一并删除(拆分后会静默建残缺 schema) +- OCC 泄漏治理 + 吞吐方案设计先行(原 D10):`_reset_occ_executor` 补 `cancel_futures=True`——不止卫生问题:旧实现下"慢恢复"的旧线程会继续消化旧队列,与新 executor **并发操作非线程安全的 OCC**(数据竞争);吞吐路线定稿于 [topics/performance/OCC_THROUGHPUT.md](topics/performance/OCC_THROUGHPUT.md)(短期 A:celery prefork 伸缩 + max-tasks-per-child 兜底;中期 B:run_occ 接口进程化 + kill-on-timeout 根治) +- 归属边界回归测试:[tests/test_model_ownership.py](../tests/test_model_ownership.py)(31 表全量注册、单模块独立 mapper 配置、旧模块无 facade) + 详细历史过程保留在原始技术债文档中,后续将转入归档。 --- ## 3. 当前活跃技术债 -### D1. `advanced_router` 过大,职责混杂 +### D1. `advanced_router` 过大,职责混杂 —— 已清偿(2026-09-17,批次 3) -现状: -- 导出、估算、设计/分析相关接口仍混在同一个 router 中 -- 请求体仍有较多手动解析逻辑 +修复内容: +- 592 行的 advanced_router 按职责拆为四个子路由,端点路径全部不变:[design_router.py](../src/moldinsight/api/design_router.py)(布局/冷浇/模架/倒扣)、[cost_router.py](../src/moldinsight/api/cost_router.py)、[machining_router.py](../src/moldinsight/api/machining_router.py)(CAM/碰撞/刀路/电极/仿真)、[export_router.py](../src/moldinsight/api/export_router.py)(导出/下载/建议) +- 全部请求体改 Pydantic 模型(`request.json()` 手动解析退役),校验失败统一 422;`_get_cached_import` 上提为 [core_modules.py](../src/moldinsight/api/core_modules.py) 共用 +- 契约测试:[tests/test_advanced_split_contract.py](../tests/test_advanced_split_contract.py)(路径不丢、鉴权不丢、422 语义、纯计算端点冒烟) +- openapi.json 重导出 + 前端 `gen:api`(接口变更三件套随批完成) -影响: -- 路由边界不清晰 -- OpenAPI 可读性差 -- 接口参数校验不统一 -- 后续继续扩展时维护成本高 - -建议: -- 拆分为 export / design / cost 等子路由 -- 高优先级请求体改为 Pydantic 模型 - -优先级:**P1** +~~原现状 / 影响~~:导出/估算/设计接口混在单文件,边界不清晰、OpenAPI 可读性差、参数校验不统一。 ### D2. 铝价模拟数据未显式标注来源 @@ -93,21 +99,17 @@ 优先级:**P2** -### D3. shared/platform 边界仍需继续收敛 +### D3. shared/platform 边界仍需继续收敛 —— ORM 归属已清偿(2026-09-17,批次 4) -现状: -- `shared` 同时承担平台基础能力与部分历史耦合职责 -- 共享 ORM 与 app factory 仍是主要耦合点 +已完成部分: +- 共享 ORM(原最强耦合点)按模块拆分:base / identity(shared)+ moldinsight/models + inventory/models;跨模块只允许裸 FK,单模块部署 mapper 可独立配置(详见 §2.8 与 [ARCHITECTURE.md](ARCHITECTURE.md) §6.1) +- 旧 `shared/models/database.py` 物理删除,无兼容 facade;归属边界由 [tests/test_model_ownership.py](../tests/test_model_ownership.py) 锁定 -影响: -- 模块边界认知成本较高 -- 新增逻辑容易继续堆入 shared +仍保留的收敛方向(低优先级,随实际重构推进): +- [app_factory.py](../src/shared/app_factory.py) 组合职责偏重(ARCHITECTURE §6.2) +- identity / platform 的边界语义(ROADMAP §2.1) -建议: -- 继续从文档、目录语义、职责边界上推进收敛 -- 在后续实际重构中优先避免把业务逻辑继续沉入 shared - -优先级:**P2** +优先级:**P3**(剩余部分) ### D4. 文档现状 / 规划 / 历史混放 @@ -171,19 +173,17 @@ ~~原现状 / 影响~~:各存储方法内部自行 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 吞吐 -- 超时重建 executor 每次泄漏 1 个线程,长期运行只涨不降 +已完成: +- `_reset_occ_executor` 补 `cancel_futures=True`:排队任务随重建丢弃——旧实现下"慢恢复"的旧线程会继续消化旧队列,与新 executor 并发操作非线程安全的 OCC;修复后残留收敛为"运行中线程滞留 1 个"(C++ 栈 Python 层不可杀,属客观边界) +- 吞吐与隔离方案定稿:[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 落地 -建议: -- 记录吞吐上限为已知约束;线程泄漏治理方案设计先行(见 [ROADMAP.md](ROADMAP.md) §3.1 批次 4) - -优先级:**P2** +优先级:**P3**(中期方案 B 实施前维持观察) ### D11. HTML 报告本地磁盘与 RustFS 双写双读 @@ -216,20 +216,21 @@ 优先级:**P2**(剩余锁文件部分) -### D14. 配置漂移:弱默认 / 死配置 / 重复解析 +### D14. 配置漂移:弱默认 / 死配置 / 重复解析 —— 已清偿(2026-09-16 ~ 09-17,批次 1 / 3) -现状: -- ~~RUSTFS_* 弱默认~~(2026-09-16 代码侧已去除);~~compose 侧 SECRET_KEY / ADMIN_PASSWORD 弱默认~~(2026-09-16 已去除:改用 `${VAR:?}` 强制显式配置,`create_admin_user` 对空 ADMIN_PASSWORD 显式报错) -- MAX_FILE_SIZE 配置项未被使用([file_handler.py](../src/shared/utils/file_handler.py) 硬编码 50MB) -- [celery_app.py](../src/celery_app.py) 重新 load_dotenv 并手拼 REDIS URL,与 settings 两份实现 +修复内容: +- ~~RUSTFS_* 弱默认~~(批次 0 代码侧去除);~~compose 侧 SECRET_KEY / ADMIN_PASSWORD 弱默认~~(批次 1 改 `${VAR:?}` 强制显式配置) +- ~~MAX_FILE_SIZE 死配置~~(批次 3):upload/batch 路由的 `FileHandler` 接 `settings.UPLOAD_DIR / settings.MAX_FILE_SIZE`(此前处理器硬编码 50MB;接线后默认上限变为 100MB,以 .env 为准) +- ~~celery_app 重复拼装~~(批次 3):删除自行 load_dotenv + 手拼 REDIS URL,broker/backend 复用 `Settings.redis_url`(新增 property,连接串唯一拼装点) -影响: -- 违背"关键项不兜底"硬约束;配置行为与文档不一致 +优先级:已清偿 -建议: -- 去掉弱默认、对齐或删除死配置、celery_app 复用 settings +### D15. 前端 `npm run build` 因既有 TS 错误失败(批次 3 连带发现)—— 已清偿(2026-09-17,批次 4) -优先级:**P2** +修复内容: +- [vite.config.ts](../frontend/vite.config.ts) 删除未使用的回调参数 `mode`(TS6133 源头,一行修复);`vue-tsc -b` 实测通过,生产构建链路恢复 + +~~原现状 / 影响~~:`vue-tsc -b`(`npm run build` 的类型检查步)因既有 TS6133 失败,前端无法出生产包(与批次 3 改动无关的既有问题)。 --- @@ -238,14 +239,14 @@ > 注:2026-09-15 后端设计审查后,治理**执行顺序**以 [ROADMAP.md](ROADMAP.md) §3.1 批次计划为准(批次 0–4);D5–D14 的批次归属见该表。本节保留原有优先项作为补充说明。 ### 第一优先级 -1. `advanced_router` 拆分 -2. 高优先级接口补 Pydantic 请求模型 +1. ~~`advanced_router` 拆分~~(2026-09-17 批次 3 完成,见 D1) +2. ~~高优先级接口补 Pydantic 请求模型~~(2026-09-17 批次 3 完成) 3. 文档主骨架收口并减少重复说明 ### 第二优先级 4. 铝价模拟数据来源显式化 5. 部署历史文档归档 -6. shared/platform 语义继续收敛 +6. shared/platform 语义继续收敛(共享 ORM 归属已于批次 4 清偿,剩余为 app_factory 组合职责等,见 D3) --- diff --git a/docs/topics/performance/OCC_THROUGHPUT.md b/docs/topics/performance/OCC_THROUGHPUT.md new file mode 100644 index 0000000..fbb745e --- /dev/null +++ b/docs/topics/performance/OCC_THROUGHPUT.md @@ -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 的进程回收兜底。 diff --git a/frontend/src/modules/users/UsersView.vue b/frontend/src/modules/users/UsersView.vue index c527be9..501507a 100644 --- a/frontend/src/modules/users/UsersView.vue +++ b/frontend/src/modules/users/UsersView.vue @@ -240,7 +240,7 @@ const resetPassword = async (user: UserItem) => { try { await apiRequest(`/api/auth/users/${user.id}/reset-password`, { method: 'PUT', - body: JSON.stringify(newPassword), + body: JSON.stringify({ new_password: newPassword }), }) addNotification('密码已重置', 'success') } catch (e) { diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index d8276f7..a70f87e 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -213,6 +213,459 @@ export interface paths { patch?: never; trace?: never; }; + "/api/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Health */ + get: operations["health_api_health_get"]; + put?: never; + /** Health */ + post: operations["health_api_health_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/upload": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Upload Stp + * @description 上传STP文件并存储到数据库 + */ + post: operations["upload_stp_api_upload_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/batch-upload": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Batch Upload + * @description 批量上传多个 STP 文件,每个文件创建独立分析任务,用 batch_id 聚合。 + */ + post: operations["batch_upload_api_batch_upload_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/batch/{batch_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Batch Status + * @description 聚合查询批量任务进度(D7:以 PG 为单一事实源,按 batch_id 聚合;Redis 仅热缓存) + */ + get: operations["get_batch_status_api_batch__batch_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/status/{task_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Status + * @description 获取任务状态(需登录,且仅任务所有者可访问) + * + * 优先返回内存中的任务信息; + * 如果内存中不存在,则从 PostgreSQL + RustFS 组装一个持久化的任务视图, + * 结构与内存任务保持尽量一致,便于前端集中展示总结性信息。 + */ + get: operations["get_status_api_status__task_id__get"]; + put?: never; + /** + * Get Status + * @description 获取任务状态(需登录,且仅任务所有者可访问) + * + * 优先返回内存中的任务信息; + * 如果内存中不存在,则从 PostgreSQL + RustFS 组装一个持久化的任务视图, + * 结构与内存任务保持尽量一致,便于前端集中展示总结性信息。 + */ + post: operations["get_status_api_status__task_id__post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get File History + * @description 获取当前用户按文件名分组的文件历史记录(支持多上传) + */ + get: operations["get_file_history_api_history_get"]; + put?: never; + /** + * Get File History + * @description 获取当前用户按文件名分组的文件历史记录(支持多上传) + */ + post: operations["get_file_history_api_history_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/history/{filename}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get File Records + * @description 获取当前用户指定文件名的所有上传记录(支持多上传历史) + */ + get: operations["get_file_records_api_history__filename__get"]; + put?: never; + /** + * Get File Records + * @description 获取当前用户指定文件名的所有上传记录(支持多上传历史) + */ + post: operations["get_file_records_api_history__filename__post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/cam/plan": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Generate Cam Plan + * @description 基于任务分模结果生成 CAM 准备包(MVP)。 + */ + post: operations["generate_cam_plan_api_cam_plan_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/optimize-layout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Optimize Cavity Layout */ + post: operations["optimize_cavity_layout_api_optimize_layout_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/design-cooling": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Design Cooling System */ + post: operations["design_cooling_system_api_design_cooling_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/design-gating": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Design Gating System */ + post: operations["design_gating_system_api_design_gating_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/design-mold-system": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Design Complete Mold System */ + post: operations["design_complete_mold_system_api_design_mold_system_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/detect-undercuts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Detect Undercuts */ + post: operations["detect_undercuts_api_detect_undercuts_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/cost-estimate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Estimate Cost + * @description 模具成本估算:优先使用 LLM,未启用时降级为规则式估算 + */ + post: operations["estimate_cost_api_cost_estimate_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/design-cam": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Design Mold Cam */ + post: operations["design_mold_cam_api_design_cam_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/check-collision": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Check Toolpath Collision */ + post: operations["check_toolpath_collision_api_check_collision_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/optimize-toolpath": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Optimize Toolpath */ + post: operations["optimize_toolpath_api_optimize_toolpath_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/design-electrodes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Design Edm Electrodes */ + post: operations["design_edm_electrodes_api_design_electrodes_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/simulate-machining": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Simulate Machining */ + post: operations["simulate_machining_api_simulate_machining_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/export-mold": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Export Mold Results */ + post: operations["export_mold_results_api_export_mold_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/export-download/{filepath}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Download Export File */ + get: operations["download_export_file_api_export_download__filepath__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/export-recommendations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Export Recommendations */ + get: operations["get_export_recommendations_api_export_recommendations_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/aluminum-price/current": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Aluminum Current Price */ + get: operations["aluminum_current_price_api_aluminum_price_current_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/aluminum-price/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Aluminum Price History */ + get: operations["aluminum_price_history_api_aluminum_price_history_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/products": { parameters: { query?: never; @@ -717,6 +1170,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/purchase-demands/convert": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Convert Purchase Demands + * @description 将采购需求一键转换为采购单(按供应商分组,每组生成一张草稿采购单) + */ + post: operations["convert_purchase_demands_api_purchase_demands_convert_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/dashboard": { parameters: { query?: never; @@ -887,242 +1360,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/aluminum-price/current": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Aluminum Current Price */ - get: operations["aluminum_current_price_api_aluminum_price_current_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/aluminum-price/history": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Aluminum Price History */ - get: operations["aluminum_price_history_api_aluminum_price_history_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/health": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Health */ - get: operations["health_api_health_get"]; - put?: never; - /** Health */ - post: operations["health_api_health_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/upload": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Upload Stp - * @description 上传STP文件并存储到数据库 - */ - post: operations["upload_stp_api_upload_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/batch-upload": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Batch Upload - * @description 批量上传多个 STP 文件,每个文件创建独立分析任务,用 batch_id 聚合。 - */ - post: operations["batch_upload_api_batch_upload_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/batch/{batch_id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Batch Status - * @description 聚合查询批量任务进度 - */ - get: operations["get_batch_status_api_batch__batch_id__get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/status/{task_id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Status - * @description 获取任务状态 - * - * 优先返回内存中的任务信息; - * 如果内存中不存在,则从 PostgreSQL + RustFS 组装一个持久化的任务视图, - * 结构与内存任务保持尽量一致,便于前端集中展示总结性信息。 - */ - get: operations["get_status_api_status__task_id__get"]; - put?: never; - /** - * Get Status - * @description 获取任务状态 - * - * 优先返回内存中的任务信息; - * 如果内存中不存在,则从 PostgreSQL + RustFS 组装一个持久化的任务视图, - * 结构与内存任务保持尽量一致,便于前端集中展示总结性信息。 - */ - post: operations["get_status_api_status__task_id__post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/history": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get File History - * @description 获取按文件名分组的文件历史记录(支持多上传) - */ - get: operations["get_file_history_api_history_get"]; - put?: never; - /** - * Get File History - * @description 获取按文件名分组的文件历史记录(支持多上传) - */ - post: operations["get_file_history_api_history_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/history/{filename}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get File Records - * @description 获取指定文件名的所有上传记录(支持多上传历史) - */ - get: operations["get_file_records_api_history__filename__get"]; - put?: never; - /** - * Get File Records - * @description 获取指定文件名的所有上传记录(支持多上传历史) - */ - post: operations["get_file_records_api_history__filename__post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/debug/tasks": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Debug Tasks - * @description 调试接口:查看所有任务 - */ - get: operations["debug_tasks_api_debug_tasks_get"]; - put?: never; - /** - * Debug Tasks - * @description 调试接口:查看所有任务 - */ - post: operations["debug_tasks_api_debug_tasks_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/cam/plan": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Generate Cam Plan - * @description 基于任务分模结果生成 CAM 准备包(MVP)。 - */ - post: operations["generate_cam_plan_api_cam_plan_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/health": { parameters: { query?: never; @@ -1141,146 +1378,19 @@ export interface paths { patch?: never; trace?: never; }; - "/": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Root */ - get: operations["root__get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/moldinsight": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Moldinsight */ - get: operations["moldinsight_moldinsight_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/inventory": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Inventory */ - get: operations["inventory_inventory_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/login": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Login */ - get: operations["login_login_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/users": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Users */ - get: operations["users_users_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/moldinsight/result/{task_id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Moldinsight Result */ - get: operations["moldinsight_result_moldinsight_result__task_id__get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/_design-system": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Design System */ - get: operations["design_system__design_system_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/_release": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Release */ - get: operations["release__release_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; } export type webhooks = Record; export interface components { schemas: { + /** BBox3D */ + BBox3D: { + /** Dimensions */ + dimensions?: number[]; + /** Min */ + min?: number[] | null; + /** Max */ + max?: number[] | null; + }; /** Body_batch_upload_api_batch_upload_post */ Body_batch_upload_api_batch_upload_post: { /** Files */ @@ -1350,6 +1460,75 @@ export interface components { /** Cavity Match */ cavity_match: number; }; + /** CamDesignRequest */ + CamDesignRequest: { + cavity_bbox?: components["schemas"]["BBox3D"]; + stock_bbox?: components["schemas"]["BBox3D"]; + /** + * Mold Steel + * @default P20 + */ + mold_steel: string; + /** + * Surface Quality + * @default standard + */ + surface_quality: string; + /** + * Controller + * @default fanuc + */ + controller: string; + }; + /** + * CamPlanRequest + * @description 未提供的偏好字段回落到任务持久化偏好,再回落到默认值。 + */ + CamPlanRequest: { + /** Task Id */ + task_id: string; + /** Scheme Id */ + scheme_id?: string | null; + /** Mold Steel */ + mold_steel?: string | null; + /** Surface Quality */ + surface_quality?: string | null; + /** Controller */ + controller?: string | null; + /** Include Gcode */ + include_gcode?: boolean | null; + }; + /** CollisionCheckRequest */ + CollisionCheckRequest: { + /** Toolpath Points */ + toolpath_points?: number[][]; + tool?: components["schemas"]["ToolSpec"]; + stock_bbox?: components["schemas"]["BBox3D"]; + /** Clamp Positions */ + clamp_positions?: number[][] | null; + }; + /** CoolingDesignRequest */ + CoolingDesignRequest: { + mold_size?: components["schemas"]["MoldSize"]; + product_bbox?: components["schemas"]["BBox3D"]; + /** + * Material + * @default ABS + */ + material: string; + /** + * Cavity Count + * @default 1 + */ + cavity_count: number; + /** Cycle Time Target */ + cycle_time_target?: number | null; + }; + /** CostEstimateRequest */ + CostEstimateRequest: { + /** Task Id */ + task_id: string; + }; /** CustomerCreate */ CustomerCreate: { /** Code */ @@ -1382,6 +1561,40 @@ export interface components { /** Is Active */ is_active: boolean; }; + /** ElectrodeDesignRequest */ + ElectrodeDesignRequest: { + /** Undercut Regions */ + undercut_regions?: { + [key: string]: unknown; + }[]; + cavity_bbox?: components["schemas"]["BBox3D"]; + /** + * Material + * @default copper + */ + material: string; + /** + * Spark Gap + * @default 0.05 + */ + spark_gap: number; + /** + * Overburn + * @default 0.1 + */ + overburn: number; + }; + /** ExportMoldRequest */ + ExportMoldRequest: { + /** Task Id */ + task_id: string; + /** Scheme Id */ + scheme_id?: string | null; + /** Formats */ + formats?: string[]; + /** Components */ + components?: string[]; + }; /** FinanceAllocationCreate */ FinanceAllocationCreate: { /** @@ -1545,6 +1758,27 @@ export interface components { */ allocations: components["schemas"]["FinanceAllocationResponse"][]; }; + /** GatingDesignRequest */ + GatingDesignRequest: { + product_bbox?: components["schemas"]["BBox3D"]; + /** + * Material + * @default ABS + */ + material: string; + /** + * Cavity Count + * @default 1 + */ + cavity_count: number; + /** + * Gate Type + * @default auto + */ + gate_type: string; + /** Layout Positions */ + layout_positions?: number[][] | null; + }; /** HTTPValidationError */ HTTPValidationError: { /** Detail */ @@ -1610,6 +1844,19 @@ export interface components { /** Password */ password: string; }; + /** MachiningSimulateRequest */ + MachiningSimulateRequest: { + /** Operations */ + operations?: { + [key: string]: unknown; + }[]; + stock_bbox?: components["schemas"]["BBox3D"]; + /** + * Resolution + * @default 2 + */ + resolution: number; + }; /** MaterialConsumptionItem */ MaterialConsumptionItem: { /** Material Id */ @@ -1772,6 +2019,63 @@ export interface components { */ updated_at: string; }; + /** MoldSize */ + MoldSize: { + /** + * Length + * @default 300 + */ + length: number; + /** + * Width + * @default 300 + */ + width: number; + /** + * Height + * @default 200 + */ + height: number; + }; + /** MoldSystemDesignRequest */ + MoldSystemDesignRequest: { + mold_size?: components["schemas"]["MoldSize"]; + product_bbox?: components["schemas"]["BBox3D"]; + /** + * Material + * @default ABS + */ + material: string; + /** + * Cavity Count + * @default 1 + */ + cavity_count: number; + /** + * Gate Type + * @default auto + */ + gate_type: string; + /** Cycle Time Target */ + cycle_time_target?: number | null; + /** Layout Positions */ + layout_positions?: number[][] | null; + }; + /** OptimizeLayoutRequest */ + OptimizeLayoutRequest: { + product_bbox?: components["schemas"]["BBox3D"]; + /** + * Cavity Count + * @default 1 + */ + cavity_count: number; + mold_base_size?: components["schemas"]["BBox3D"] | null; + /** + * Layout Type + * @default auto + */ + layout_type: string; + }; /** PaginatedResponse[FinanceTransactionResponse] */ PaginatedResponse_FinanceTransactionResponse_: { /** Items */ @@ -2111,6 +2415,52 @@ export interface components { */ sales_order_ids: number[]; }; + /** + * PurchaseDemandConvertItem + * @description 待转采购单的单条需求 + */ + PurchaseDemandConvertItem: { + /** Material Id */ + material_id: number; + /** + * Quantity + * @description 采购数量(通常取缺口量) + */ + quantity: number | string; + /** + * Unit Cost + * @description 单价 + */ + unit_cost: number | string; + /** Supplier Id */ + supplier_id: number; + }; + /** + * PurchaseDemandConvertRequest + * @description 一键生成采购单请求(按 supplier_id 分组,每组生成一张草稿采购单) + */ + PurchaseDemandConvertRequest: { + /** Items */ + items: components["schemas"]["PurchaseDemandConvertItem"][]; + /** Expected Date */ + expected_date?: string | null; + /** Remark */ + remark?: string | null; + }; + /** + * PurchaseDemandConvertResponse + * @description 一键生成采购单结果 + */ + PurchaseDemandConvertResponse: { + /** Created Orders */ + created_orders?: components["schemas"]["PurchaseOrderCreatedResponse"][]; + /** + * Skipped + * @description 因数量<=0被跳过的条目数 + * @default 0 + */ + skipped: number; + }; /** * PurchaseDemandItemResponse * @description 单个物料的采购建议 @@ -2204,6 +2554,24 @@ export interface components { /** Items */ items: components["schemas"]["PurchaseOrderItemCreate"][]; }; + /** + * PurchaseOrderCreatedResponse + * @description 已创建的采购单摘要 + */ + PurchaseOrderCreatedResponse: { + /** Purchase Order Id */ + purchase_order_id: number; + /** Order No */ + order_no: string; + /** Supplier Id */ + supplier_id: number; + /** Supplier Name */ + supplier_name: string; + /** Item Count */ + item_count: number; + /** Total Amount */ + total_amount: string; + }; /** PurchaseOrderDetailResponse */ PurchaseOrderDetailResponse: { /** Id */ @@ -2380,6 +2748,11 @@ export interface components { /** Status */ status: string; }; + /** ResetPasswordRequest */ + ResetPasswordRequest: { + /** New Password */ + new_password: string; + }; /** RoleCreate */ RoleCreate: { /** Code */ @@ -2687,6 +3060,42 @@ export interface components { token_type: string; user: components["schemas"]["UserResponse"]; }; + /** ToolSpec */ + ToolSpec: { + /** + * Diameter + * @default 10 + */ + diameter: number; + /** + * Flute Length + * @default 30 + */ + flute_length: number; + /** + * Shank Diameter + * @default 10 + */ + shank_diameter: number; + }; + /** ToolpathOptimizeRequest */ + ToolpathOptimizeRequest: { + /** Toolpath Points */ + toolpath_points?: number[][]; + /** Cutting Params */ + cutting_params?: { + [key: string]: unknown; + }; + stock_bbox?: components["schemas"]["BBox3D"] | null; + }; + /** UndercutDetectRequest */ + UndercutDetectRequest: { + /** Task Id */ + task_id: string; + /** Parting Direction */ + parting_direction?: number[]; + mold_size?: components["schemas"]["MoldSize"]; + }; /** UserCreate */ UserCreate: { /** Username */ @@ -3013,16 +3422,18 @@ export interface operations { }; reset_user_password_api_auth_users__user_id__reset_password_put: { parameters: { - query: { - new_password: string; - }; + query?: never; header?: never; path: { user_id: number; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["ResetPasswordRequest"]; + }; + }; responses: { /** @description Successful Response */ 200: { @@ -3282,6 +3693,851 @@ export interface operations { }; }; }; + health_api_health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + health_api_health_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + upload_stp_api_upload_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_upload_stp_api_upload_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + batch_upload_api_batch_upload_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_batch_upload_api_batch_upload_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_batch_status_api_batch__batch_id__get: { + parameters: { + query?: never; + header?: never; + path: { + batch_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_status_api_status__task_id__get: { + parameters: { + query?: never; + header?: never; + path: { + task_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_status_api_status__task_id__post: { + parameters: { + query?: never; + header?: never; + path: { + task_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_file_history_api_history_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + get_file_history_api_history_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + get_file_records_api_history__filename__get: { + parameters: { + query?: never; + header?: never; + path: { + filename: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_file_records_api_history__filename__post: { + parameters: { + query?: never; + header?: never; + path: { + filename: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + generate_cam_plan_api_cam_plan_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CamPlanRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + optimize_cavity_layout_api_optimize_layout_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["OptimizeLayoutRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + design_cooling_system_api_design_cooling_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CoolingDesignRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + design_gating_system_api_design_gating_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GatingDesignRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + design_complete_mold_system_api_design_mold_system_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MoldSystemDesignRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + detect_undercuts_api_detect_undercuts_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UndercutDetectRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + estimate_cost_api_cost_estimate_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CostEstimateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + design_mold_cam_api_design_cam_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CamDesignRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + check_toolpath_collision_api_check_collision_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CollisionCheckRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + optimize_toolpath_api_optimize_toolpath_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ToolpathOptimizeRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + design_edm_electrodes_api_design_electrodes_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ElectrodeDesignRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + simulate_machining_api_simulate_machining_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MachiningSimulateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + export_mold_results_api_export_mold_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExportMoldRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + download_export_file_api_export_download__filepath__get: { + parameters: { + query: { + task_id: string; + }; + header?: never; + path: { + filepath: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_export_recommendations_api_export_recommendations_get: { + parameters: { + query?: { + target?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + aluminum_current_price_api_aluminum_price_current_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + aluminum_price_history_api_aluminum_price_history_get: { + parameters: { + query?: { + days?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_products_api_products_get: { parameters: { query?: { @@ -4827,6 +6083,39 @@ export interface operations { }; }; }; + convert_purchase_demands_api_purchase_demands_convert_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PurchaseDemandConvertRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PurchaseDemandConvertResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_dashboard_api_dashboard_get: { parameters: { query?: never; @@ -5149,418 +6438,6 @@ export interface operations { }; }; }; - aluminum_current_price_api_aluminum_price_current_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - }; - }; - aluminum_price_history_api_aluminum_price_history_get: { - parameters: { - query?: { - days?: number; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - health_api_health_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - }; - }; - health_api_health_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - }; - }; - upload_stp_api_upload_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "multipart/form-data": components["schemas"]["Body_upload_stp_api_upload_post"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - batch_upload_api_batch_upload_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "multipart/form-data": components["schemas"]["Body_batch_upload_api_batch_upload_post"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - get_batch_status_api_batch__batch_id__get: { - parameters: { - query?: never; - header?: never; - path: { - batch_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - get_status_api_status__task_id__get: { - parameters: { - query?: never; - header?: never; - path: { - task_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - get_status_api_status__task_id__post: { - parameters: { - query?: never; - header?: never; - path: { - task_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - get_file_history_api_history_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - }; - }; - get_file_history_api_history_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - }; - }; - get_file_records_api_history__filename__get: { - parameters: { - query?: never; - header?: never; - path: { - filename: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - get_file_records_api_history__filename__post: { - parameters: { - query?: never; - header?: never; - path: { - filename: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - debug_tasks_api_debug_tasks_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - }; - }; - debug_tasks_api_debug_tasks_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - }; - }; - generate_cam_plan_api_cam_plan_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - }; - }; health_health_get: { parameters: { query?: never; @@ -5601,175 +6478,4 @@ export interface operations { }; }; }; - root__get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - }; - }; - moldinsight_moldinsight_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - }; - }; - inventory_inventory_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - }; - }; - login_login_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - }; - }; - users_users_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - }; - }; - moldinsight_result_moldinsight_result__task_id__get: { - parameters: { - query?: never; - header?: never; - path: { - task_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - design_system__design_system_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - }; - }; - release__release_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - }; - }; } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 60be57e..fff6715 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { resolve } from 'path' -export default defineConfig(({ mode }) => ({ +export default defineConfig(() => ({ plugins: [vue()], resolve: { alias: { diff --git a/migrations/env.py b/migrations/env.py index 80f91a1..a4295d0 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -2,7 +2,7 @@ - 从 shared.config.settings 读取 DB 配置,构造同步 URL(psycopg2)供 alembic 使用 (项目运行时用 asyncpg,但 alembic 是同步库,需 psycopg2) -- target_metadata 指向 shared.models.database.Base.metadata +- target_metadata 指向 shared.models.base.Base.metadata(全量模型注册见下方 import) - 支持 ALEMBIC_URL 环境变量覆盖(用于离线/空库生成初始迁移,如 sqlite:///empty.db) """ from logging.config import fileConfig @@ -18,8 +18,11 @@ project_root = Path(__file__).parent.parent sys.path.insert(0, str(project_root / "src")) from shared.config.settings import settings # noqa: E402 -from shared.models.database import Base # noqa: E402 -import shared.models.database # noqa: E402,F401 # 导入所有模型,确保 metadata 注册 +from shared.models.base import Base # noqa: E402 +# 导入全部三包模型,确保 metadata 注册(全量注册点约定见 shared/models/base.py) +import shared.models.identity # noqa: E402,F401 +import moldinsight.models # noqa: E402,F401 +import inventory.models # noqa: E402,F401 config = context.config diff --git a/openapi.json b/openapi.json index cccf8f3..39d72ec 100644 --- a/openapi.json +++ b/openapi.json @@ -1,8 +1,7 @@ { "openapi": "3.1.0", "info": { - "title": "Gemold - 模具制造管理系统", - "description": "模具制造行业综合管理平台,包含模具分析、进销存管理等功能", + "title": "Gemold - Unified Backend", "version": "4.0.0" }, "paths": { @@ -328,17 +327,18 @@ "type": "integer", "title": "User Id" } - }, - { - "name": "new_password", - "in": "query", - "required": true, - "schema": { - "type": "string", - "title": "New Password" - } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordRequest" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -713,6 +713,1064 @@ } } }, + "/api/health": { + "get": { + "summary": "Health", + "operationId": "health_api_health_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "summary": "Health", + "operationId": "health_api_health_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/upload": { + "post": { + "summary": "Upload Stp", + "description": "上传STP文件并存储到数据库", + "operationId": "upload_stp_api_upload_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_upload_stp_api_upload_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/api/batch-upload": { + "post": { + "summary": "Batch Upload", + "description": "批量上传多个 STP 文件,每个文件创建独立分析任务,用 batch_id 聚合。", + "operationId": "batch_upload_api_batch_upload_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_batch_upload_api_batch_upload_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/api/batch/{batch_id}": { + "get": { + "summary": "Get Batch Status", + "description": "聚合查询批量任务进度(D7:以 PG 为单一事实源,按 batch_id 聚合;Redis 仅热缓存)", + "operationId": "get_batch_status_api_batch__batch_id__get", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "batch_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Batch Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/status/{task_id}": { + "post": { + "summary": "Get Status", + "description": "获取任务状态(需登录,且仅任务所有者可访问)\n\n优先返回内存中的任务信息;\n如果内存中不存在,则从 PostgreSQL + RustFS 组装一个持久化的任务视图,\n结构与内存任务保持尽量一致,便于前端集中展示总结性信息。", + "operationId": "get_status_api_status__task_id__post", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "get": { + "summary": "Get Status", + "description": "获取任务状态(需登录,且仅任务所有者可访问)\n\n优先返回内存中的任务信息;\n如果内存中不存在,则从 PostgreSQL + RustFS 组装一个持久化的任务视图,\n结构与内存任务保持尽量一致,便于前端集中展示总结性信息。", + "operationId": "get_status_api_status__task_id__get", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/history": { + "get": { + "summary": "Get File History", + "description": "获取当前用户按文件名分组的文件历史记录(支持多上传)", + "operationId": "get_file_history_api_history_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + }, + "post": { + "summary": "Get File History", + "description": "获取当前用户按文件名分组的文件历史记录(支持多上传)", + "operationId": "get_file_history_api_history_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/api/history/{filename}": { + "post": { + "summary": "Get File Records", + "description": "获取当前用户指定文件名的所有上传记录(支持多上传历史)", + "operationId": "get_file_records_api_history__filename__post", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "filename", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Filename" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "get": { + "summary": "Get File Records", + "description": "获取当前用户指定文件名的所有上传记录(支持多上传历史)", + "operationId": "get_file_records_api_history__filename__get", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "filename", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Filename" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/cam/plan": { + "post": { + "summary": "Generate Cam Plan", + "description": "基于任务分模结果生成 CAM 准备包(MVP)。", + "operationId": "generate_cam_plan_api_cam_plan_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CamPlanRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/api/optimize-layout": { + "post": { + "summary": "Optimize Cavity Layout", + "operationId": "optimize_cavity_layout_api_optimize_layout_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OptimizeLayoutRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/api/design-cooling": { + "post": { + "summary": "Design Cooling System", + "operationId": "design_cooling_system_api_design_cooling_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoolingDesignRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/api/design-gating": { + "post": { + "summary": "Design Gating System", + "operationId": "design_gating_system_api_design_gating_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatingDesignRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/api/design-mold-system": { + "post": { + "summary": "Design Complete Mold System", + "operationId": "design_complete_mold_system_api_design_mold_system_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoldSystemDesignRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/api/detect-undercuts": { + "post": { + "summary": "Detect Undercuts", + "operationId": "detect_undercuts_api_detect_undercuts_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UndercutDetectRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/api/cost-estimate": { + "post": { + "summary": "Estimate Cost", + "description": "模具成本估算:优先使用 LLM,未启用时降级为规则式估算", + "operationId": "estimate_cost_api_cost_estimate_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CostEstimateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/api/design-cam": { + "post": { + "summary": "Design Mold Cam", + "operationId": "design_mold_cam_api_design_cam_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CamDesignRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/api/check-collision": { + "post": { + "summary": "Check Toolpath Collision", + "operationId": "check_toolpath_collision_api_check_collision_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollisionCheckRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/api/optimize-toolpath": { + "post": { + "summary": "Optimize Toolpath", + "operationId": "optimize_toolpath_api_optimize_toolpath_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolpathOptimizeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/api/design-electrodes": { + "post": { + "summary": "Design Edm Electrodes", + "operationId": "design_edm_electrodes_api_design_electrodes_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ElectrodeDesignRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/api/simulate-machining": { + "post": { + "summary": "Simulate Machining", + "operationId": "simulate_machining_api_simulate_machining_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MachiningSimulateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/api/export-mold": { + "post": { + "summary": "Export Mold Results", + "operationId": "export_mold_results_api_export_mold_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportMoldRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/api/export-download/{filepath}": { + "get": { + "summary": "Download Export File", + "operationId": "download_export_file_api_export_download__filepath__get", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "filepath", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Filepath" + } + }, + { + "name": "task_id", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/export-recommendations": { + "get": { + "summary": "Get Export Recommendations", + "operationId": "get_export_recommendations_api_export_recommendations_get", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "target", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "ug", + "title": "Target" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/aluminum-price/current": { + "get": { + "tags": [ + "铝金属价格" + ], + "summary": "Aluminum Current Price", + "operationId": "aluminum_current_price_api_aluminum_price_current_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/aluminum-price/history": { + "get": { + "tags": [ + "铝金属价格" + ], + "summary": "Aluminum Price History", + "operationId": "aluminum_price_history_api_aluminum_price_history_get", + "parameters": [ + { + "name": "days", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 365, + "minimum": 7, + "default": 30, + "title": "Days" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/products": { "get": { "tags": [ @@ -3364,6 +4422,54 @@ ] } }, + "/api/purchase-demands/convert": { + "post": { + "tags": [ + "进销存", + "采购需求推导" + ], + "summary": "Convert Purchase Demands", + "description": "将采购需求一键转换为采购单(按供应商分组,每组生成一张草稿采购单)", + "operationId": "convert_purchase_demands_api_purchase_demands_convert_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PurchaseDemandConvertRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PurchaseDemandConvertResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } + }, "/api/dashboard": { "get": { "tags": [ @@ -4124,459 +5230,6 @@ } } }, - "/api/aluminum-price/current": { - "get": { - "tags": [ - "铝金属价格" - ], - "summary": "Aluminum Current Price", - "operationId": "aluminum_current_price_api_aluminum_price_current_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - } - } - } - }, - "/api/aluminum-price/history": { - "get": { - "tags": [ - "铝金属价格" - ], - "summary": "Aluminum Price History", - "operationId": "aluminum_price_history_api_aluminum_price_history_get", - "parameters": [ - { - "name": "days", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "maximum": 365, - "minimum": 7, - "default": 30, - "title": "Days" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/health": { - "get": { - "summary": "Health", - "operationId": "health_api_health_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - } - } - }, - "post": { - "summary": "Health", - "operationId": "health_api_health_post", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - } - } - } - }, - "/api/upload": { - "post": { - "summary": "Upload Stp", - "description": "上传STP文件并存储到数据库", - "operationId": "upload_stp_api_upload_post", - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Body_upload_stp_api_upload_post" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ] - } - }, - "/api/batch-upload": { - "post": { - "summary": "Batch Upload", - "description": "批量上传多个 STP 文件,每个文件创建独立分析任务,用 batch_id 聚合。", - "operationId": "batch_upload_api_batch_upload_post", - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Body_batch_upload_api_batch_upload_post" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ] - } - }, - "/api/batch/{batch_id}": { - "get": { - "summary": "Get Batch Status", - "description": "聚合查询批量任务进度", - "operationId": "get_batch_status_api_batch__batch_id__get", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "batch_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Batch Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/status/{task_id}": { - "post": { - "summary": "Get Status", - "description": "获取任务状态\n\n优先返回内存中的任务信息;\n如果内存中不存在,则从 PostgreSQL + RustFS 组装一个持久化的任务视图,\n结构与内存任务保持尽量一致,便于前端集中展示总结性信息。", - "operationId": "get_status_api_status__task_id__post", - "parameters": [ - { - "name": "task_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Task Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "get": { - "summary": "Get Status", - "description": "获取任务状态\n\n优先返回内存中的任务信息;\n如果内存中不存在,则从 PostgreSQL + RustFS 组装一个持久化的任务视图,\n结构与内存任务保持尽量一致,便于前端集中展示总结性信息。", - "operationId": "get_status_api_status__task_id__get", - "parameters": [ - { - "name": "task_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Task Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/history": { - "get": { - "summary": "Get File History", - "description": "获取按文件名分组的文件历史记录(支持多上传)", - "operationId": "get_file_history_api_history_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - } - } - }, - "post": { - "summary": "Get File History", - "description": "获取按文件名分组的文件历史记录(支持多上传)", - "operationId": "get_file_history_api_history_post", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - } - } - } - }, - "/api/history/{filename}": { - "post": { - "summary": "Get File Records", - "description": "获取指定文件名的所有上传记录(支持多上传历史)", - "operationId": "get_file_records_api_history__filename__post", - "parameters": [ - { - "name": "filename", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Filename" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "get": { - "summary": "Get File Records", - "description": "获取指定文件名的所有上传记录(支持多上传历史)", - "operationId": "get_file_records_api_history__filename__get", - "parameters": [ - { - "name": "filename", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Filename" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/debug/tasks": { - "get": { - "summary": "Debug Tasks", - "description": "调试接口:查看所有任务", - "operationId": "debug_tasks_api_debug_tasks_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - } - } - }, - "post": { - "summary": "Debug Tasks", - "description": "调试接口:查看所有任务", - "operationId": "debug_tasks_api_debug_tasks_post", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - } - } - } - }, - "/api/cam/plan": { - "post": { - "summary": "Generate Cam Plan", - "description": "基于任务分模结果生成 CAM 准备包(MVP)。", - "operationId": "generate_cam_plan_api_cam_plan_post", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ] - } - }, "/health": { "get": { "summary": "Health", @@ -4606,159 +5259,51 @@ } } } - }, - "/": { - "get": { - "summary": "Root", - "operationId": "root__get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - } - } - } - }, - "/moldinsight": { - "get": { - "summary": "Moldinsight", - "operationId": "moldinsight_moldinsight_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - } - } - } - }, - "/inventory": { - "get": { - "summary": "Inventory", - "operationId": "inventory_inventory_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - } - } - } - }, - "/login": { - "get": { - "summary": "Login", - "operationId": "login_login_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - } - } - } - }, - "/users": { - "get": { - "summary": "Users", - "operationId": "users_users_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - } - } - } - }, - "/moldinsight/result/{task_id}": { - "get": { - "summary": "Moldinsight Result", - "operationId": "moldinsight_result_moldinsight_result__task_id__get", - "parameters": [ - { - "name": "task_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Task Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/_design-system": { - "get": { - "summary": "Design System", - "operationId": "design_system__design_system_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - } - } - } - }, - "/_release": { - "get": { - "summary": "Release", - "operationId": "release__release_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - } - } - } } }, "components": { "schemas": { + "BBox3D": { + "properties": { + "dimensions": { + "items": { + "type": "number" + }, + "type": "array", + "title": "Dimensions" + }, + "min": { + "anyOf": [ + { + "items": { + "type": "number" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Min" + }, + "max": { + "anyOf": [ + { + "items": { + "type": "number" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Max" + } + }, + "type": "object", + "title": "BBox3D" + }, "Body_batch_upload_api_batch_upload_post": { "properties": { "files": { @@ -4899,6 +5444,189 @@ ], "title": "Body_upload_stp_api_upload_post" }, + "CamDesignRequest": { + "properties": { + "cavity_bbox": { + "$ref": "#/components/schemas/BBox3D" + }, + "stock_bbox": { + "$ref": "#/components/schemas/BBox3D" + }, + "mold_steel": { + "type": "string", + "title": "Mold Steel", + "default": "P20" + }, + "surface_quality": { + "type": "string", + "title": "Surface Quality", + "default": "standard" + }, + "controller": { + "type": "string", + "title": "Controller", + "default": "fanuc" + } + }, + "type": "object", + "title": "CamDesignRequest" + }, + "CamPlanRequest": { + "properties": { + "task_id": { + "type": "string", + "title": "Task Id" + }, + "scheme_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scheme Id" + }, + "mold_steel": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mold Steel" + }, + "surface_quality": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Surface Quality" + }, + "controller": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Controller" + }, + "include_gcode": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Include Gcode" + } + }, + "type": "object", + "required": [ + "task_id" + ], + "title": "CamPlanRequest", + "description": "未提供的偏好字段回落到任务持久化偏好,再回落到默认值。" + }, + "CollisionCheckRequest": { + "properties": { + "toolpath_points": { + "items": { + "items": { + "type": "number" + }, + "type": "array" + }, + "type": "array", + "title": "Toolpath Points" + }, + "tool": { + "$ref": "#/components/schemas/ToolSpec" + }, + "stock_bbox": { + "$ref": "#/components/schemas/BBox3D" + }, + "clamp_positions": { + "anyOf": [ + { + "items": { + "items": { + "type": "number" + }, + "type": "array" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Clamp Positions" + } + }, + "type": "object", + "title": "CollisionCheckRequest" + }, + "CoolingDesignRequest": { + "properties": { + "mold_size": { + "$ref": "#/components/schemas/MoldSize" + }, + "product_bbox": { + "$ref": "#/components/schemas/BBox3D" + }, + "material": { + "type": "string", + "title": "Material", + "default": "ABS" + }, + "cavity_count": { + "type": "integer", + "maximum": 64.0, + "minimum": 1.0, + "title": "Cavity Count", + "default": 1 + }, + "cycle_time_target": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Cycle Time Target" + } + }, + "type": "object", + "title": "CoolingDesignRequest" + }, + "CostEstimateRequest": { + "properties": { + "task_id": { + "type": "string", + "title": "Task Id" + } + }, + "type": "object", + "required": [ + "task_id" + ], + "title": "CostEstimateRequest" + }, "CustomerCreate": { "properties": { "code": { @@ -5038,6 +5766,76 @@ ], "title": "CustomerResponse" }, + "ElectrodeDesignRequest": { + "properties": { + "undercut_regions": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Undercut Regions" + }, + "cavity_bbox": { + "$ref": "#/components/schemas/BBox3D" + }, + "material": { + "type": "string", + "title": "Material", + "default": "copper" + }, + "spark_gap": { + "type": "number", + "title": "Spark Gap", + "default": 0.05 + }, + "overburn": { + "type": "number", + "title": "Overburn", + "default": 0.1 + } + }, + "type": "object", + "title": "ElectrodeDesignRequest" + }, + "ExportMoldRequest": { + "properties": { + "task_id": { + "type": "string", + "title": "Task Id" + }, + "scheme_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scheme Id" + }, + "formats": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Formats" + }, + "components": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Components" + } + }, + "type": "object", + "required": [ + "task_id" + ], + "title": "ExportMoldRequest" + }, "FinanceAllocationCreate": { "properties": { "order_type": { @@ -5439,6 +6237,49 @@ ], "title": "FinanceTransactionResponse" }, + "GatingDesignRequest": { + "properties": { + "product_bbox": { + "$ref": "#/components/schemas/BBox3D" + }, + "material": { + "type": "string", + "title": "Material", + "default": "ABS" + }, + "cavity_count": { + "type": "integer", + "maximum": 64.0, + "minimum": 1.0, + "title": "Cavity Count", + "default": 1 + }, + "gate_type": { + "type": "string", + "title": "Gate Type", + "default": "auto" + }, + "layout_positions": { + "anyOf": [ + { + "items": { + "items": { + "type": "number" + }, + "type": "array" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Layout Positions" + } + }, + "type": "object", + "title": "GatingDesignRequest" + }, "HTTPValidationError": { "properties": { "detail": { @@ -5650,6 +6491,28 @@ ], "title": "LoginRequest" }, + "MachiningSimulateRequest": { + "properties": { + "operations": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Operations" + }, + "stock_bbox": { + "$ref": "#/components/schemas/BBox3D" + }, + "resolution": { + "type": "number", + "title": "Resolution", + "default": 2.0 + } + }, + "type": "object", + "title": "MachiningSimulateRequest" + }, "MaterialConsumptionItem": { "properties": { "material_id": { @@ -6041,6 +6904,115 @@ "title": "MaterialSupplierResponse", "description": "物料供应商关联的响应模型" }, + "MoldSize": { + "properties": { + "length": { + "type": "number", + "title": "Length", + "default": 300.0 + }, + "width": { + "type": "number", + "title": "Width", + "default": 300.0 + }, + "height": { + "type": "number", + "title": "Height", + "default": 200.0 + } + }, + "type": "object", + "title": "MoldSize" + }, + "MoldSystemDesignRequest": { + "properties": { + "mold_size": { + "$ref": "#/components/schemas/MoldSize" + }, + "product_bbox": { + "$ref": "#/components/schemas/BBox3D" + }, + "material": { + "type": "string", + "title": "Material", + "default": "ABS" + }, + "cavity_count": { + "type": "integer", + "maximum": 64.0, + "minimum": 1.0, + "title": "Cavity Count", + "default": 1 + }, + "gate_type": { + "type": "string", + "title": "Gate Type", + "default": "auto" + }, + "cycle_time_target": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Cycle Time Target" + }, + "layout_positions": { + "anyOf": [ + { + "items": { + "items": { + "type": "number" + }, + "type": "array" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Layout Positions" + } + }, + "type": "object", + "title": "MoldSystemDesignRequest" + }, + "OptimizeLayoutRequest": { + "properties": { + "product_bbox": { + "$ref": "#/components/schemas/BBox3D" + }, + "cavity_count": { + "type": "integer", + "maximum": 64.0, + "minimum": 1.0, + "title": "Cavity Count", + "default": 1 + }, + "mold_base_size": { + "anyOf": [ + { + "$ref": "#/components/schemas/BBox3D" + }, + { + "type": "null" + } + ] + }, + "layout_type": { + "type": "string", + "title": "Layout Type", + "default": "auto" + } + }, + "type": "object", + "title": "OptimizeLayoutRequest" + }, "PaginatedResponse_FinanceTransactionResponse_": { "properties": { "items": { @@ -6980,6 +7952,116 @@ "title": "PurchaseDemandCalculateRequest", "description": "计算采购需求的请求体" }, + "PurchaseDemandConvertItem": { + "properties": { + "material_id": { + "type": "integer", + "title": "Material Id" + }, + "quantity": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + } + ], + "title": "Quantity", + "description": "采购数量(通常取缺口量)" + }, + "unit_cost": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + } + ], + "title": "Unit Cost", + "description": "单价" + }, + "supplier_id": { + "type": "integer", + "title": "Supplier Id" + } + }, + "type": "object", + "required": [ + "material_id", + "quantity", + "unit_cost", + "supplier_id" + ], + "title": "PurchaseDemandConvertItem", + "description": "待转采购单的单条需求" + }, + "PurchaseDemandConvertRequest": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/PurchaseDemandConvertItem" + }, + "type": "array", + "minItems": 1, + "title": "Items" + }, + "expected_date": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Expected Date" + }, + "remark": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Remark" + } + }, + "type": "object", + "required": [ + "items" + ], + "title": "PurchaseDemandConvertRequest", + "description": "一键生成采购单请求(按 supplier_id 分组,每组生成一张草稿采购单)" + }, + "PurchaseDemandConvertResponse": { + "properties": { + "created_orders": { + "items": { + "$ref": "#/components/schemas/PurchaseOrderCreatedResponse" + }, + "type": "array", + "title": "Created Orders" + }, + "skipped": { + "type": "integer", + "title": "Skipped", + "description": "因数量<=0被跳过的条目数", + "default": 0 + } + }, + "type": "object", + "title": "PurchaseDemandConvertResponse", + "description": "一键生成采购单结果" + }, "PurchaseDemandItemResponse": { "properties": { "material_id": { @@ -7163,6 +8245,46 @@ ], "title": "PurchaseOrderCreate" }, + "PurchaseOrderCreatedResponse": { + "properties": { + "purchase_order_id": { + "type": "integer", + "title": "Purchase Order Id" + }, + "order_no": { + "type": "string", + "title": "Order No" + }, + "supplier_id": { + "type": "integer", + "title": "Supplier Id" + }, + "supplier_name": { + "type": "string", + "title": "Supplier Name" + }, + "item_count": { + "type": "integer", + "title": "Item Count" + }, + "total_amount": { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", + "title": "Total Amount" + } + }, + "type": "object", + "required": [ + "purchase_order_id", + "order_no", + "supplier_id", + "supplier_name", + "item_count", + "total_amount" + ], + "title": "PurchaseOrderCreatedResponse", + "description": "已创建的采购单摘要" + }, "PurchaseOrderDetailResponse": { "properties": { "id": { @@ -7699,6 +8821,20 @@ ], "title": "ReceivableItemResponse" }, + "ResetPasswordRequest": { + "properties": { + "new_password": { + "type": "string", + "minLength": 6, + "title": "New Password" + } + }, + "type": "object", + "required": [ + "new_password" + ], + "title": "ResetPasswordRequest" + }, "RoleCreate": { "properties": { "code": { @@ -8759,6 +9895,81 @@ ], "title": "Token" }, + "ToolSpec": { + "properties": { + "diameter": { + "type": "number", + "title": "Diameter", + "default": 10.0 + }, + "flute_length": { + "type": "number", + "title": "Flute Length", + "default": 30.0 + }, + "shank_diameter": { + "type": "number", + "title": "Shank Diameter", + "default": 10.0 + } + }, + "type": "object", + "title": "ToolSpec" + }, + "ToolpathOptimizeRequest": { + "properties": { + "toolpath_points": { + "items": { + "items": { + "type": "number" + }, + "type": "array" + }, + "type": "array", + "title": "Toolpath Points" + }, + "cutting_params": { + "additionalProperties": true, + "type": "object", + "title": "Cutting Params" + }, + "stock_bbox": { + "anyOf": [ + { + "$ref": "#/components/schemas/BBox3D" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "title": "ToolpathOptimizeRequest" + }, + "UndercutDetectRequest": { + "properties": { + "task_id": { + "type": "string", + "title": "Task Id" + }, + "parting_direction": { + "items": { + "type": "number" + }, + "type": "array", + "title": "Parting Direction" + }, + "mold_size": { + "$ref": "#/components/schemas/MoldSize" + } + }, + "type": "object", + "required": [ + "task_id" + ], + "title": "UndercutDetectRequest" + }, "UserCreate": { "properties": { "username": { diff --git a/scripts/migrations/backfill_mold_cavity_confidence.py b/scripts/migrations/backfill_mold_cavity_confidence.py index a537791..c3dc36e 100644 --- a/scripts/migrations/backfill_mold_cavity_confidence.py +++ b/scripts/migrations/backfill_mold_cavity_confidence.py @@ -22,7 +22,7 @@ sys.path.insert(0, str(src_root)) from sqlalchemy import select from database.database import db_manager -from models.database import MoldCavityData +from moldinsight.models import MoldCavityData from storage.rustfs_storage import rustfs_manager from config.settings import settings from services.storage_integration_rustfs import StorageIntegrationService diff --git a/scripts/tools/check_api_response.py b/scripts/tools/check_api_response.py index 8782576..c7c3cc8 100644 --- a/scripts/tools/check_api_response.py +++ b/scripts/tools/check_api_response.py @@ -14,7 +14,7 @@ sys.path.insert(0, str(project_root / "src")) from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy import text, select from config.settings import settings -from models.database import SalesOrder, Customer +from inventory.models import SalesOrder, Customer async def check(): diff --git a/src/celery_app.py b/src/celery_app.py index 0dfcd5c..e0dc9ad 100644 --- a/src/celery_app.py +++ b/src/celery_app.py @@ -1,23 +1,16 @@ -import os -from dotenv import load_dotenv +"""Celery 应用入口。 + +D14:broker/backend 复用 settings 的 Redis 配置——此前本模块自行 +load_dotenv 并手拼 REDIS URL,与 settings 两份实现、行为可能漂移。 +""" from celery import Celery -load_dotenv() - -redis_host = os.getenv("REDIS_HOST", "localhost") -redis_port = os.getenv("REDIS_PORT", "6379") -redis_password = os.getenv("REDIS_PASSWORD", "") -redis_db = os.getenv("REDIS_DB", "0") - -if redis_password: - broker_url = f"redis://:{redis_password}@{redis_host}:{redis_port}/{redis_db}" -else: - broker_url = f"redis://{redis_host}:{redis_port}/{redis_db}" +from shared.config.settings import settings app = Celery( "moldinsight", - broker=broker_url, - backend=broker_url, + broker=settings.redis_url, + backend=settings.redis_url, include=["celery_tasks"], ) diff --git a/src/inventory/api/customer_routes.py b/src/inventory/api/customer_routes.py index 904ea99..7765a2f 100644 --- a/src/inventory/api/customer_routes.py +++ b/src/inventory/api/customer_routes.py @@ -17,7 +17,8 @@ from datetime import datetime from shared.database.database import get_db_session from shared.services.auth_service import get_current_active_user, get_current_admin_user -from shared.models.database import User, Customer +from shared.models.identity import User +from inventory.models import Customer from ..schemas import CustomerCreate, CustomerResponse router = APIRouter(prefix="/customers", tags=["客户管理"]) diff --git a/src/inventory/api/dashboard_routes.py b/src/inventory/api/dashboard_routes.py index e89bc99..90faec0 100644 --- a/src/inventory/api/dashboard_routes.py +++ b/src/inventory/api/dashboard_routes.py @@ -15,10 +15,8 @@ from sqlalchemy import select, func from shared.database.database import get_db_session from shared.services.auth_service import get_current_active_user -from shared.models.database import ( - User, Product, Supplier, Customer, Warehouse, - Inventory, PurchaseOrder, SalesOrder -) +from shared.models.identity import User +from inventory.models import Product, Supplier, Customer, Warehouse, Inventory, PurchaseOrder, SalesOrder router = APIRouter(prefix="/dashboard", tags=["仪表盘"]) diff --git a/src/inventory/api/finance_routes.py b/src/inventory/api/finance_routes.py index 509d698..23d80c6 100644 --- a/src/inventory/api/finance_routes.py +++ b/src/inventory/api/finance_routes.py @@ -8,7 +8,7 @@ from typing import Optional, List from shared.database.database import get_db_session from shared.services.auth_service import get_current_active_user -from shared.models.database import User +from shared.models.identity import User from ..schemas import ( ReceiptCreate, PaymentCreate, diff --git a/src/inventory/api/inventory_routes.py b/src/inventory/api/inventory_routes.py index 2858164..edd88fa 100644 --- a/src/inventory/api/inventory_routes.py +++ b/src/inventory/api/inventory_routes.py @@ -9,7 +9,7 @@ from typing import Optional from shared.database.database import get_db_session from shared.services.auth_service import get_current_active_user -from shared.models.database import User +from shared.models.identity import User from ..schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse from ..services.inventory_service import inventory_service diff --git a/src/inventory/api/material_routes.py b/src/inventory/api/material_routes.py index 8760ffe..1e16e02 100644 --- a/src/inventory/api/material_routes.py +++ b/src/inventory/api/material_routes.py @@ -15,7 +15,8 @@ from typing import Optional, List from shared.database.database import get_db_session from shared.services.auth_service import get_current_active_user -from shared.models.database import User, Product, MaterialPriceHistory, MaterialSupplier, Supplier +from shared.models.identity import User +from inventory.models import Product, MaterialPriceHistory, MaterialSupplier, Supplier from ..schemas import ( MaterialPriceHistoryCreate, MaterialPriceHistoryResponse, diff --git a/src/inventory/api/product_routes.py b/src/inventory/api/product_routes.py index 0b8e041..098909f 100644 --- a/src/inventory/api/product_routes.py +++ b/src/inventory/api/product_routes.py @@ -18,7 +18,9 @@ from pathlib import Path from shared.database.database import get_db_session from shared.services.auth_service import get_current_active_user, get_current_admin_user -from shared.models.database import User, Product, ProductMaterial, STPFile, ProcessingTask +from shared.models.identity import User +from moldinsight.models import STPFile, ProcessingTask +from inventory.models import Product, ProductMaterial from ..schemas import ( ProductCreate, ProductResponse, diff --git a/src/inventory/api/purchase_demand_routes.py b/src/inventory/api/purchase_demand_routes.py index 8bd87a2..b63f274 100644 --- a/src/inventory/api/purchase_demand_routes.py +++ b/src/inventory/api/purchase_demand_routes.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from shared.database.database import get_db_session from shared.services.auth_service import get_current_active_user -from shared.models.database import User +from shared.models.identity import User from ..schemas import ( PurchaseDemandCalculateRequest, PurchaseDemandResponse, diff --git a/src/inventory/api/purchase_order_routes.py b/src/inventory/api/purchase_order_routes.py index 3d7d1b9..8a685c9 100644 --- a/src/inventory/api/purchase_order_routes.py +++ b/src/inventory/api/purchase_order_routes.py @@ -9,7 +9,7 @@ from typing import Optional from shared.database.database import get_db_session from shared.services.auth_service import get_current_active_user -from shared.models.database import User +from shared.models.identity import User from ..schemas import ( PurchaseOrderCreate, PurchaseOrderResponse, diff --git a/src/inventory/api/sales_order_routes.py b/src/inventory/api/sales_order_routes.py index 01712f5..cb62da0 100644 --- a/src/inventory/api/sales_order_routes.py +++ b/src/inventory/api/sales_order_routes.py @@ -9,7 +9,7 @@ from typing import Optional from shared.database.database import get_db_session from shared.services.auth_service import get_current_active_user -from shared.models.database import User +from shared.models.identity import User from ..schemas import ( SalesOrderCreate, SalesOrderResponse, diff --git a/src/inventory/api/stock_movement_routes.py b/src/inventory/api/stock_movement_routes.py index 1f584e9..63b9983 100644 --- a/src/inventory/api/stock_movement_routes.py +++ b/src/inventory/api/stock_movement_routes.py @@ -9,7 +9,7 @@ from typing import Optional from shared.database.database import get_db_session from shared.services.auth_service import get_current_active_user -from shared.models.database import User +from shared.models.identity import User from ..schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse from ..services.stock_movement_service import stock_movement_service diff --git a/src/inventory/api/supplier_routes.py b/src/inventory/api/supplier_routes.py index c6a5848..1529c31 100644 --- a/src/inventory/api/supplier_routes.py +++ b/src/inventory/api/supplier_routes.py @@ -17,7 +17,8 @@ from datetime import datetime from shared.database.database import get_db_session from shared.services.auth_service import get_current_active_user, get_current_admin_user -from shared.models.database import User, Supplier +from shared.models.identity import User +from inventory.models import Supplier from ..schemas import SupplierCreate, SupplierResponse router = APIRouter(prefix="/suppliers", tags=["供应商管理"]) diff --git a/src/inventory/api/warehouse_routes.py b/src/inventory/api/warehouse_routes.py index 54abfbd..890f389 100644 --- a/src/inventory/api/warehouse_routes.py +++ b/src/inventory/api/warehouse_routes.py @@ -15,7 +15,8 @@ from datetime import datetime from shared.database.database import get_db_session from shared.services.auth_service import get_current_active_user -from shared.models.database import User, Warehouse +from shared.models.identity import User +from inventory.models import Warehouse from ..schemas import WarehouseCreate, WarehouseResponse router = APIRouter(prefix="/warehouses", tags=["仓库管理"]) diff --git a/src/inventory/models/__init__.py b/src/inventory/models/__init__.py new file mode 100644 index 0000000..4040446 --- /dev/null +++ b/src/inventory/models/__init__.py @@ -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", +] diff --git a/src/inventory/models/catalog.py b/src/inventory/models/catalog.py new file mode 100644 index 0000000..afe1031 --- /dev/null +++ b/src/inventory/models/catalog.py @@ -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"" + + +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"" + + +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"" + + +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"" + + +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"" + + +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"" diff --git a/src/inventory/models/finance.py b/src/inventory/models/finance.py new file mode 100644 index 0000000..0ff779c --- /dev/null +++ b/src/inventory/models/finance.py @@ -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"" + + +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"" diff --git a/src/inventory/models/trading.py b/src/inventory/models/trading.py new file mode 100644 index 0000000..3c884b0 --- /dev/null +++ b/src/inventory/models/trading.py @@ -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"" + + +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"" + + +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"" + + +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"" diff --git a/src/inventory/models/warehouse.py b/src/inventory/models/warehouse.py new file mode 100644 index 0000000..c5d168c --- /dev/null +++ b/src/inventory/models/warehouse.py @@ -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"" + + +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"" + + @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"" diff --git a/src/inventory/services/finance_service.py b/src/inventory/services/finance_service.py index 1046f07..271c2ec 100644 --- a/src/inventory/services/finance_service.py +++ b/src/inventory/services/finance_service.py @@ -11,18 +11,8 @@ from typing import Optional, List, Dict, Tuple from datetime import datetime from decimal import Decimal -from shared.models.database import ( - User, - Customer, - Supplier, - Product, - SalesOrder, - SalesOrderItem, - PurchaseOrder, - PurchaseOrderItem, - FinanceTransaction, - FinanceAllocation, -) +from shared.models.identity import User +from inventory.models import Customer, Supplier, Product, SalesOrder, SalesOrderItem, PurchaseOrder, PurchaseOrderItem, FinanceTransaction, FinanceAllocation from ..schemas import ( ReceiptCreate, PaymentCreate, diff --git a/src/inventory/services/inventory_service.py b/src/inventory/services/inventory_service.py index aa52e9c..40d0f01 100644 --- a/src/inventory/services/inventory_service.py +++ b/src/inventory/services/inventory_service.py @@ -10,7 +10,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from sqlalchemy.exc import IntegrityError -from shared.models.database import User, Product, Warehouse, Inventory +from shared.models.identity import User +from inventory.models import Product, Warehouse, Inventory from ..schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse diff --git a/src/inventory/services/purchase_demand_service.py b/src/inventory/services/purchase_demand_service.py index 2911c5b..71fad3b 100644 --- a/src/inventory/services/purchase_demand_service.py +++ b/src/inventory/services/purchase_demand_service.py @@ -10,18 +10,8 @@ from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func -from shared.models.database import ( - User, - Product, - ProductMaterial, - SalesOrder, - SalesOrderItem, - Inventory, - MaterialSupplier, - Supplier, - PurchaseOrder, - PurchaseOrderItem, -) +from shared.models.identity import User +from inventory.models import Product, ProductMaterial, SalesOrder, SalesOrderItem, Inventory, MaterialSupplier, Supplier, PurchaseOrder, PurchaseOrderItem from ..utils import generate_order_no from ..schemas.purchase_demand_schemas import ( PurchaseDemandItemResponse, diff --git a/src/inventory/services/purchase_order_service.py b/src/inventory/services/purchase_order_service.py index abf6954..d790963 100644 --- a/src/inventory/services/purchase_order_service.py +++ b/src/inventory/services/purchase_order_service.py @@ -10,16 +10,8 @@ from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, update -from shared.models.database import ( - User, - Supplier, - Product, - Warehouse, - Inventory, - StockMovement, - PurchaseOrder, - PurchaseOrderItem, -) +from shared.models.identity import User +from inventory.models import Supplier, Product, Warehouse, Inventory, StockMovement, PurchaseOrder, PurchaseOrderItem from ..schemas import ( PurchaseOrderCreate, PurchaseOrderResponse, diff --git a/src/inventory/services/sales_order_service.py b/src/inventory/services/sales_order_service.py index 26d35d2..f982f68 100644 --- a/src/inventory/services/sales_order_service.py +++ b/src/inventory/services/sales_order_service.py @@ -12,17 +12,8 @@ from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, delete, update -from shared.models.database import ( - User, - Customer, - Product, - ProductMaterial, - Warehouse, - Inventory, - StockMovement, - SalesOrder, - SalesOrderItem, -) +from shared.models.identity import User +from inventory.models import Customer, Product, ProductMaterial, Warehouse, Inventory, StockMovement, SalesOrder, SalesOrderItem from ..schemas import ( SalesOrderCreate, SalesOrderResponse, diff --git a/src/inventory/services/stock_movement_service.py b/src/inventory/services/stock_movement_service.py index 5c2d25a..af0ca2b 100644 --- a/src/inventory/services/stock_movement_service.py +++ b/src/inventory/services/stock_movement_service.py @@ -9,7 +9,8 @@ from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, update, func -from shared.models.database import User, Product, Warehouse, Inventory, StockMovement +from shared.models.identity import User +from inventory.models import Product, Warehouse, Inventory, StockMovement from ..schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse from ..utils import generate_order_no diff --git a/src/moldinsight/api/__init__.py b/src/moldinsight/api/__init__.py index 101e838..52be6d8 100644 --- a/src/moldinsight/api/__init__.py +++ b/src/moldinsight/api/__init__.py @@ -3,32 +3,54 @@ import importlib from shared.config.settings import settings from shared.utils.logger import get_logger +from moldinsight.api.route_registry import route_load_status logger = get_logger(__name__) router = APIRouter() -def _safe_include(module_path: str, label: str): +# 业务路由装载清单:新增路由必须登记于此。 +# 失败语义(原 _safe_include 仅 WARNING 跳过,进程带病启动不可感知): +# - 非 DEBUG:记录进 route_load_status["failed"],/api/health 呈现 degraded +# - DEBUG:直接抛错 fail fast——开发环境路由缺失必须当场暴露 +ROUTE_MODULES = [ + # (label, module_path, debug_only) + ("健康检查", "moldinsight.api.health_router", False), + ("上传", "moldinsight.api.upload_router", False), + ("批量", "moldinsight.api.batch_router", False), + ("任务", "moldinsight.api.task_router", False), + ("历史", "moldinsight.api.history_router", False), + ("CAM", "moldinsight.api.cam_router", False), + ("设计", "moldinsight.api.design_router", False), + ("成本", "moldinsight.api.cost_router", False), + ("加工", "moldinsight.api.machining_router", False), + ("导出", "moldinsight.api.export_router", False), + ("铝价", "moldinsight.api.aluminum_price_routes", False), + # 调试端点会 dump 全量任务数据,仅 DEBUG 模式注册(双重防线:还需登录) + ("调试", "moldinsight.api.debug_router", True), +] + + +def _safe_include(label: str, module_path: str, debug_only: bool = False): + if debug_only and not settings.DEBUG: + route_load_status["disabled"].append({"label": label, "module": module_path}) + return try: module = importlib.import_module(module_path) router_obj = getattr(module, "router", None) if router_obj is None: raise ValueError("未找到 router 对象") router.include_router(router_obj) + route_load_status["loaded"].append({"label": label, "module": module_path}) logger.info(f"{label} 路由加载成功") except Exception as exc: - logger.warning(f"{label} 路由加载失败,已跳过: {exc}") + route_load_status["failed"].append( + {"label": label, "module": module_path, "error": str(exc)} + ) + logger.error(f"{label} 路由加载失败: {exc}") + if settings.DEBUG: + raise -_safe_include("moldinsight.api.health_router", "健康检查") -_safe_include("moldinsight.api.upload_router", "上传") -_safe_include("moldinsight.api.batch_router", "批量") -_safe_include("moldinsight.api.task_router", "任务") -_safe_include("moldinsight.api.history_router", "历史") -_safe_include("moldinsight.api.cam_router", "CAM") -_safe_include("moldinsight.api.advanced_router", "高级") -_safe_include("moldinsight.api.aluminum_price_routes", "铝价") - -# 调试端点会 dump 全量任务数据,仅 DEBUG 模式注册(双重防线:还需登录) -if settings.DEBUG: - _safe_include("moldinsight.api.debug_router", "调试") +for _label, _module_path, _debug_only in ROUTE_MODULES: + _safe_include(_label, _module_path, _debug_only) diff --git a/src/moldinsight/api/advanced_router.py b/src/moldinsight/api/advanced_router.py deleted file mode 100644 index dfc767d..0000000 --- a/src/moldinsight/api/advanced_router.py +++ /dev/null @@ -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} - - diff --git a/src/moldinsight/api/batch_router.py b/src/moldinsight/api/batch_router.py index f569e85..85041d5 100644 --- a/src/moldinsight/api/batch_router.py +++ b/src/moldinsight/api/batch_router.py @@ -18,19 +18,22 @@ from sqlalchemy.orm import joinedload from shared.database.database import get_db_session from shared.services.auth_service import get_current_active_user -from shared.models.database import User, ProcessingTask, STPFile +from shared.models.identity import User +from moldinsight.models import ProcessingTask, STPFile from shared.models.schemas import ProcessingStatus, create_task_info from shared.services.redis_task_manager import redis_task_manager from shared.utils.file_handler import FileHandler from shared.utils.logger import get_logger -from moldinsight.services.storage_integration_rustfs import StorageIntegrationService +from shared.config.settings import settings +from moldinsight.services.task_storage_service import TaskStorageService from moldinsight.services.task_dispatcher import dispatch_processing logger = get_logger(__name__) router = APIRouter() -file_handler = FileHandler() +# D14:上传限制接 settings(MAX_FILE_SIZE 此前为死配置,文件处理器硬编码 50MB) +file_handler = FileHandler(upload_dir=settings.UPLOAD_DIR, max_file_size=settings.MAX_FILE_SIZE) @router.post("/batch-upload") @@ -60,7 +63,7 @@ async def batch_upload( batch_id = str(uuid.uuid4()) tasks: List[Dict[str, Any]] = [] - storage_service = StorageIntegrationService() + storage_service = TaskStorageService() for file in files: # 文件类型检查 diff --git a/src/moldinsight/api/cam_router.py b/src/moldinsight/api/cam_router.py index 8a188b1..0fc0b43 100644 --- a/src/moldinsight/api/cam_router.py +++ b/src/moldinsight/api/cam_router.py @@ -1,10 +1,15 @@ -from fastapi import APIRouter, Depends, HTTPException, Request +import asyncio +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from datetime import datetime from shared.database.database import get_db_session -from shared.models.database import User, ProcessingTask +from shared.models.identity import User +from moldinsight.models import ProcessingTask from shared.services.auth_service import get_current_active_user from moldinsight.services.cam_bundle_service import cam_bundle_service from moldinsight.services.task_query_service import TaskQueryService @@ -21,20 +26,25 @@ DEFAULT_CAM_PREFERENCES = { } +class CamPlanRequest(BaseModel): + """未提供的偏好字段回落到任务持久化偏好,再回落到默认值。""" + task_id: str + scheme_id: Optional[str] = None + mold_steel: Optional[str] = None + surface_quality: Optional[str] = None + controller: Optional[str] = None + include_gcode: Optional[bool] = None + + @router.post("/cam/plan") async def generate_cam_plan( - request: Request, + body: CamPlanRequest, db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_current_active_user), ): """基于任务分模结果生成 CAM 准备包(MVP)。""" _ = current_user - body = await request.json() - task_id = body.get("task_id") - scheme_id = body.get("scheme_id") - - if not task_id: - raise HTTPException(status_code=400, detail="缺少 task_id") + task_id = body.task_id task_result = await db_session.execute( select(ProcessingTask).where(ProcessingTask.task_id == task_id) @@ -47,23 +57,26 @@ async def generate_cam_plan( processing_task.parameters.get("cam_preferences", {}) or {} ) - mold_steel = body.get( - "mold_steel", - persisted_preferences.get("mold_steel", DEFAULT_CAM_PREFERENCES["mold_steel"]), + # 注意 include_gcode 显式判 None:False 是有效值,不能走 or 回落 + mold_steel = ( + body.mold_steel + if body.mold_steel is not None + else persisted_preferences.get("mold_steel", DEFAULT_CAM_PREFERENCES["mold_steel"]) ) - surface_quality = body.get( - "surface_quality", - persisted_preferences.get("surface_quality", DEFAULT_CAM_PREFERENCES["surface_quality"]), + surface_quality = ( + body.surface_quality + if body.surface_quality is not None + else persisted_preferences.get("surface_quality", DEFAULT_CAM_PREFERENCES["surface_quality"]) ) - controller = body.get( - "controller", - persisted_preferences.get("controller", DEFAULT_CAM_PREFERENCES["controller"]), + controller = ( + body.controller + if body.controller is not None + else persisted_preferences.get("controller", DEFAULT_CAM_PREFERENCES["controller"]) ) - include_gcode = bool( - body.get( - "include_gcode", - persisted_preferences.get("include_gcode", DEFAULT_CAM_PREFERENCES["include_gcode"]), - ) + include_gcode = ( + body.include_gcode + if body.include_gcode is not None + else persisted_preferences.get("include_gcode", DEFAULT_CAM_PREFERENCES["include_gcode"]) ) task_view = await TaskQueryService.get_task_view(db_session, task_id) @@ -73,9 +86,11 @@ async def generate_cam_plan( raise HTTPException(status_code=400, detail="任务尚未完成,无法生成CAM计划") try: - data = cam_bundle_service.build_bundle( + # CAM 刀路计算为纯 Python 重计算,投放线程池避免阻塞事件循环 + data = await asyncio.to_thread( + cam_bundle_service.build_bundle, task_view=task_view, - scheme_id=scheme_id, + scheme_id=body.scheme_id, mold_steel=mold_steel, surface_quality=surface_quality, controller=controller, diff --git a/src/moldinsight/api/core_modules.py b/src/moldinsight/api/core_modules.py new file mode 100644 index 0000000..8925b84 --- /dev/null +++ b/src/moldinsight/api/core_modules.py @@ -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 diff --git a/src/moldinsight/api/cost_router.py b/src/moldinsight/api/cost_router.py new file mode 100644 index 0000000..e7650bf --- /dev/null +++ b/src/moldinsight/api/cost_router.py @@ -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} diff --git a/src/moldinsight/api/debug_router.py b/src/moldinsight/api/debug_router.py index ed1080e..e4db71a 100644 --- a/src/moldinsight/api/debug_router.py +++ b/src/moldinsight/api/debug_router.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends from shared.services.auth_service import get_current_active_user from shared.services.redis_task_manager import redis_task_manager -from shared.models.database import User +from shared.models.identity import User router = APIRouter() diff --git a/src/moldinsight/api/design_router.py b/src/moldinsight/api/design_router.py new file mode 100644 index 0000000..d5f9f4a --- /dev/null +++ b/src/moldinsight/api/design_router.py @@ -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} diff --git a/src/moldinsight/api/export_router.py b/src/moldinsight/api/export_router.py new file mode 100644 index 0000000..d6d495a --- /dev/null +++ b/src/moldinsight/api/export_router.py @@ -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} diff --git a/src/moldinsight/api/health_router.py b/src/moldinsight/api/health_router.py index 6db5bf6..8ca0fed 100644 --- a/src/moldinsight/api/health_router.py +++ b/src/moldinsight/api/health_router.py @@ -1,7 +1,11 @@ # api/v1/health_router.py +import asyncio + from fastapi import APIRouter from shared.services.redis_task_manager import redis_task_manager +from moldinsight.api.route_registry import route_load_status +from moldinsight.core.occ_availability import is_pythonocc_available router = APIRouter() @@ -9,10 +13,19 @@ router = APIRouter() @router.get("/health") @router.post("/health") async def health(): - task_count = await redis_task_manager.get_task_count() + # 首次调用会触发 PythonOCC 导入(可能耗时数秒),投放线程池避免阻塞事件循环 + pythonocc_available = await asyncio.to_thread(is_pythonocc_available) + failed_routes = route_load_status["failed"] return { - "status": "healthy", - "pythonocc": True, - "total_tasks": task_count, - "redis_connected": redis_task_manager.is_connected + # 有业务路由装载失败即 degraded:进程活着但功能残缺,监控必须可感知 + "status": "degraded" if failed_routes else "healthy", + # 真实探测 PythonOCC(此前硬编码 True,与上传预检的诚实化同源) + "pythonocc": pythonocc_available, + "total_tasks": await redis_task_manager.get_task_count(), + "redis_connected": redis_task_manager.is_connected, + "routes": { + "loaded": [m["label"] for m in route_load_status["loaded"]], + "failed": failed_routes, + "disabled": [m["label"] for m in route_load_status["disabled"]], + }, } diff --git a/src/moldinsight/api/history_router.py b/src/moldinsight/api/history_router.py index 3ed3c8f..dd29d35 100644 --- a/src/moldinsight/api/history_router.py +++ b/src/moldinsight/api/history_router.py @@ -2,10 +2,10 @@ from fastapi import APIRouter, Depends import urllib.parse -from moldinsight.services.storage_integration_rustfs import StorageIntegrationService +from moldinsight.services.file_history_service import FileHistoryService from shared.database.database import get_db_session from shared.services.auth_service import get_current_active_user -from shared.models.database import User +from shared.models.identity import User from shared.utils.logger import get_logger from sqlalchemy.ext.asyncio import AsyncSession @@ -21,7 +21,7 @@ async def get_file_history( current_user: User = Depends(get_current_active_user), ): """获取当前用户按文件名分组的文件历史记录(支持多上传)""" - storage_service = StorageIntegrationService() + storage_service = FileHistoryService() file_groups = await storage_service.get_all_file_groups( db_session, user_id=current_user.id ) @@ -42,7 +42,7 @@ async def get_file_records( """获取当前用户指定文件名的所有上传记录(支持多上传历史)""" decoded_filename = urllib.parse.unquote(filename) - storage_service = StorageIntegrationService() + storage_service = FileHistoryService() file_records = await storage_service.get_file_history_by_filename( db_session, decoded_filename, diff --git a/src/moldinsight/api/machining_router.py b/src/moldinsight/api/machining_router.py new file mode 100644 index 0000000..ffc8cc4 --- /dev/null +++ b/src/moldinsight/api/machining_router.py @@ -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} diff --git a/src/moldinsight/api/route_registry.py b/src/moldinsight/api/route_registry.py new file mode 100644 index 0000000..308aeb3 --- /dev/null +++ b/src/moldinsight/api/route_registry.py @@ -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": [], +} diff --git a/src/moldinsight/api/task_router.py b/src/moldinsight/api/task_router.py index f3e0d35..3643f0e 100644 --- a/src/moldinsight/api/task_router.py +++ b/src/moldinsight/api/task_router.py @@ -7,7 +7,7 @@ from moldinsight.services.task_query_service import TaskQueryService from shared.database.database import get_db_session from shared.services.auth_service import get_current_active_user from shared.utils.logger import get_logger -from shared.models.database import User +from shared.models.identity import User logger = get_logger(__name__) diff --git a/src/moldinsight/api/upload_router.py b/src/moldinsight/api/upload_router.py index f7c0fe4..5b3571c 100644 --- a/src/moldinsight/api/upload_router.py +++ b/src/moldinsight/api/upload_router.py @@ -4,34 +4,24 @@ import uuid from datetime import datetime from shared.models.schemas import ProcessingStatus, create_task_info +from shared.config.settings import settings from shared.utils.file_handler import FileHandler -from moldinsight.services.storage_integration_rustfs import StorageIntegrationService +from moldinsight.services.task_storage_service import TaskStorageService from moldinsight.services.task_dispatcher import dispatch_processing from shared.services.redis_task_manager import redis_task_manager from shared.database.database import get_db_session from shared.utils.logger import get_logger from sqlalchemy.ext.asyncio import AsyncSession from shared.services.auth_service import get_current_active_user -from shared.models.database import User +from shared.models.identity import User +from moldinsight.core.occ_availability import is_pythonocc_available logger = get_logger(__name__) router = APIRouter() -file_handler = FileHandler() - - -def _occ_available() -> bool: - """真实检测 PythonOCC 可用性(惰性导入,缺失时不影响本路由加载)。 - - 此前该字段硬编码 True,响应不诚实;几何处理依赖 OCC, - 不可用时任务会在处理阶段以明确错误失败。 - """ - try: - import OCC.Core.STEPControl # noqa: F401 - return True - except Exception: - return False +# D14:上传限制接 settings(MAX_FILE_SIZE 此前为死配置,文件处理器硬编码 50MB) +file_handler = FileHandler(upload_dir=settings.UPLOAD_DIR, max_file_size=settings.MAX_FILE_SIZE) @router.post("/upload") @@ -72,7 +62,7 @@ async def upload_stp( raise HTTPException(400, str(exc)) from exc logger.info(f"[UPLOAD] 文件已保存: {file_path} ({file_size} bytes), task_id={task_id}") - storage_service = StorageIntegrationService() + storage_service = TaskStorageService() stp_file = await storage_service.save_stp_file( session=db_session, @@ -114,7 +104,7 @@ async def upload_stp( "file_info": { "filename": file.filename, "size": file_size, - "pythonocc_available": _occ_available(), + "pythonocc_available": is_pythonocc_available(), "database_file_id": stp_file.id, "sha256": file_meta["sha256"], }, diff --git a/src/moldinsight/core/occ_availability.py b/src/moldinsight/core/occ_availability.py new file mode 100644 index 0000000..e096deb --- /dev/null +++ b/src/moldinsight/core/occ_availability.py @@ -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 diff --git a/src/moldinsight/models/__init__.py b/src/moldinsight/models/__init__.py new file mode 100644 index 0000000..d061a88 --- /dev/null +++ b/src/moldinsight/models/__init__.py @@ -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", +] diff --git a/src/moldinsight/models/stp_analysis.py b/src/moldinsight/models/stp_analysis.py new file mode 100644 index 0000000..7fbf6ed --- /dev/null +++ b/src/moldinsight/models/stp_analysis.py @@ -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"" + +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"" + + +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"" + +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"" + +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"" + +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"" + + +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"" + + +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"" + + +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"" diff --git a/src/moldinsight/services/storage_integration_rustfs.py b/src/moldinsight/services/analysis_storage_service.py similarity index 60% rename from src/moldinsight/services/storage_integration_rustfs.py rename to src/moldinsight/services/analysis_storage_service.py index ba7a51c..96758d4 100644 --- a/src/moldinsight/services/storage_integration_rustfs.py +++ b/src/moldinsight/services/analysis_storage_service.py @@ -1,27 +1,25 @@ -# services/storage_integration_rustfs.py -"""存储集成服务 - 协调 PostgreSQL 和 RustFS""" -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 +# services/analysis_storage_service.py +"""分析结果数据存储——几何/网格/型腔/HTML/特征的 RustFS 上传与 PG 元数据, +以及任务完整数据视图的组装。 -from shared.models.database import ( - STPFile, GeometryData, MeshData, MoldCavityData, - HTMLFile, ProcessingTask, User, - FeatureDetection, DesignRecommendation, - UserActivity, SystemLog -) +批次 3 自 storage_integration_rustfs.py 按职责拆分(见 task_storage_service.py 头注)。 +""" +import json +from typing import Optional, Dict, Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload + +from moldinsight.models import STPFile, GeometryData, MeshData, MoldCavityData, HTMLFile, FeatureDetection, DesignRecommendation from moldinsight.storage.rustfs_storage import rustfs_manager from shared.utils.logger import get_logger logger = get_logger(__name__) -class StorageIntegrationService: - """存储集成服务 - PostgreSQL + RustFS""" +class AnalysisStorageService: + """分析结果数据(几何/网格/型腔/HTML/特征)存储与视图组装""" @staticmethod def _resolve_best_scheme_payload(cavity_json: Dict[str, Any]) -> Dict[str, Any]: @@ -80,169 +78,6 @@ class StorageIntegrationService: except (TypeError, ValueError): return None - async def save_stp_file(self, session: AsyncSession, - file_path: Path, - original_filename: str, - user_id: Optional[int] = None, - upload_batch: Optional[str] = None) -> STPFile: - """保存STP文件到PostgreSQL元数据 + RustFS对象存储 - - 支持同一文件多次上传,每次上传都会创建新记录 - """ - - # 1. 上传到RustFS - upload_result = await rustfs_manager.upload_file( - file_type='stp_files', - file_path=file_path, - original_filename=original_filename, - metadata={ - 'original_filename': original_filename, - 'user_id': str(user_id) if user_id else 'anonymous', - 'upload_batch': upload_batch or str(uuid.uuid4()) - } - ) - - file_hash = upload_result['file_hash'] - batch_id = upload_batch or str(uuid.uuid4()) - - # 2. 创建新PostgreSQL记录(每次上传都创建新记录) - from datetime import datetime - stp_file = STPFile( - user_id=user_id, - object_key=upload_result['object_key'], - storage_bucket=upload_result['bucket'], - original_filename=original_filename, - file_size=upload_result['file_size'], - file_hash=file_hash, - upload_batch=batch_id, - status="uploaded", - file_path=str(file_path), - upload_time=datetime.now() - ) - - session.add(stp_file) - # D9:仅 flush,与 ProcessingTask 由路由层一并原子提交(避免孤儿文件记录) - await session.flush() - await session.refresh(stp_file) - - logger.info(f"STP文件保存成功 RustFS: {stp_file.id}, 批次: {batch_id}") - return stp_file - - async def create_processing_task( - self, - session: AsyncSession, - task_id: str, - stp_file_id: int, - task_type: str = "stp_parsing", - parameters: Optional[Dict[str, Any]] = None, - batch_id: Optional[str] = None, - ) -> ProcessingTask: - """创建处理任务记录(D9:仅 flush 不 commit,事务由调用方收口—— - 与 STPFile 记录同批提交,避免留下无任务的孤儿文件记录;batch_id 用于批量任务聚合查询) - """ - try: - task = ProcessingTask( - task_id=task_id, - stp_file_id=stp_file_id, - task_type=task_type, - status="pending", - started_time=datetime.now(), - parameters=parameters or {}, - batch_id=batch_id, - ) - - session.add(task) - await session.flush() - - logger.info(f"处理任务创建成功: {task_id}") - return task - - except Exception as e: - await session.rollback() - logger.error(f"创建处理任务失败: {e}") - raise - - async def update_task_status( - self, - session: AsyncSession, - task_id: str, - status: str, - progress: Optional[int] = None, - current_step: Optional[str] = None, - error_message: Optional[str] = None - ): - """更新任务状态(保留即时 commit:进度/状态需跨事务对外可见, - 处理链路中的各阶段进度依赖它落库——D9 收口仅针对数据本体写方法)""" - try: - update_data = { - "status": status, - "completed_time": datetime.now() if status in ["completed", "failed"] else None, - "error_message": error_message - } - - if progress is not None: - update_data["progress"] = progress - if current_step is not None: - update_data["current_step"] = current_step - - await session.execute( - update(ProcessingTask) - .where(ProcessingTask.task_id == task_id) - .values(**update_data) - ) - await session.commit() - - logger.info(f"任务状态更新: {task_id} -> {status}") - - except Exception as e: - await session.rollback() - logger.error(f"更新任务状态失败: {e}") - raise - - async def update_task_parameters( - self, - session: AsyncSession, - task_id: str, - parameters: Dict[str, Any], - ): - """合并更新任务参数,便于保存阶段耗时等元数据。(D9:flush 不 commit,事务由调用方收口)""" - try: - task = await session.execute( - select(ProcessingTask).where(ProcessingTask.task_id == task_id) - ) - task = task.scalar_one_or_none() - if task is None: - return - - merged = dict(task.parameters or {}) - merged.update(parameters or {}) - task.parameters = merged - await session.flush() - except Exception as e: - await session.rollback() - logger.error(f"更新任务参数失败: {e}") - raise - - async def update_stp_file_status(self, session: AsyncSession, stp_file_id: int, status: str): - """更新STP文件状态(保留即时 commit,理由同 update_task_status)""" - try: - await session.execute( - update(STPFile) - .where(STPFile.id == stp_file_id) - .values( - status=status, - processed_time=datetime.now() if status in ["completed", "failed"] else None - ) - ) - await session.commit() - - logger.info(f"STP文件状态更新: ID {stp_file_id} -> {status}") - - except Exception as e: - await session.rollback() - logger.error(f"更新STP文件状态失败: {e}") - raise - async def save_geometry_data(self, session: AsyncSession, stp_file_id: int, geometry_json: Dict[str, Any], @@ -517,37 +352,9 @@ class StorageIntegrationService: await session.flush() logger.info(f"保存了 {len(features)} 个特征和 {len(recommendations)} 个建议") - async def log_user_activity(self, session: AsyncSession, - user_id: int, - activity_type: str, - resource_type: Optional[str] = None, - resource_id: Optional[int] = None, - description: Optional[str] = None, - metadata: Optional[Dict] = None, - ip_address: Optional[str] = None, - user_agent: Optional[str] = None): - """记录用户活动""" - - activity = UserActivity( - user_id=user_id, - activity_type=activity_type, - resource_type=resource_type, - resource_id=resource_id, - description=description, - meta_data=metadata, - ip_address=ip_address, - user_agent=user_agent - ) - - session.add(activity) - await session.commit() - logger.debug(f"用户活动记录: {activity_type} by user {user_id}") - async def get_stp_file_with_data(self, session: AsyncSession, stp_file_id: int) -> Dict[str, Any]: """获取STP文件及其所有关联数据""" - from sqlalchemy.orm import joinedload - try: # 1. 获取STP文件记录(使用 joinedload 预加载关联数据) result = await session.execute( @@ -666,157 +473,6 @@ class StorageIntegrationService: return result - async def get_file_history_by_filename( - self, - session: AsyncSession, - filename: str, - user_id: Optional[int] = None, - limit: int = 50 - ) -> list: - """获取同一文件名的所有上传历史记录""" - from shared.models.database import ProcessingTask - from sqlalchemy.orm import joinedload - - query = select(STPFile).options( - joinedload(STPFile.processing_tasks) - ).where( - STPFile.original_filename == filename - ).order_by(STPFile.upload_time.desc()) - - if user_id: - query = query.where(STPFile.user_id == user_id) - - query = query.limit(limit) - - result = await session.execute(query) - files = result.unique().scalars().all() - - return [ - { - 'id': f.id, - 'task_id': f.processing_tasks[0].task_id if f.processing_tasks else None, - 'upload_batch': f.upload_batch, - 'upload_time': f.upload_time.strftime('%Y-%m-%d %H:%M:%S') if f.upload_time else None, - 'file_size': f.file_size, - 'status': f.status, - 'volume': f.volume, - 'surface_area': f.surface_area, - 'product_weight': f.product_weight, - 'has_analysis': f.status == 'completed' - } - for f in files - ] - - async def get_all_file_groups( - self, - session: AsyncSession, - user_id: Optional[int] = None, - limit: int = 100 - ) -> list: - """获取所有文件分组(按文件名分组),包含每个文件的最新分析结果""" - - from sqlalchemy import func, desc - from sqlalchemy.orm import joinedload - from shared.models.database import ProcessingTask - - # 子查询:获取每个文件名的最新上传 - subquery = ( - select( - STPFile.original_filename, - func.max(STPFile.upload_time).label('latest_upload') - ) - .group_by(STPFile.original_filename) - .order_by(desc('latest_upload')) - .limit(limit) - ) - - if user_id: - subquery = subquery.where(STPFile.user_id == user_id) - - subquery = subquery.subquery() - - # 主查询:获取最新记录和统计信息 - query = ( - select(STPFile).options( - joinedload(STPFile.processing_tasks) - ) - .join( - subquery, - (STPFile.original_filename == subquery.c.original_filename) & - (STPFile.upload_time == subquery.c.latest_upload) - ) - .order_by(STPFile.upload_time.desc()) - ) - - result = await session.execute(query) - latest_files = result.unique().scalars().all() - - # 一次性聚合每个文件名的上传次数(替代逐文件 count 的 N+1 查询) - count_subquery = ( - select(STPFile.original_filename, func.count().label("upload_count")) - .group_by(STPFile.original_filename) - ) - if user_id: - count_subquery = count_subquery.where(STPFile.user_id == user_id) - count_result = await session.execute(count_subquery) - upload_counts = { - row.original_filename: row.upload_count for row in count_result - } - - # 获取每个文件名的上传次数 - file_groups = [] - for f in latest_files: - task_id = f.processing_tasks[0].task_id if f.processing_tasks else None - upload_count = upload_counts.get(f.original_filename, 1) - - file_groups.append({ - 'filename': f.original_filename, - 'latest_id': f.id, - 'latest_task_id': task_id, - 'latest_upload_time': f.upload_time.strftime('%Y-%m-%d %H:%M:%S') if f.upload_time else None, - 'latest_status': f.status, - 'upload_count': upload_count, - 'file_size': f.file_size, - 'volume': f.volume, - 'surface_area': f.surface_area, - 'product_weight': f.product_weight - }) - - return file_groups - - async def update_stp_file_analysis_summary( - self, - session: AsyncSession, - stp_file_id: int, - volume: Optional[float] = None, - surface_area: Optional[float] = None, - product_weight: Optional[float] = None - ): - """更新STP文件的分析摘要字段(用于快速查询)""" - try: - update_data = {} - if volume is not None: - update_data['volume'] = volume - if surface_area is not None: - update_data['surface_area'] = surface_area - if product_weight is not None: - update_data['product_weight'] = product_weight - - if update_data: - await session.execute( - update(STPFile) - .where(STPFile.id == stp_file_id) - .values(**update_data) - ) - # D9:flush 不 commit,随结果包由编排层统一提交 - await session.flush() - logger.info(f"STP文件分析摘要更新: ID {stp_file_id}") - - except Exception as e: - await session.rollback() - logger.error(f"更新STP文件分析摘要失败: {e}") - raise - async def delete_stp_file_cascade(self, session: AsyncSession, stp_file_id: int): """级联删除STP文件及其所有关联数据""" @@ -861,7 +517,3 @@ class StorageIntegrationService: await session.commit() logger.info(f"STP文件及其关联数据已删除: {stp_file_id}") - - -# 全局存储集成服务实例 -storage_integration = StorageIntegrationService() diff --git a/src/moldinsight/services/file_history_service.py b/src/moldinsight/services/file_history_service.py new file mode 100644 index 0000000..1fe1acb --- /dev/null +++ b/src/moldinsight/services/file_history_service.py @@ -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 diff --git a/src/moldinsight/services/processing_service.py b/src/moldinsight/services/processing_service.py index e0c4054..903d25b 100644 --- a/src/moldinsight/services/processing_service.py +++ b/src/moldinsight/services/processing_service.py @@ -20,14 +20,15 @@ from moldinsight.core.geometry_analyzer import GeometryAnalyzer from moldinsight.core.mesh_generator import MeshGenerator from moldinsight.core.multi_scheme_planner import MultiSchemeMoldPlanner from moldinsight.core.cad_exporter import CADExporter -from moldinsight.services.storage_integration_rustfs import StorageIntegrationService +from moldinsight.services.task_storage_service import TaskStorageService +from moldinsight.services.analysis_storage_service import AnalysisStorageService from moldinsight.storage.rustfs_storage import rustfs_manager from shared.services.redis_task_manager import redis_task_manager from moldinsight.services.material_service import MaterialService from moldinsight.services.calculation_service import CalculationService from moldinsight.services.llm_service import llm_service from shared.models.schemas import ProcessingStatus -from shared.models.database import STPFile +from moldinsight.models import STPFile from shared.database.database import db_manager from shared.utils.html_generator import HTMLGenerator from shared.utils.logger import get_logger @@ -43,7 +44,9 @@ class ProcessingService: self.geometry_analyzer = GeometryAnalyzer() self.mesh_generator = MeshGenerator(quality="medium") self.html_generator = HTMLGenerator() - self.storage_service = StorageIntegrationService() + # 批次 3 按职责拆分:任务/文件生命周期 与 分析结果数据(原 StorageIntegrationService) + self.task_storage = TaskStorageService() + self.analysis_storage = AnalysisStorageService() self.multi_scheme_planner = MultiSchemeMoldPlanner() self.cad_exporter = CADExporter() # TopoDS_Shape 为 C++ 原生内存对象,LRU 上限防止长期运行内存只涨不降 @@ -59,12 +62,15 @@ class ProcessingService: asyncio.wait_for 只能取消协程,正在执行 OCC 布尔运算的线程无法中断; 单 worker executor 中一个挂死线程会让后续任务永久排队直至重启。 - 代价是泄漏 1 个线程,收益是恢复服务可用性。 + cancel_futures=True 丢弃旧 executor 中尚未开跑的排队任务(否则旧线程 + 恢复后仍会继续消化旧队列,与新 executor 并发操作 OCC 必然崩溃)。 + 已在运行中的 C++ 线程在 Python 层不可杀,仍会滞留——这是已知残留 + 泄漏(每次超时 1 线程),根治需进程级 OCC 隔离,见 docs/OCC_THROUGHPUT.md。 """ old = self._occ_executor self._occ_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="occ") - old.shutdown(wait=False) - logger.warning("OCC executor 已因处理超时重建(放弃等待旧线程,可能泄漏 1 个线程)") + old.shutdown(wait=False, cancel_futures=True) + logger.warning("OCC executor 已因处理超时重建(排队任务已丢弃,运行中线程可能滞留 1 个)") async def run_occ(self, fn, *args): """在 OCC 单线程 executor 中执行同步几何操作。 @@ -169,8 +175,8 @@ class ProcessingService: # 避免 failed 更新把半成品 flush 数据一起带上 await db_session.rollback() - await self.storage_service.update_stp_file_status(db_session, stp_file_id, "failed") - await self.storage_service.update_task_status( + await self.task_storage.update_stp_file_status(db_session, stp_file_id, "failed") + await self.task_storage.update_task_status( db_session, task_id, "failed", error_message=str(e) ) @@ -203,7 +209,7 @@ class ProcessingService: stage_timings: Dict[str, float] = {} # 1. 解析STP文件 - await self.storage_service.update_task_status( + await self.task_storage.update_task_status( db_session, task_id, "processing", 20, "解析STP文件" ) @@ -218,7 +224,7 @@ class ProcessingService: stage_timings["parse_stp"] = round(time.perf_counter() - stage_started, 3) # 2. 生成网格数据并持久化 - await self.storage_service.update_task_status( + await self.task_storage.update_task_status( db_session, task_id, "processing", 30, "生成网格数据" ) @@ -229,7 +235,7 @@ class ProcessingService: stage_timings["generate_mesh"] = round(time.perf_counter() - stage_started, 3) # 3. 生成模具型腔 - await self.storage_service.update_task_status( + await self.task_storage.update_task_status( db_session, task_id, "processing", 40, "生成模具型腔" ) @@ -257,7 +263,7 @@ class ProcessingService: ) # 4. 生成详细JSON数据 — 委托 CalculationService - await self.storage_service.update_task_status( + await self.task_storage.update_task_status( db_session, task_id, "processing", 60, "生成型腔详细数据" ) @@ -284,12 +290,12 @@ class ProcessingService: cavity_key_info = best_key_info # 6. 保存几何数据到数据库 - await self.storage_service.update_task_status( + await self.task_storage.update_task_status( db_session, task_id, "processing", 70, "保存几何数据" ) stage_started = time.perf_counter() - await self.storage_service.save_geometry_data( + await self.analysis_storage.save_geometry_data( db_session, stp_file_id, geometry_data, @@ -301,7 +307,7 @@ class ProcessingService: await db_session.commit() # 7. 生成HTML可视化 - await self.storage_service.update_task_status( + await self.task_storage.update_task_status( db_session, task_id, "processing", 85, "生成可视化报告" ) @@ -337,7 +343,7 @@ class ProcessingService: best_key_info = best_scheme.get("key_info", {}) if best_scheme else best_key_info # 8. 保存模具型腔数据(包含方案级预览链接) - await self.storage_service.save_mold_cavity_data( + await self.analysis_storage.save_mold_cavity_data( db_session, stp_file_id, detailed_cavity_json ) @@ -349,7 +355,7 @@ class ProcessingService: lod_data=lod_data, ) - await self.storage_service.save_html_file( + await self.analysis_storage.save_html_file( db_session, stp_file_id, Path(html_file_path).name, @@ -370,7 +376,7 @@ class ProcessingService: ) if analysis_result: - await self.storage_service.save_features_and_recommendations( + await self.analysis_storage.save_features_and_recommendations( db_session, stp_file_id, analysis_result.get("detected_features", []), @@ -381,7 +387,7 @@ class ProcessingService: stage_timings["analyze_design"] = round(time.perf_counter() - stage_started, 3) # 9.6 更新STP文件的分析摘要字段 - await self.storage_service.update_stp_file_analysis_summary( + await self.task_storage.update_stp_file_analysis_summary( db_session, stp_file_id, volume=geometry_data.get("volume", 0), @@ -419,7 +425,7 @@ class ProcessingService: stage_timings["generate_llm_report"] = round(time.perf_counter() - stage_started, 3) # 10. 完成处理——先 flush 任务参数,完成状态提交时一并原子落库(D9) - await self.storage_service.update_task_parameters( + await self.task_storage.update_task_parameters( db_session, task_id, { @@ -431,8 +437,8 @@ class ProcessingService: **process_params, }, ) - await self.storage_service.update_stp_file_status(db_session, stp_file_id, "completed") - await self.storage_service.update_task_status( + await self.task_storage.update_stp_file_status(db_session, stp_file_id, "completed") + await self.task_storage.update_task_status( db_session, task_id, "completed", 100, "模具型腔生成完成" ) @@ -464,8 +470,8 @@ class ProcessingService: # D9:先丢弃未提交的数据本体再置失败(同外层说明) await db_session.rollback() - await self.storage_service.update_stp_file_status(db_session, stp_file_id, "failed") - await self.storage_service.update_task_status( + await self.task_storage.update_stp_file_status(db_session, stp_file_id, "failed") + await self.task_storage.update_task_status( db_session, task_id, "failed", error_message=str(e) ) @@ -524,7 +530,7 @@ class ProcessingService: "bounding_box": bbox, } - await self.storage_service.save_mesh_data( + await self.analysis_storage.save_mesh_data( db_session, stp_file_id=stp_file_id, mesh_json=mesh_json, @@ -732,7 +738,7 @@ class ProcessingService: logger.info("FreeCAD验证已禁用(设置 ENABLE_FREECAD_VERIFICATION=true 启用)") return {"status": "disabled", "reason": "FreeCAD验证已禁用"} - await self.storage_service.update_task_status( + await self.task_storage.update_task_status( db_session, task_id, "processing", 90, "FreeCAD几何验证" ) @@ -787,7 +793,7 @@ class ProcessingService: async def _save_analysis_metrics(self, session: AsyncSession, stp_file_id: int, analysis_result: dict): """保存分析指标到数据库""" - from shared.models.database import AnalysisMetrics + from moldinsight.models import AnalysisMetrics quality_metrics = analysis_result.get("quality_metrics", {}) analysis_summary = analysis_result.get("analysis_summary", "") @@ -807,7 +813,7 @@ class ProcessingService: async def _save_verification_metrics(self, session: AsyncSession, stp_file_id: int, verification_result: dict): """保存验证指标到数据库""" - from shared.models.database import AnalysisMetrics + from moldinsight.models import AnalysisMetrics from sqlalchemy import select result = await session.execute( diff --git a/src/moldinsight/services/shape_loader.py b/src/moldinsight/services/shape_loader.py index 71e63de..da2221e 100644 --- a/src/moldinsight/services/shape_loader.py +++ b/src/moldinsight/services/shape_loader.py @@ -12,7 +12,7 @@ from typing import Optional from sqlalchemy import select 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 logger = get_logger(__name__) diff --git a/src/moldinsight/services/task_query_service.py b/src/moldinsight/services/task_query_service.py index 4edceee..48004cb 100644 --- a/src/moldinsight/services/task_query_service.py +++ b/src/moldinsight/services/task_query_service.py @@ -10,9 +10,9 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload -from moldinsight.services.storage_integration_rustfs import StorageIntegrationService +from moldinsight.services.analysis_storage_service import AnalysisStorageService from shared.services.redis_task_manager import redis_task_manager -from shared.models.database import ProcessingTask, STPFile, MeshData, HTMLFile +from moldinsight.models import ProcessingTask, STPFile, MeshData, HTMLFile from shared.utils.logger import get_logger logger = get_logger(__name__) @@ -103,7 +103,7 @@ class TaskQueryService: return cached # 3. 持久化任务(已完成/失败,或服务重启后的任务) - storage_service = StorageIntegrationService() + storage_service = AnalysisStorageService() # 查询任务和文件元数据(预加载 html_file 关联) result = await db_session.execute( diff --git a/src/moldinsight/services/task_storage_service.py b/src/moldinsight/services/task_storage_service.py new file mode 100644 index 0000000..2fc5f6c --- /dev/null +++ b/src/moldinsight/services/task_storage_service.py @@ -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 diff --git a/src/scripts/create_admin.py b/src/scripts/create_admin.py index 8e1f8ae..b03da38 100644 --- a/src/scripts/create_admin.py +++ b/src/scripts/create_admin.py @@ -9,7 +9,7 @@ sys.path.insert(0, str(src_root)) from sqlalchemy import select from shared.database.database import db_manager -from shared.models.database import User, Role, UserRole +from shared.models.identity import User, Role, UserRole from shared.services.auth_service import get_password_hash from shared.config.settings import settings from shared.utils.logger import get_logger diff --git a/src/shared/config/settings.py b/src/shared/config/settings.py index c8f0f61..9425043 100644 --- a/src/shared/config/settings.py +++ b/src/shared/config/settings.py @@ -101,6 +101,13 @@ class Settings: def allowed_extensions_set(self) -> set: return set(ext.strip() for ext in self.ALLOWED_EXTENSIONS.split(",")) + @property + def redis_url(self) -> str: + """Redis 连接串(Celery broker/backend 使用;RedisTaskManager 走分参数连接,不经此处)""" + if self.REDIS_PASSWORD: + return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" + return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" + @staticmethod def _parse_cors_origins(raw: str) -> List[str]: """解析 CORS_ORIGINS 环境变量,逗号分隔。 diff --git a/src/shared/database/database.py b/src/shared/database/database.py index 187b805..77028a9 100644 --- a/src/shared/database/database.py +++ b/src/shared/database/database.py @@ -115,18 +115,6 @@ class DatabaseManager: await self.connect() 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() diff --git a/src/shared/database/init_db.py b/src/shared/database/init_db.py index d584818..968a059 100644 --- a/src/shared/database/init_db.py +++ b/src/shared/database/init_db.py @@ -3,7 +3,7 @@ import sys from pathlib import Path from sqlalchemy import text, select from shared.database.database import db_manager -from shared.models.database import User, Role, Permission, UserRole, RolePermission +from shared.models.identity import User, Role, Permission, UserRole, RolePermission from shared.services.auth_service import get_password_hash from shared.config.settings import settings from shared.utils.logger import get_logger diff --git a/src/shared/models/base.py b/src/shared/models/base.py new file mode 100644 index 0000000..59af6af --- /dev/null +++ b/src/shared/models/base.py @@ -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() diff --git a/src/shared/models/database.py b/src/shared/models/database.py deleted file mode 100644 index 7ea9e3b..0000000 --- a/src/shared/models/database.py +++ /dev/null @@ -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"" - - -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"" - - -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"" - - -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"" - - -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"" - -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"" - -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"" - - -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"" - -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"" - -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"" - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - @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"" - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - diff --git a/src/shared/models/identity.py b/src/shared/models/identity.py new file mode 100644 index 0000000..bdfae88 --- /dev/null +++ b/src/shared/models/identity.py @@ -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"" + + +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"" + + +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"" + + +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"" + + +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"" + + +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"" + + +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"" diff --git a/src/shared/services/auth_routes.py b/src/shared/services/auth_routes.py index 118eeb8..bf3b691 100644 --- a/src/shared/services/auth_routes.py +++ b/src/shared/services/auth_routes.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.ext.asyncio import AsyncSession -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional, List from datetime import timedelta from sqlalchemy import select @@ -14,7 +14,7 @@ from shared.services.auth_service import ( get_current_active_user, get_password_hash ) -from shared.models.database import User, Role, Permission, UserRole, RolePermission +from shared.models.identity import User, Role, Permission, UserRole, RolePermission from shared.config.settings import settings from shared.utils.logger import get_logger @@ -97,6 +97,13 @@ class UserUpdate(BaseModel): role_ids: Optional[List[int]] = None +class ResetPasswordRequest(BaseModel): + # 新密码走 JSON body(与前端 api-client.ts 的 { new_password } 结构一致)。 + # 此前声明为裸 str 参数被 FastAPI 解析为 query param,前端发 body 必然 422, + # 重置密码功能端到端断裂;最短 6 位对齐 UsersView 前端校验。 + new_password: str = Field(min_length=6) + + def check_admin(user: User) -> bool: if not user.is_superuser: raise HTTPException(status_code=403, detail="需要管理员权限") @@ -307,7 +314,7 @@ async def delete_user( @router.put("/users/{user_id}/reset-password") async def reset_user_password( user_id: int, - new_password: str, + body: ResetPasswordRequest, db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_current_active_user) ): @@ -319,7 +326,7 @@ async def reset_user_password( raise HTTPException(status_code=404, detail="用户不存在") try: - user.hashed_password = get_password_hash(new_password) + user.hashed_password = get_password_hash(body.new_password) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc await db_session.commit() diff --git a/src/shared/services/auth_service.py b/src/shared/services/auth_service.py index 8970812..0b23714 100644 --- a/src/shared/services/auth_service.py +++ b/src/shared/services/auth_service.py @@ -10,7 +10,7 @@ from sqlalchemy.orm import selectinload from shared.config.settings import settings from shared.database.database import get_db_session -from shared.models.database import User, UserRole +from shared.models.identity import User, UserRole from shared.utils.logger import get_logger logger = get_logger(__name__) diff --git a/tests/conftest.py b/tests/conftest.py index 09e7687..5e2a120 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,10 +17,10 @@ from sqlalchemy import select from fastapi import FastAPI, APIRouter from inventory.api import inventory_router -from shared.models.database import ( - Base, User, Customer, Warehouse, Supplier, Product, ProductMaterial, - Inventory, MaterialSupplier, SalesOrder, SalesOrderItem, -) +from shared.models.base import Base +from shared.models.identity import User +from inventory.models import Customer, Warehouse, Supplier, Product, ProductMaterial, Inventory, MaterialSupplier, SalesOrder, SalesOrderItem +import moldinsight.models # noqa: F401 # 全量注册:create_all 需含 moldinsight 表(stp_files 等) from shared.database.database import get_db_session from shared.services.auth_service import get_current_active_user diff --git a/tests/test_advanced_split_contract.py b/tests/test_advanced_split_contract.py new file mode 100644 index 0000000..78251ea --- /dev/null +++ b/tests/test_advanced_split_contract.py @@ -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" diff --git a/tests/test_auth_password_reset.py b/tests/test_auth_password_reset.py new file mode 100644 index 0000000..6feae52 --- /dev/null +++ b/tests/test_auth_password_reset.py @@ -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 diff --git a/tests/test_batch_status_pg.py b/tests/test_batch_status_pg.py index 422a2be..95fb206 100644 --- a/tests/test_batch_status_pg.py +++ b/tests/test_batch_status_pg.py @@ -11,7 +11,8 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession from moldinsight.api.batch_router import router as batch_router from shared.database.database import get_db_session -from shared.models.database import User, STPFile, ProcessingTask +from shared.models.identity import User +from moldinsight.models import STPFile, ProcessingTask from shared.services.auth_service import get_current_active_user diff --git a/tests/test_config_governance.py b/tests/test_config_governance.py new file mode 100644 index 0000000..dd9a2ce --- /dev/null +++ b/tests/test_config_governance.py @@ -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 diff --git a/tests/test_model_ownership.py b/tests/test_model_ownership.py new file mode 100644 index 0000000..3ad3a30 --- /dev/null +++ b/tests/test_model_ownership.py @@ -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") diff --git a/tests/test_redis_no_fallback.py b/tests/test_redis_no_fallback.py index 435041b..15efec4 100644 --- a/tests/test_redis_no_fallback.py +++ b/tests/test_redis_no_fallback.py @@ -40,9 +40,9 @@ async def test_storage_writes_flush_but_never_commit(): """D9:数据本体写方法仅 flush;commit 由编排层/请求侧负责。""" pytest.importorskip("minio") from sqlalchemy.ext.asyncio import AsyncSession - from moldinsight.services.storage_integration_rustfs import StorageIntegrationService + from moldinsight.services.task_storage_service import TaskStorageService - svc = StorageIntegrationService() + svc = TaskStorageService() session = AsyncMock(spec=AsyncSession) # update_task_parameters:select 返回 None(任务不存在)→ 直接 return result_mock = MagicMock() diff --git a/tests/test_route_load_status.py b/tests/test_route_load_status.py new file mode 100644 index 0000000..d28f24b --- /dev/null +++ b/tests/test_route_load_status.py @@ -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) diff --git a/tests/test_status_endpoint_auth.py b/tests/test_status_endpoint_auth.py index 008d7de..fd23ea1 100644 --- a/tests/test_status_endpoint_auth.py +++ b/tests/test_status_endpoint_auth.py @@ -12,7 +12,8 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession from moldinsight.api.task_router import router as task_router from shared.database.database import get_db_session -from shared.models.database import User, STPFile, ProcessingTask +from shared.models.identity import User +from moldinsight.models import STPFile, ProcessingTask from shared.services.auth_service import ( get_current_active_user, get_password_hash,