This commit is contained in:
2026-08-31 18:01:34 +08:00
parent 3ea59551db
commit bee439cf34
46 changed files with 1884 additions and 1898 deletions
+6 -2
View File
@@ -4,9 +4,13 @@ HOST=0.0.0.0
# ================================
# 模块 API 对外端口
# ================================
# gemold(moldinsight)API 对外端口
# 前端 Nginx 对外端口
FRONTEND_PORT=80
# unified backend 对外端口
BACKEND_PORT=8000
# gemold(moldinsight)API 对外端口(独立部署时使用)
MOLDINSIGHT_PORT=8000
# inventory API 对外端口
# inventory API 对外端口(独立部署时使用)
INVENTORY_PORT=8001
# ================================
-28
View File
@@ -1,28 +0,0 @@
# Legacy Dockerfile for geMoldInsight
#
# 说明:
# 该文件保留为历史/兼容用途,仍按旧的单体入口 `src/main.py` 组织。
# 当前模块化部署的主入口应优先使用:
# - deploy/Dockerfile.moldinsight
# - deploy/Dockerfile.inventory
# - deploy/Dockerfile.celery
# 以及 deploy/docker-compose.yml
FROM continuumio/miniconda3:latest
WORKDIR /app
COPY . .
RUN conda update -n base -c defaults conda -y && \
conda create -n moldinsight python=3.11 pythonocc-core=7.9.0 -c conda-forge -y
RUN . /opt/conda/etc/profile.d/conda.sh && \
conda activate moldinsight && \
pip install -r requirements.txt
RUN mkdir -p uploads html_output logs
RUN chmod +x start.sh
EXPOSE 8000
CMD ["/bin/bash", "-c", "source /opt/conda/etc/profile.d/conda.sh && conda activate moldinsight && python src/main.py"]
+18 -13
View File
@@ -115,11 +115,13 @@ geMoldInsight/
│ └── package.json
│
├── alembic/ # Alembic migrations
├── deploy/ # 镜像构建与部署辅助文件
├── deploy/ # 镜像构建、Nginx 配置与部署辅助文件
│ ├── Dockerfile.base
│ ├── Dockerfile.moldinsight
│ ├── Dockerfile.inventory
│ └── Dockerfile.celery
│ ├── Dockerfile.celery
│ ├── Dockerfile.frontend
│ └── nginx/
│
├── docs/
├── tests/
@@ -192,13 +194,14 @@ geMoldInsight/
当前项目设计上支持三种模式:
### 1. unified
一个统一后端同时挂载 gemold + inventory。
### 1. unified(当前推荐)
一个统一后端同时挂载 gemold + inventory,并作为前端同域反代的默认 backend。
适合:
- 本地开发
- 集成环境
- 小团队统一部署
- 前端独立部署 + 单 upstream 反代
### 2. gemold-only
只部署模具分析后端。
@@ -300,15 +303,15 @@ RUSTFS_SECRET_KEY=your-secret-key
当前唯一 Compose 入口:
- [docker-compose.yml](docker-compose.yml)
该 compose 文件**只启动项目自身容器**:
- `moldinsight`
该 compose 文件会启动:
- `frontend`(独立前端 Nginx 静态站点)
- `backend`(unified backend,当前推荐)
- `moldinsight-celery`
- `inventory`
- 可选保留:`moldinsight` / `inventory`(模块独立部署 profile)
并通过 `.env` 连接服务器上**已经存在**的:
- PostgreSQL
- Redis
- RustFS / MinIO 兼容对象存储
其中:
- 前端通过同域反代把 `/api`、`/health`、`/html` 转发给 unified backend
- PostgreSQL / Redis / RustFS / MinIO 兼容对象存储仍由服务器现有服务提供
示例:
@@ -318,10 +321,12 @@ docker compose --profile full up -d
可选 profile:
- `full`
- `frontend`
- `unified`
- `moldinsight`
- `inventory`
> 说明:`docker-compose.yml` 不再重复部署 postgres / redis / minio,而是复用服务器现有基础设施。
> 说明:根目录 `docker-compose.yml` 是当前唯一 Compose 入口;前端已独立部署,并默认反代到 unified backend。
### 方式 B:直接启动后端入口
@@ -337,7 +342,7 @@ inventory-only:
uvicorn src.entrypoints.inventory:app --reload --host 0.0.0.0 --port 8001
```
### 方式 C:启动前端
### 方式 C:单独启动前端开发服务器
```bash
cd frontend
+3 -1
View File
@@ -3,7 +3,9 @@
# ============================================
# 复制为 .env 并按服务器实际服务地址修改
# API 对外端口
# API / 前端对外端口
FRONTEND_PORT=80
BACKEND_PORT=8000
MOLDINSIGHT_PORT=8000
INVENTORY_PORT=8001
+13
View File
@@ -0,0 +1,13 @@
FROM node:20-alpine AS build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build
FROM nginx:1.27-alpine
COPY deploy/nginx/frontend.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/frontend/dist /usr/share/nginx/html
EXPOSE 80
-2
View File
@@ -2,8 +2,6 @@ FROM gemold-base:latest
COPY src/inventory/ /app/src/inventory/
COPY src/entrypoints/ /app/src/entrypoints/
COPY static/ /app/static/
RUN mkdir -p /app/logs
EXPOSE 8001
-1
View File
@@ -17,7 +17,6 @@ COPY src/inventory/ /app/src/inventory/
COPY src/entrypoints/ /app/src/entrypoints/
COPY src/celery_app.py src/celery_tasks.py /app/src/
COPY uploads/ /app/uploads/
COPY static/ /app/static/
COPY html_output/ /app/html_output/
RUN mkdir -p /app/logs
+17 -10
View File
@@ -8,25 +8,32 @@ echo === 构建基础镜像 ===
docker build -t gemold-base:latest -f deploy\Dockerfile.base .
echo.
echo === 构建 MoldInsight 镜像 (含 PythonOCC) ===
docker build -t gemold-moldinsight:latest -f deploy\Dockerfile.moldinsight .
echo.
echo === 构建 Inventory 镜像 ===
docker build -t gemold-inventory:latest -f deploy\Dockerfile.inventory .
echo === 构建统一后端镜像 ===
docker build -t gemold-backend:latest -f deploy\Dockerfile.moldinsight .
echo.
echo === 构建 Celery Worker 镜像 ===
docker build -t gemold-celery:latest -f deploy\Dockerfile.celery .
echo.
echo.
echo === 构建前端镜像 (Nginx 静态站点) ===
docker build -t gemold-frontend:latest -f deploy\Dockerfile.frontend .
echo.
echo === 全部构建完成 ===
echo.
echo 启动完整系统:
echo cd deploy ^&^& docker compose --profile full up -d
echo 启动完整系统(前端 + unified backend + celery):
echo docker compose --profile full up -d
echo.
echo 仅启动 unified backend:
echo docker compose --profile unified up -d
echo.
echo 仅启动前端入口:
echo docker compose --profile frontend up -d
echo.
echo 仅启动进销存:
echo cd deploy ^&^& docker compose --profile inventory up -d
echo docker compose --profile inventory up -d
echo.
echo 仅启动模具分析:
echo cd deploy ^&^& docker compose --profile moldinsight up -d
echo docker compose --profile moldinsight up -d
+17 -10
View File
@@ -10,25 +10,32 @@ echo "=== 构建基础镜像 ==="
docker build -t gemold-base:latest -f deploy/Dockerfile.base .
echo ""
echo "=== 构建 MoldInsight 镜像 (含 PythonOCC) ==="
docker build -t gemold-moldinsight:latest -f deploy/Dockerfile.moldinsight .
echo ""
echo "=== 构建 Inventory 镜像 ==="
docker build -t gemold-inventory:latest -f deploy/Dockerfile.inventory .
echo "=== 构建统一后端镜像 ==="
docker build -t gemold-backend:latest -f deploy/Dockerfile.moldinsight .
echo ""
echo "=== 构建 Celery Worker 镜像 ==="
docker build -t gemold-celery:latest -f deploy/Dockerfile.celery .
echo ""
echo ""
echo "=== 构建前端镜像 (Nginx 静态站点) ==="
docker build -t gemold-frontend:latest -f deploy/Dockerfile.frontend .
echo ""
echo "=== 全部构建完成 ==="
echo ""
echo "启动完整系统:"
echo " cd deploy && docker compose --profile full up -d"
echo "启动完整系统(前端 + unified backend + celery):"
echo " docker compose --profile full up -d"
echo ""
echo "仅启动 unified backend:"
echo " docker compose --profile unified up -d"
echo ""
echo "仅启动前端入口:"
echo " docker compose --profile frontend up -d"
echo ""
echo "仅启动进销存:"
echo " cd deploy && docker compose --profile inventory up -d"
echo " docker compose --profile inventory up -d"
echo ""
echo "仅启动模具分析:"
echo " cd deploy && docker compose --profile moldinsight up -d"
echo " docker compose --profile moldinsight up -d"
+46
View File
@@ -0,0 +1,46 @@
upstream gemold_backend_upstream {
server backend:8000;
}
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /assets/ {
try_files $uri =404;
access_log off;
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
}
location /api/ {
proxy_pass http://gemold_backend_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /health {
proxy_pass http://gemold_backend_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /html/ {
proxy_pass http://gemold_backend_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+122 -48
View File
@@ -1,4 +1,124 @@
services:
frontend:
build:
context: .
dockerfile: deploy/Dockerfile.frontend
image: gemold-frontend:latest
container_name: gemold_frontend
ports:
- "${FRONTEND_PORT:-80}:80"
depends_on:
- backend
restart: unless-stopped
profiles:
- full
- frontend
networks:
- gemold_network
backend:
build:
context: .
dockerfile: deploy/Dockerfile.moldinsight
image: gemold-backend:latest
container_name: gemold_backend
command: ["python", "-m", "uvicorn", "entrypoints.unified:app", "--host", "0.0.0.0", "--port", "8000"]
ports:
- "${BACKEND_PORT:-8000}:8000"
environment:
HOST: 0.0.0.0
PORT: "8000"
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT:-5432}
DB_NAME: ${DB_NAME:-moldinsight}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
REDIS_HOST: ${REDIS_HOST}
REDIS_PORT: ${REDIS_PORT:-6379}
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
RUSTFS_ENDPOINT: ${RUSTFS_ENDPOINT}
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY}
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY}
RUSTFS_TIMEOUT: ${RUSTFS_TIMEOUT:-30}
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@gemold.com}
ADMIN_FULL_NAME: ${ADMIN_FULL_NAME:-系统管理员}
ALGORITHM: ${ALGORITHM:-HS256}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-1440}
DEBUG: ${DEBUG:-false}
SERVE_FRONTEND_STATIC: ${SERVE_FRONTEND_STATIC:-false}
UPLOAD_DIR: ${UPLOAD_DIR:-./uploads}
MAX_FILE_SIZE: ${MAX_FILE_SIZE:-104857600}
ALLOWED_EXTENSIONS: ${ALLOWED_EXTENSIONS:-.stp,.step,.stp.gz}
POINTCLOUD_SAMPLE_COUNT: ${POINTCLOUD_SAMPLE_COUNT:-10000}
MESH_QUALITY: ${MESH_QUALITY:-high}
PARALLEL_PROCESSING: ${PARALLEL_PROCESSING:-true}
RUSTFS_PRESIGNED_URL_EXPIRES: ${RUSTFS_PRESIGNED_URL_EXPIRES:-3600}
ENABLE_FREECAD_VERIFICATION: ${ENABLE_FREECAD_VERIFICATION:-false}
FREECAD_VERIFICATION_TIMEOUT: ${FREECAD_VERIFICATION_TIMEOUT:-120}
LLM_ENABLED: ${LLM_ENABLED:-false}
LLM_API_URL: ${LLM_API_URL:-https://api.openai.com/v1}
LLM_API_KEY: ${LLM_API_KEY:-}
LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini}
LLM_TIMEOUT: ${LLM_TIMEOUT:-60}
LLM_MAX_TOKENS: ${LLM_MAX_TOKENS:-2000}
restart: unless-stopped
profiles:
- full
- unified
networks:
- gemold_network
moldinsight-celery:
build:
context: .
dockerfile: deploy/Dockerfile.celery
container_name: gemold_celery
environment:
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT:-5432}
DB_NAME: ${DB_NAME:-moldinsight}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
REDIS_HOST: ${REDIS_HOST}
REDIS_PORT: ${REDIS_PORT:-6379}
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
RUSTFS_ENDPOINT: ${RUSTFS_ENDPOINT}
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY}
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY}
RUSTFS_TIMEOUT: ${RUSTFS_TIMEOUT:-30}
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
ALGORITHM: ${ALGORITHM:-HS256}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-1440}
DEBUG: ${DEBUG:-false}
SERVE_FRONTEND_STATIC: ${SERVE_FRONTEND_STATIC:-false}
UPLOAD_DIR: ${UPLOAD_DIR:-./uploads}
MAX_FILE_SIZE: ${MAX_FILE_SIZE:-104857600}
ALLOWED_EXTENSIONS: ${ALLOWED_EXTENSIONS:-.stp,.step,.stp.gz}
POINTCLOUD_SAMPLE_COUNT: ${POINTCLOUD_SAMPLE_COUNT:-10000}
MESH_QUALITY: ${MESH_QUALITY:-high}
PARALLEL_PROCESSING: ${PARALLEL_PROCESSING:-true}
RUSTFS_PRESIGNED_URL_EXPIRES: ${RUSTFS_PRESIGNED_URL_EXPIRES:-3600}
ENABLE_FREECAD_VERIFICATION: ${ENABLE_FREECAD_VERIFICATION:-false}
FREECAD_VERIFICATION_TIMEOUT: ${FREECAD_VERIFICATION_TIMEOUT:-120}
LLM_ENABLED: ${LLM_ENABLED:-false}
LLM_API_URL: ${LLM_API_URL:-https://api.openai.com/v1}
LLM_API_KEY: ${LLM_API_KEY:-}
LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini}
LLM_TIMEOUT: ${LLM_TIMEOUT:-60}
LLM_MAX_TOKENS: ${LLM_MAX_TOKENS:-2000}
depends_on:
- backend
restart: unless-stopped
profiles:
- full
- unified
- moldinsight
networks:
- gemold_network
moldinsight:
build:
context: .
@@ -30,6 +150,7 @@ services:
ALGORITHM: ${ALGORITHM:-HS256}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-1440}
DEBUG: ${DEBUG:-false}
SERVE_FRONTEND_STATIC: ${SERVE_FRONTEND_STATIC:-false}
UPLOAD_DIR: ${UPLOAD_DIR:-./uploads}
MAX_FILE_SIZE: ${MAX_FILE_SIZE:-104857600}
ALLOWED_EXTENSIONS: ${ALLOWED_EXTENSIONS:-.stp,.step,.stp.gz}
@@ -47,53 +168,6 @@ services:
LLM_MAX_TOKENS: ${LLM_MAX_TOKENS:-2000}
restart: unless-stopped
profiles:
- full
- moldinsight
networks:
- gemold_network
moldinsight-celery:
build:
context: .
dockerfile: deploy/Dockerfile.celery
container_name: gemold_celery
environment:
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT:-5432}
DB_NAME: ${DB_NAME:-moldinsight}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
REDIS_HOST: ${REDIS_HOST}
REDIS_PORT: ${REDIS_PORT:-6379}
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
RUSTFS_ENDPOINT: ${RUSTFS_ENDPOINT}
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY}
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY}
RUSTFS_TIMEOUT: ${RUSTFS_TIMEOUT:-30}
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
ALGORITHM: ${ALGORITHM:-HS256}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-1440}
DEBUG: ${DEBUG:-false}
UPLOAD_DIR: ${UPLOAD_DIR:-./uploads}
MAX_FILE_SIZE: ${MAX_FILE_SIZE:-104857600}
ALLOWED_EXTENSIONS: ${ALLOWED_EXTENSIONS:-.stp,.step,.stp.gz}
POINTCLOUD_SAMPLE_COUNT: ${POINTCLOUD_SAMPLE_COUNT:-10000}
MESH_QUALITY: ${MESH_QUALITY:-high}
PARALLEL_PROCESSING: ${PARALLEL_PROCESSING:-true}
RUSTFS_PRESIGNED_URL_EXPIRES: ${RUSTFS_PRESIGNED_URL_EXPIRES:-3600}
ENABLE_FREECAD_VERIFICATION: ${ENABLE_FREECAD_VERIFICATION:-false}
FREECAD_VERIFICATION_TIMEOUT: ${FREECAD_VERIFICATION_TIMEOUT:-120}
LLM_ENABLED: ${LLM_ENABLED:-false}
LLM_API_URL: ${LLM_API_URL:-https://api.openai.com/v1}
LLM_API_KEY: ${LLM_API_KEY:-}
LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini}
LLM_TIMEOUT: ${LLM_TIMEOUT:-60}
LLM_MAX_TOKENS: ${LLM_MAX_TOKENS:-2000}
depends_on:
- moldinsight
restart: unless-stopped
profiles:
- full
- moldinsight
networks:
- gemold_network
@@ -125,9 +199,9 @@ services:
ALGORITHM: ${ALGORITHM:-HS256}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-1440}
DEBUG: ${DEBUG:-false}
SERVE_FRONTEND_STATIC: ${SERVE_FRONTEND_STATIC:-false}
restart: unless-stopped
profiles:
- full
- inventory
networks:
- gemold_network
+315
View File
@@ -0,0 +1,315 @@
# 前端独立部署 + 统一后端入口实施计划
> 目标:在已经切换到“前端独立部署 + 同域反代”的基础上,进一步取消前端 Nginx 对 `/api` 的路径级分流,改为反代到一个真正的 **unified backend**,一次性解决长期维护成本。
---
## 1. 背景
当前项目已经完成了两项关键演进:
1. 前端从历史 `static/` 托管模式中抽离,开始走独立构建与独立部署
2. 前端通过同域 Nginx 反代访问后端 API 与分析产物
但当前 Nginx 仍然承担了“后端路由所有权判断”的职责:
- 一部分 `/api/...` 被转发到 `moldinsight`
- 另一部分 `/api/...` 被转发到 `inventory`
这虽然能跑通当前功能,但长期存在明显问题:
- 每新增一个 gemold API,Nginx 都要同步改配置
- Nginx 配置承担了业务边界知识,维护成本高
- `/health` 只能代表某一套后端,而不是统一入口
- 与“unified / gemold-only / inventory-only”三种部署模式的目标不完全一致
因此,本轮改造的目标是:
> 把前端入口反代逻辑从“按路径分流到两套后端”升级为“统一反代到一个 unified backend”。
---
## 2. 目标状态
### 浏览器视角
浏览器始终只访问一个同域入口:
- `/` → 前端静态页面
- `/api/*` → unified backend
- `/health` → unified backend
- `/html/*` → unified backend(由 unified backend 再提供 gemold 产物访问)
### Nginx 视角
Nginx 不再负责理解 gemold / inventory 的业务边界。
它只做两件事:
1. 提供前端静态文件与 SPA fallback
2. 把 `/api`、`/health`、`/html` 统一转发给一个 backend upstream
### 后端视角
后端新增一个统一入口,负责组合:
- auth
- moldinsight routes
- inventory routes
- `/health`
- `/html`
同时继续保留:
- `moldinsight-only`
- `inventory-only`
以满足模块独立部署场景。
---
## 3. 设计决策
### 3.1 为什么要引入 unified backend
因为前端与网关层最适合面对的是一个统一后端,而不是两套需要网关手工分流的内部模块。
收益:
- Nginx 配置显著简化
- 新增 API 不需要修改网关规则
- 文档和运维认知更简单
- 前端保持统一 `/api` 契约
- 更符合模块化蓝图中对 `unified` 模式的定义
### 3.2 为什么不直接把 split 模式删掉
因为:
- `gemold-only` 和 `inventory-only` 仍然有独立部署价值
- 当前仓库已经形成了清晰模块边界
- 统一入口应该成为**前端同域反代的默认方案**,而不是抹掉模块部署模式
所以最终保留三类入口:
- `src/entrypoints/unified.py`
- `src/entrypoints/moldinsight.py`
- `src/entrypoints/inventory.py`
---
## 4. 需要改动的核心文件
## 4.1 新增 unified 入口
新增:
- `src/entrypoints/unified.py`
职责:
- 基于 `shared.app_factory.create_app()` 创建应用
- 统一挂载:
- `moldinsight.api.router`(prefix=`/api`)
- `inventory.api.inventory_router`
- 使用:
- `mount_html=True`
- `serve_frontend_static=False`
- 不额外挂载 auth(交给 `app_factory`)
- 不手工重复定义 `/health`
## 4.2 简化前端 Nginx
修改:
- `deploy/nginx/frontend.conf`
从当前:
- 双 upstream:`moldinsight` / `inventory`
- 多个 `location /api/...` 手工分流
改成:
- 单 upstream:例如 `gemold_backend_upstream`
- 统一转发:
- `/api/` → unified backend
- `/health` → unified backend
- `/html/` → unified backend
保留:
- `/` 的 SPA fallback
- `/assets/` 的静态缓存策略
## 4.3 调整 Compose
修改:
- `docker-compose.yml`
目标:
- 增加 unified backend 服务
- `frontend` 只依赖 unified backend
- 保留 `moldinsight-celery`
- 按需保留 split backend 入口作为独立 profile
建议最终 profile 语义:
- `full`:frontend + unified + celery
- `frontend`:仅前端入口
- `moldinsight`:仅 gemold-only
- `inventory`:仅 inventory-only
- (可选)`unified`:仅 unified backend
## 4.4 视实现需要调整 Dockerfile
可能新增:
- `deploy/Dockerfile.unified`
或复用已有:
- `deploy/Dockerfile.moldinsight`
取决于是否希望 unified backend 使用单独镜像名。
统一要求:
- unified backend 镜像必须包含:
- `src/moldinsight/`
- `src/inventory/`
- `src/shared/`
- `src/entrypoints/unified.py`
## 4.5 文档同步
需要更新:
- `README.md`
- `frontend/README.md`
- `docs/deployment/LINUX_SETUP.md`
- `docs/deployment/DEPLOY_PORT.md`
- `docs/deployment/PORT_CONFIG.md`
重点改动:
- 当前推荐部署方式改为“frontend + unified backend + celery”
- 说明 split 模式仍保留,但不再是前端同域反代默认方式
- 端口说明中要区分:
- 前端入口端口
- unified backend 内部/对外端口
- gemold-only / inventory-only 模块端口
---
## 5. 路由与冲突评估
根据当前代码结构,unified 模式可行,主要原因:
- inventory 所有业务路由都挂在 `/api` 下,并且以独立业务前缀区分
- moldinsight 业务路由同样挂在 `/api` 下,但使用不同子路径
- auth 路由使用 `/api/auth`
- top-level `/health` 由 `app_factory` 提供
- moldinsight 内部还有 `/api/health`,与 top-level `/health` 不冲突
- `/html` 只有 moldinsight 需要
关键约束:
1. unified 入口中不要重复 include auth
2. unified 入口中不要手工再定义 top-level `/health`
3. unified 入口必须 `mount_html=True`
---
## 6. 风险与控制
### 风险 1:统一入口与现有 split 入口行为不一致
**控制:**
- 保留现有 `moldinsight.py` 与 `inventory.py`
- 只把 unified 作为前端默认 upstream
### 风险 2:`/health` 语义变化
当前前端只请求一个 `/health`,但 split 时代它实际上只代表某个后端。
**控制:**
- unified 上的 `/health` 明确作为“前端默认 backend 健康入口”
- 文档中明确其语义
### 风险 3:`/html` 丢失或不可达
**控制:**
- unified backend 继续 `mount_html=True`
- 前端 Nginx 保留 `/html/` 反代
### 风险 4:Compose、Nginx、文档不同步
**控制:**
- 先写本计划文档
- 再改 unified 入口、Nginx、Compose
- 最后统一 README 与 deployment docs
---
## 7. 验证方案
## 7.1 路由验证
unified backend 启动后应验证:
- `/api/auth/login`
- `/api/auth/me`
- `/api/upload`
- `/api/status/{task_id}`
- `/api/history`
- `/api/cost-estimate`
- `/api/products`
- `/api/inventory`
- `/api/dashboard`
- `/api/finance/*`
- `/health`
- `/html/...`
## 7.2 前端验证
前端同域访问应验证:
- `/login`
- `/moldinsight`
- `/inventory`
- `/moldinsight/result/:taskId`
关键交互:
- 登录
- 模具上传
- 任务轮询
- 成本估算
- 产品/库存/订单页面加载
- `/html` 分析结果页访问
## 7.3 Compose 验证
完整系统:
```bash
docker compose --profile full up -d
```
应满足:
- `frontend` 正常提供页面
- `frontend` 只反代一个 unified backend
- `moldinsight-celery` 正常运行
- 不再依赖 Nginx 路径级业务分流
---
## 8. 实施顺序
1. 新增 `docs/FRONTEND_UNIFIED_DEPLOYMENT_PLAN.md`
2. 新增 `src/entrypoints/unified.py`
3. 修改 `deploy/nginx/frontend.conf`
4. 修改 `docker-compose.yml`
5. 按需要修改 Dockerfile / 构建脚本
6. 更新 README 与 deployment docs
7. 做一致性验证
---
## 9. 最终预期
完成后,系统对外部署形态将变成:
- 前端:独立 Nginx 静态站点
- 网关:同域同入口
- 后端:一个 unified backend 作为前端默认 upstream
- worker:保留 gemold Celery 异步处理
- split 模式:继续作为模块独立部署能力保留
这能一次性解决当前“前端入口依赖 Nginx 路径级业务分流”的长期维护问题。
+127
View File
@@ -0,0 +1,127 @@
# moldinsight 模块技术债务分析与重构计划
> 日期:2026-08-31 · 基线 commit:`3ea5955`(模块拆分 init)
> 状态标记:`[ ]` 待办 / `[x]` 已完成 / `[~]` 部分完成
---
## 一、问题清单(按严重程度)
### A. 安全漏洞(P0)
| # | 问题 | 位置 | 影响 |
|---|------|------|------|
| S1 | `/api/debug/tasks` 无鉴权,全量 dump 所有用户任务(含 geometry_data、analysis_result、文件名、LLM 报告)及 Redis 拓扑信息 | `api/debug_router.py:9-20` | 跨用户数据泄露 |
| S2 | `/api/history` 与 `/api/history/{filename}` 无鉴权,且 `get_all_file_groups()` 未传 user_id(参数形同虚设) | `api/history_router.py:25-40`、`services/storage_integration_rustfs.py:698-704` | 跨用户文件清单泄露 |
| S3 | `_ensure_task_access` 对 `owner_id is None` 的无主数据直接放行 | `api/advanced_router.py:87-89` | 任意登录用户可下载历史无主任务的导出文件 |
### B. 静默失败(P0)
| # | 问题 | 位置 | 影响 |
|---|------|------|------|
| F1 | `/api/detect-undercuts` 传 `shape=None`,OCC 异常被兜底 except 吞掉,**永远返回"无倒扣"的假 DFM 结论** | `api/advanced_router.py:293-302`、`core/side_action_designer.py:205-218` | 功能性错误,用户拿到 200 + 错误工程结论 |
| F2 | `asyncio.wait_for` 超时无法杀死 OCC 线程;`_occ_executor` 为 `max_workers=1`,一个病态文件可**永久堵死全部分析队列**直到重启 | `services/processing_service.py:44-45,74-82` | 服务级可用性风险 |
| F3 | `asyncio.create_task(...)` 未持有引用(GC 可回收任务)且无并发上限 | `api/upload_router.py:99-108`、`api/batch_router.py:114-121` | 后台任务静默消失 / 内存失控 |
### C. 性能与资源(P1)
| # | 问题 | 位置 | 影响 |
|---|------|------|------|
| P1 | 已完成任务每次状态轮询都从 RustFS 全量拉取 geometry + 多方案型腔 JSON + 网格 + 完整 HTML,无缓存 | `services/task_query_service.py:54-58` | 轮询 5s 一次 = 每次几十 MB 对象存储流量 |
| P2 | `_export_shapes_cache` 缓存 OCC TopoDS_Shape(C++ 原生内存),按 task_id 无上限增长,无 LRU/TTL | `services/processing_service.py:43` | 原生内存泄漏 |
| P3 | `save_html_file` 双写:完整 HTML 既入 RustFS 又塞 PG 行(`html_content`) | `services/storage_integration_rustfs.py:448-462` | PG 表膨胀 + 双份数据一致性负担 |
| P4 | `get_all_file_groups` 每文件组单独一次 count 查询(N+1) | `services/storage_integration_rustfs.py:745-751` | history 接口放大 100 倍查询 |
| P5 | `update_task` 为 get->merge->set 三步非原子,后台流程与 export-mold 端点并发写同一任务会**丢更新**;且每次进度 tick 全量重写整个 blob | `shared/services/redis_task_manager.py:138-146` | 竞态丢数据 + 写放大 |
| P6 | 服务重启后 `_export_shapes_cache` 清空,STL 等格式的重导出直接 409 | `api/advanced_router.py:477-481` | 用户体验缺陷 |
| P7 | `get_task_view` 已 joinedload `html_file` 后又单独查询 HTMLFile;`llm_service._chat` 每次新建 httpx client 且无重试 | `services/task_query_service.py:76-81`、`services/llm_service.py:517-531` | 小浪费 × 高频 |
### D. 架构与死代码(P2)
| # | 问题 | 位置 | 影响 |
|---|------|------|------|
| D1 | ~1000 行死代码:`storage_integration.py`(MinIO版,376行,零引用)、`storage/object_storage.py`(361行,仅被死文件引用)、`storage_service.py`(295行,零引用,仍用已弃用列)、`src/main.py`(废弃单体,~230行) | 详见各文件 | 认知负担 + 误用风险 |
| D2 | **根 Dockerfile 仍在运行旧单体** `python src/main.py`,在仓库根目录 `docker build .` 会部署出错误服务 | `Dockerfile:28` | 部署陷阱 |
| D3 | planner 调用 generator 13 个 `_` 前缀私有方法,私有方法成为事实契约;公共 API `generate_mold_cavities` 反而无人使用 | `core/multi_scheme_planner.py:38,102-165` | core 边界糊化,重构即炸 |
| D4 | `REDIS_HOST` 两处读取两个默认值,其一为硬编码个人主机名 `szcjw`;settings 在 **import 时**因缺 DB 配置直接 raise | `shared/services/redis_task_manager.py:38`、`shared/config/settings.py:50-51` | 配置漂移 + 模块不可导入即不可测 |
| D5 | upload/batch 约 50 行复制粘贴(参数归一化 + Celery/asyncio 分派);`process_file_with_storage` 与 `process_file_core` 异常处理两份拷贝 | `api/upload_router.py:43-49` vs `api/batch_router.py:62-68` | 漂移风险 |
| D6 | moldinsight 测试覆盖为零;唯一测试 `temp_test_injection_p0.py` 因无 `test_` 前缀不被收集,且用黑加载规避 settings 导入期失败 | `tests/` | 回归无保障 |
| D7 | 铝价服务返回模拟数据但未在任何层面标注 | `services/aluminum_price_service.py` | 产品诚信问题 |
---
## 二、实施方案
### P0:安全 + 静默失败(先做)
- [x] **① 补鉴权(修 S1/S2/S3)**
- `history_router` 两个端点加 `get_current_active_user` 依赖,显式传 `user_id=current_user.id`
- `debug_router` 加鉴权,且仅在 `settings.DEBUG` 下注册
- `_ensure_task_access` 改为 `owner_id != user_id` 即 403(无主数据同样拒绝)
- [x] **② 统一后台分派(修 F3,消 D5 一半)**
- 新建 `services/task_dispatcher.py`:Celery 可用走 `process_stp_task.delay`;否则 `asyncio.create_task` 并持有强引用(`_background_tasks` set + done_callback 回收)
- upload/batch 路由统一调用;`asyncio.Semaphore` 限制 API 进程内并发处理数
- [x] **③ 超时后重置 OCC executor(修 F2)**
- `asyncio.TimeoutError` 分支调用 `_reset_occ_executor()`:新建 executor、旧 executor `shutdown(wait=False)`
- 泄漏 1 个挂死线程远好于全队列堵死;生产环境确认 celery worker 必配(进程隔离天然免疫)
- [x] **④ shape_loader 重建几何(修 F1)**
- 新建 `services/shape_loader.py`:task_id -> PG 查 object_key -> RustFS 下载 STP -> 临时文件 -> occ executor 内 `stp_parser.load_step_file`
- `/detect-undercuts` 用真实 shape 调 `analyze_and_design`,补 `_ensure_task_access`
- `/cost-estimate` 的任务数据源从 Redis 直读迁移到 `TaskQueryService.get_task_view`(完成态走 PG+RustFS 组装,语义正确)
- [x] **⑤ Redis 哈希原子更新 + 配置收敛 + 完成态瘦身(修 P5/D4 部分)**
- `redis_task_manager` 改为 Hash 存储:`HSET task:{id} field value` 字段级原子更新,无读改写竞态,进度 tick 不再全量重写 blob
- 兼容读旧 string 格式(过渡期);`redis_client` 属性保留供 batch_router 使用
- 连接参数统一读 `settings.*`,删除硬编码 `szcjw`
- 完成态任务 Redis 只存摘要字段(去掉 geometry_data/analysis_result 大对象,完成态视图本就由 PG+RustFS 组装)
### P1:性能与资源
- [x] **⑤ 任务视图 TTL 缓存(修 P1/P7 部分)**
- `TaskQueryService.get_task_view` 对 PG 路径(completed/failed)加进程内 TTL 缓存(60s)
- export-mold / cam 写参数后显式失效;删除重复的 HTMLFile 单独查询
- [x] **⑥ export_shapes_cache 改 LRU(修 P2)**
- OrderedDict LRU,`maxsize=32`,命中 `move_to_end`,满则逐出最旧(连原生 OCC shape 一起释放)
- [x] **⑦ 重启后 STEP->STL 现场转换(修 P6/F1 根因延伸)**
- 分析期已持久化各方案 cavity/core/分型面 STEP;重启后 cache miss 时下载已持久化的 STEP -> OCC 读取 -> 三角化 -> 写 STL
- `export-mold` 的 409 分支前新增此兜底,用户不再需要重新分析
- [x] **⑧ 收尾(修 P3/P4/P7)**
- `save_html_file` 停止向 PG 写 `html_content`(RustFS 为准,PG 只存 key 与文件名)
- `get_all_file_groups` 的 N+1 count 改为单条 `GROUP BY` 聚合
- `llm_service._chat` 加一次瞬态错误重试(保持 per-call client:celery 每任务新循环,模块级 AsyncClient 会跨循环失效,与 redis 同理)
### P2:架构清理
- [x] **⑨ 删死代码(修 D1/D2)**
- 删除:`services/storage_integration.py`、`storage/object_storage.py`、`services/storage_service.py`、`src/main.py`、根 `Dockerfile`
- 删前 `grep -r` 确认零引用(动态引用也排查)
- [x] **⑪ 配置收敛(修 D4 后半)**
- `settings` 改惰性校验:DB 配置缺失不在 import 时 raise,改为首次访问 `DATABASE_URL` 时报清晰错误
- 解锁 `import shared.*` 无 env 场景(测试环境)
- [x] **⑫ 测试建设(修 D6,本阶段做低风险部分)**
- `temp_test_injection_p0.py` -> `test_injection_p0.py`,改包路径导入
- 补纯逻辑单测:PartingSchemeScorer / PartingCandidateGenerator / MaterialService / cost_estimate_service / `_determine_mold_structure` / redis_task_manager 序列化
- [ ] **⑩ Generator 公共接口提取(修 D3)** —— 13 个 `_` 方法提为公共 API,需排期单独做(纯机械重命名,但触及 core 三个文件,建议独立 PR + 集成测试保护)
- [ ] **⑬ 顺手项(修 D7 等)** —— advanced_router 拆分 + Pydantic 模型;铝价响应加 `"source": "simulated"` 并前端标注
---
## 三、验证方式
1. `python -m pytest tests/ -x`(inventory 既有测试不回归 + 新增单测通过)
2. `python -c "import ..."` 冒烟:dispatcher / shape_loader / redis_task_manager / task_query_service 可导入
3. 部署面:`docker-compose.yml` 仅引用 `deploy/Dockerfile.*`,根 Dockerfile 删除后无引用(grep 验证)
## 四、风险与回滚
- Redis Hash 改造保留旧 string 读取兼容:升级期间在途任务可读;新写入一律 Hash。回滚版本读到 Hash 会 `get_task` 返回 None -> 走 PG 组装路径(TaskQueryService 兜底),不会 500
- `_ensure_task_access` 收紧 owner=None 后,如确有管理员查看无主历史数据的需求,后续走 admin 角色专用端点,而非放开普通用户
- `html_content` 停写后,历史行中的旧数据仍可读(列保留),仅新行不再写入
+10 -6
View File
@@ -4,9 +4,9 @@
当前推荐部署对象:
- gemold API
- frontend(Nginx,同域入口)
- unified backend
- gemold Celery worker(无 HTTP 端口)
- inventory API
以下基础设施默认由服务器现有服务提供,不在本项目 compose 中重复部署:
@@ -20,8 +20,10 @@
| 组件 | 默认端口 | 说明 |
|---|---:|---|
| gemold API | 8000 | 模具分析后端 |
| inventory API | 8001 | 进销存后端 |
| frontend | 80 | 前端 Nginx,同域入口 |
| unified backend | 8000 | 当前推荐统一后端 |
| gemold API | 8000 | 模具分析独立部署时使用 |
| inventory API | 8001 | 进销存独立部署时使用 |
| PostgreSQL | 5432 | 共享数据库 |
| Redis | 6379 | 共享队列/缓存 |
| MinIO API | 9000 | 对象存储接口 |
@@ -67,8 +69,10 @@
关键端口映射:
- `MOLDINSIGHT_PORT` → gemold API 外部端口
- `INVENTORY_PORT` → inventory API 外部端口
- `FRONTEND_PORT` → frontend Nginx 外部端口
- `BACKEND_PORT` → unified backend 外部端口
- `MOLDINSIGHT_PORT` → gemold-only 独立部署端口
- `INVENTORY_PORT` → inventory-only 独立部署端口
示例:
+29 -6
View File
@@ -4,7 +4,7 @@
当前项目支持三种部署模式:
- **unified**:gemold + inventory 统一部署
- **unified**:frontend + unified backend + celery,统一对外部署(当前推荐)
- **gemold-only**:仅部署模具分析后端
- **inventory-only**:仅部署进销存后端
@@ -140,6 +140,19 @@ RUSTFS_SECRET_KEY=minioadmin
## 6. 启动方式
## 6.0 frontend(同域反代入口)
当前推荐把前端作为独立静态站点部署,并通过同域 Nginx 反代到 unified backend:
- `/` → 前端静态资源与 SPA 路由
- `/api` → unified backend
- `/health` → unified backend
- `/html` → unified backend(内部再提供 gemold 分析产物)
如果使用根目录 [docker-compose.yml](../../docker-compose.yml) 的 `frontend` 服务,则该入口已经内置在前端 Nginx 镜像中。
---
## 6.1 inventory-only
```bash
@@ -285,7 +298,16 @@ WantedBy=multi-user.target
---
## 8. Nginx 反向代理示例
## 8. Nginx / 前端同域反代示例
当前仓库已提供前端 Nginx 配置:
- [deploy/nginx/frontend.conf](../../deploy/nginx/frontend.conf)
如果不使用仓库内 `frontend` 容器,也应遵循同样原则:
- `/` 提供前端静态资源与 SPA fallback
- `/api/` 反代后端
- `/health` 反代后端
- `/html/` 反代 gemold
### 8.1 inventory-only
@@ -373,11 +395,12 @@ curl http://127.0.0.1:8000/health
## 11. Docker Compose 说明
当前 [docker-compose.yml](../../docker-compose.yml) 仅启动:
当前 [docker-compose.yml](../../docker-compose.yml) 会启动:
- `moldinsight`
- `frontend`
- `backend`
- `moldinsight-celery`
- `inventory`
- 可选:`moldinsight` / `inventory`(独立模块模式)
它**不会**再拉起:
@@ -385,7 +408,7 @@ curl http://127.0.0.1:8000/health
- Redis
- MinIO
这些基础设施应由服务器现有服务提供,并通过 `.env` 传入连接信息。
这些基础设施应由服务器现有服务提供,并通过 `.env` 传入连接信息;前端则由 `frontend` 容器独立提供,并通过同域反代转发到后端。
示例:
+6 -2
View File
@@ -25,6 +25,8 @@
核心环境变量:
```env
FRONTEND_PORT=80
BACKEND_PORT=8000
MOLDINSIGHT_PORT=8000
INVENTORY_PORT=8001
```
@@ -48,8 +50,10 @@ RUSTFS_ENDPOINT=http://localhost:9000
| 变量 / 端口 | 用途 |
|---|---|
| `MOLDINSIGHT_PORT` | gemold API 宿主机暴露端口 |
| `INVENTORY_PORT` | inventory API 宿主机暴露端口 |
| `FRONTEND_PORT` | 前端 Nginx 宿主机暴露端口 |
| `BACKEND_PORT` | unified backend 宿主机暴露端口 |
| `MOLDINSIGHT_PORT` | gemold-only 独立部署端口 |
| `INVENTORY_PORT` | inventory-only 独立部署端口 |
| `DB_PORT` | PostgreSQL 端口 |
| `REDIS_PORT` | Redis 端口 |
| `9000` | MinIO/RustFS S3 兼容 API |
+72 -3
View File
@@ -1,5 +1,74 @@
# Vue 3 + TypeScript + Vite
# geMoldInsight Frontend
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
这是 geMoldInsight 的独立前端工程,基于:
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
- Vue 3
- Vite
- TypeScript
- Pinia
- Vue Router
当前推荐部署方式为:
> **独立前端部署 + 同域反代 + unified backend**
即:
- 前端由 Nginx 静态站点独立提供
- `/api`、`/health`、`/html` 通过同域反代统一转发到一个 unified backend
- 前端继续使用相对路径调用后端接口
---
## 本地开发
```bash
npm install
npm run dev
```
开发服务器默认:
- 端口:`5173`
- 已代理:`/api`、`/health`、`/html`
---
## 生产构建
```bash
npm run build
```
构建产物输出到:
- `frontend/dist/`
该目录由前端 Nginx 镜像或外部静态站点托管,不再输出到仓库根目录 `static/`。
---
## 部署
当前仓库已提供:
- [deploy/Dockerfile.frontend](../deploy/Dockerfile.frontend)
- [deploy/nginx/frontend.conf](../deploy/nginx/frontend.conf)
以及根目录 Compose 中的 `frontend` 服务:
```bash
docker compose --profile frontend up -d
```
完整系统:
```bash
docker compose --profile full up -d
```
---
## 说明
前端历史上曾通过后端 `static/` 目录托管;当前已切换为独立部署模式。后端默认不再负责提供 SPA 页面,但仍提供:
- `/api/*`
- `/health`
- `/html/*`(gemold 分析产物)
+2 -2
View File
@@ -244,7 +244,7 @@ export const inventoryApi = {
updatePurchaseOrderStatus(id: number, status: string) {
return apiRequest<Schema<'PurchaseOrderResponse'>>(`/api/purchase-orders/${id}/status`, {
method: 'PUT',
method: 'PATCH',
body: JSON.stringify({ status }),
})
},
@@ -405,6 +405,6 @@ export const moldinsightApi = {
},
getAluminumPrice() {
return apiRequest<{ price: number; unit: string; updated_at: string }>('/api/aluminum-price/')
return apiRequest<{ price: number; unit: string; updated_at: string }>('/api/aluminum-price/current')
},
}
+10 -2
View File
@@ -9,9 +9,9 @@ export default defineConfig(({ mode }) => ({
'@': resolve(__dirname, 'src'),
},
},
base: mode === 'development' ? '/' : '/static/',
base: '/',
build: {
outDir: '../static',
outDir: 'dist',
emptyOutDir: true,
},
server: {
@@ -21,6 +21,14 @@ export default defineConfig(({ mode }) => ({
target: 'http://localhost:8000',
changeOrigin: true,
},
'/health': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/html': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
}))
+1
View File
@@ -25,5 +25,6 @@ app = create_app(
title="Gemold - 进销存管理系统",
service_name="inventory",
mount_html=False,
serve_frontend_static=False,
register_routers=_register_routers,
)
+1
View File
@@ -25,5 +25,6 @@ app = create_app(
title="Gemold - 模具分析引擎",
service_name="moldinsight",
mount_html=True,
serve_frontend_static=False,
register_routers=_register_routers,
)
+36
View File
@@ -0,0 +1,36 @@
"""
unified 入口 — 同时挂载 moldinsight + inventory,供前端同域反代统一访问
"""
import os, sys
from pathlib import Path
src_root = Path(__file__).parent.parent
sys.path.insert(0, str(src_root))
os.chdir(Path(__file__).parent.parent.parent)
from shared.app_factory import create_app
def _register_routers(app):
"""注册 unified 业务路由"""
try:
from moldinsight.api import router as moldinsight_router
app.include_router(moldinsight_router, prefix="/api")
except Exception as e:
print(f"[WARN] MoldInsight 路由: {e}")
try:
from inventory.api import inventory_router
app.include_router(inventory_router)
except Exception as e:
print(f"[WARN] Inventory 路由: {e}")
app = create_app(
title="Gemold - Unified Backend",
service_name="unified",
mount_html=True,
serve_frontend_static=False,
register_routers=_register_routers,
)
-261
View File
@@ -1,261 +0,0 @@
# main.py — 已废弃,保留向后兼容
# 推荐使用入口:
# - src/entrypoints/moldinsight.py (模具分析服务)
# - src/entrypoints/inventory.py (进销存服务)
# 两者均基于 shared.app_factory.create_app() 构建,消除重复代码。
import sys
import os
from pathlib import Path
# 添加项目根目录到Python路径
project_root = Path(__file__).parent.parent
src_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
sys.path.insert(0, str(src_root))
# 确保当前工作目录是项目根目录
os.chdir(project_root)
# 打印调试信息
print(f"项目根目录: {project_root}")
print(f"Python路径: {sys.path}")
print(f"当前工作目录: {os.getcwd()}")
# 测试导入配置模块
try:
from shared.config.settings import settings
print("[OK] 配置模块导入成功")
except ImportError as e:
print(f"[FAIL] 配置模块导入失败: {e}")
# 列出当前目录内容
print("当前目录内容:")
for item in os.listdir('.'):
print(f" - {item}")
# 列出config目录内容
if os.path.exists('config'):
print("config目录内容:")
for item in os.listdir('config'):
print(f" - {item}")
raise
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import asyncio
import time
from shared.services.auth_routes import router as auth_router
from inventory.api import inventory_router
from moldinsight.api.aluminum_price_routes import router as aluminum_price_router
from shared.utils.logger import setup_logging, get_logger
from shared.database.init_db import init_database
setup_logging()
logger = get_logger(__name__)
app = FastAPI(
title="Gemold - 模具制造管理系统",
description="模具制造行业综合管理平台,包含模具分析、进销存管理等功能",
version="4.0.0"
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def log_requests(request: Request, call_next):
start_time = time.time()
response = await call_next(request)
duration = time.time() - start_time
status = response.status_code
if status >= 400:
logger.warning(
f"[HTTP] {request.method} {request.url.path} -> {status} "
f"({duration:.2f}s) "
f"client={request.client.host if request.client else 'unknown'}"
)
return response
# 启动时初始化数据库和RustFS
@app.on_event("startup")
async def startup_event():
"""应用启动时初始化数据库和RustFS"""
# 初始化数据库
success = await init_database(keep_connected=True)
if success:
print("[OK] 数据库初始化成功")
else:
print("[FAIL] 数据库初始化失败,服务将继续运行但数据库功能不可用")
# 初始化RustFS连接
try:
from moldinsight.storage.rustfs_storage import rustfs_manager
from shared.config.settings import settings
await rustfs_manager.connect(
endpoint=settings.RUSTFS_ENDPOINT,
access_key=settings.RUSTFS_ACCESS_KEY,
secret_key=settings.RUSTFS_SECRET_KEY,
timeout=settings.RUSTFS_TIMEOUT
)
print("[OK] RustFS连接成功")
except Exception as e:
print(f"[FAIL] RustFS连接失败: {e}")
print("[WARN] 文件上传功能将不可用,但其他功能正常")
# 初始化Redis连接
try:
from shared.services.redis_task_manager import redis_task_manager
await redis_task_manager.connect()
if redis_task_manager.is_connected:
print("[OK] Redis连接成功")
else:
print("[WARN] Redis连接失败,任务状态将使用内存回退")
except Exception as e:
print(f"[WARN] Redis初始化异常: {e},任务状态将使用内存回退")
@app.on_event("shutdown")
async def shutdown_event():
"""应用关闭时清理资源"""
try:
from shared.services.redis_task_manager import redis_task_manager
await redis_task_manager.disconnect()
print("[OK] Redis连接已断开")
except Exception as e:
print(f"[WARN] Redis断开异常: {e}")
# 创建必要目录
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
TEMPLATES_DIR = Path("templates")
TEMPLATES_DIR.mkdir(exist_ok=True)
STATIC_DIR = Path("static")
STATIC_DIR.mkdir(exist_ok=True)
HTML_OUTPUT_DIR = Path("html_output")
HTML_OUTPUT_DIR.mkdir(exist_ok=True)
# 挂载静态文件
import os
# 使用当前工作目录下的static文件夹
static_dir = os.path.join(os.getcwd(), "static")
app.mount("/static", StaticFiles(directory=static_dir), name="static")
# 挂载HTML输出目录
html_output_dir = os.path.join(os.getcwd(), "html_output")
app.mount("/html", StaticFiles(directory=html_output_dir), name="html")
app.include_router(auth_router)
app.include_router(inventory_router)
app.include_router(aluminum_price_router, prefix="/api")
try:
from moldinsight.api import router as moldinsight_router
app.include_router(moldinsight_router, prefix="/api")
except Exception as e:
print(f"[WARN] MoldInsight 路由未加载: {e}")
@app.get("/health")
@app.post("/health")
async def health():
from shared.database.database import db_manager
from sqlalchemy import text
db_ok = False
db_error = None
try:
if not db_manager.is_connected:
await db_manager.connect()
async with db_manager.engine.begin() as conn:
await conn.execute(text("SELECT 1"))
db_ok = True
except Exception as e:
db_ok = False
db_error = str(e)
return {
"status": "healthy",
"service": "gemold",
"version": "4.0.0",
"database_connected": db_ok,
"database_error": db_error
}
@app.get("/")
async def root():
from fastapi.responses import FileResponse
return FileResponse(os.path.join(os.getcwd(), "static", "index.html"))
@app.get("/moldinsight")
async def moldinsight():
from fastapi.responses import FileResponse
return FileResponse(os.path.join(os.getcwd(), "static", "index.html"))
@app.get("/inventory")
async def inventory():
from fastapi.responses import FileResponse
return FileResponse(os.path.join(os.getcwd(), "static", "index.html"))
@app.get("/login")
async def login():
from fastapi.responses import FileResponse
return FileResponse(os.path.join(os.getcwd(), "static", "index.html"))
@app.get("/users")
async def users():
from fastapi.responses import FileResponse
return FileResponse(os.path.join(os.getcwd(), "static", "index.html"))
@app.get("/moldinsight/result/{task_id:path}")
async def moldinsight_result(task_id: str):
from fastapi.responses import FileResponse
return FileResponse(os.path.join(os.getcwd(), "static", "index.html"))
@app.get("/_design-system")
async def design_system():
from fastapi.responses import FileResponse
return FileResponse(os.path.join(os.getcwd(), "static", "index.html"))
@app.get("/_release")
async def release():
from fastapi.responses import FileResponse
return FileResponse(os.path.join(os.getcwd(), "static", "index.html"))
if __name__ == "__main__":
import uvicorn
from shared.config.settings import settings
reload_enabled = os.getenv("UVICORN_RELOAD", "0").lower() in {"1", "true", "yes", "on"}
print("启动 Gemold 模具制造管理系统 v4.0...")
print(f"访问 http://localhost:{settings.PORT}")
print(f"Python可执行文件: {sys.executable}")
print(f"进程ID: {os.getpid()}")
print(f"热重载: {reload_enabled}")
print("功能模块:")
print(" - 首页仪表盘")
print(" - 用户管理")
print(" - MoldInsight 模具分析")
print(" - 进销存管理")
uvicorn.run(
"main:app",
host=settings.HOST,
port=settings.PORT,
reload=reload_enabled
)
+5 -1
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter
import importlib
from shared.config.settings import settings
from shared.utils.logger import get_logger
logger = get_logger(__name__)
@@ -24,7 +25,10 @@ _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.debug_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", "调试")
+59 -13
View File
@@ -65,10 +65,6 @@ def _get_cached_import(key: str):
return None
async def _get_task_data(task_id: str) -> dict:
return await redis_task_manager.get_task(task_id)
async def _ensure_task_access(
db_session: AsyncSession,
task_id: str,
@@ -85,7 +81,8 @@ async def _ensure_task_access(
_, stp_file = row
owner_id = getattr(stp_file, "user_id", None)
if owner_id is not None and owner_id != user_id:
if owner_id != user_id:
# 无主历史数据(owner_id is None)同样拒绝:无主不等于公共
raise HTTPException(403, "无权访问该任务的导出文件")
return row
@@ -283,21 +280,32 @@ async def design_complete_mold_system(
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(404, "缺少 task_id")
task_data = await _get_task_data(task_id)
if not task_data:
raise HTTPException(404, "任务不存在")
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, "服务不可用:核心模块未加载")
result = sd.analyze_and_design(
shape=None, parting_direction=parting_direction, mold_size=mold_size,
# 从持久化 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}
@@ -306,13 +314,18 @@ async def detect_undercuts(
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(404, "缺少 task_id")
task_data = await _get_task_data(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")
@@ -475,6 +488,38 @@ async def export_mold_results(
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},
)
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,
"导出缓存已失效或任务尚未完成,请重新分析后再导出以保证方案一致性",
@@ -499,6 +544,7 @@ async def export_mold_results(
{"export_artifacts": merged_artifacts},
)
await redis_task_manager.update_task(task_id, {"export_artifacts": merged_artifacts})
TaskQueryService.invalidate_task_view(task_id) # parameters 已变更,缓存视图失效
return {"status": "success", "data": result}
+41 -28
View File
@@ -6,7 +6,7 @@ moldinsight/api/batch_router.py — 批量分析端点
"""
import uuid
from datetime import datetime
from typing import List, Dict, Any
from typing import List, Dict, Any, Optional
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
@@ -19,13 +19,7 @@ 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
try:
from celery_tasks import process_stp_task
_use_celery = True
except ImportError:
process_stp_task = None
_use_celery = False
from moldinsight.services.task_dispatcher import dispatch_processing
logger = get_logger(__name__)
@@ -37,6 +31,9 @@ file_handler = FileHandler()
_BATCH_KEY_PREFIX = "batch:"
_BATCH_TTL = 86400 # 24h
# Redis 不可用时的进程内降级存储(同进程内可查,跨进程/重启不可见)
_batch_meta_memory: Dict[str, dict] = {}
def _batch_redis_key(batch_id: str) -> str:
return f"{_BATCH_KEY_PREFIX}{batch_id}"
@@ -111,14 +108,7 @@ async def batch_upload(
await redis_task_manager.set_task(task_id, task_info)
# 调度处理
if _use_celery:
process_stp_task.delay(task_id, str(file_path), stp_file.id, process_params)
else:
import asyncio
from moldinsight.services.processing_service import processing_service
asyncio.create_task(processing_service.process_file_with_storage(
task_id, str(file_path), stp_file.id, process_params
))
dispatch_processing(task_id, str(file_path), stp_file.id, process_params)
tasks.append({
"filename": file.filename,
@@ -139,7 +129,7 @@ async def batch_upload(
"error": str(exc),
})
# 将 batch 元数据写入 Redis
# 将 batch 元数据写入 Redis;Redis 不可用时降级到进程内存储(任务状态本身有内存回退)
batch_meta = {
"batch_id": batch_id,
"user_id": current_user.id,
@@ -148,11 +138,7 @@ async def batch_upload(
"total": len(tasks),
"params": process_params,
}
await redis_task_manager.redis_client.set(
_batch_redis_key(batch_id),
__import__("json").dumps(batch_meta),
ex=_BATCH_TTL,
)
_save_batch_meta(batch_id, batch_meta)
return {
"batch_id": batch_id,
@@ -162,20 +148,47 @@ async def batch_upload(
}
def _save_batch_meta(batch_id: str, batch_meta: dict):
"""批量元数据持久化:优先 Redis(跨进程、带 TTL),降级进程内 dict。"""
import json as _json
if redis_task_manager.is_connected:
try:
redis_task_manager.redis_client.set(
_batch_redis_key(batch_id),
_json.dumps(batch_meta),
ex=_BATCH_TTL,
)
return
except Exception as exc:
logger.warning(f"[BATCH] batch 元数据写 Redis 失败,降级内存: {exc}")
_batch_meta_memory[batch_id] = batch_meta
async def _load_batch_meta(batch_id: str) -> Optional[dict]:
"""读取批量元数据,Redis 优先,内存兜底;不存在返回 None。"""
import json as _json
if redis_task_manager.is_connected:
try:
raw = await redis_task_manager.redis_client.get(_batch_redis_key(batch_id))
if raw:
return _json.loads(raw)
except Exception as exc:
logger.warning(f"[BATCH] batch 元数据读 Redis 失败: {exc}")
return _batch_meta_memory.get(batch_id)
@router.get("/batch/{batch_id}")
async def get_batch_status(
batch_id: str,
current_user: User = Depends(get_current_active_user),
):
"""聚合查询批量任务进度"""
import json
raw = await redis_task_manager.redis_client.get(_batch_redis_key(batch_id))
if not raw:
batch_meta = await _load_batch_meta(batch_id)
if not batch_meta:
raise HTTPException(404, "批量任务不存在或已过期")
batch_meta = json.loads(raw)
# 权限检查
if batch_meta.get("user_id") and batch_meta["user_id"] != current_user.id:
raise HTTPException(403, "无权访问该批量任务")
+2
View File
@@ -96,6 +96,8 @@ async def generate_cam_plan(
}
processing_task.parameters = parameters
await db_session.commit()
# parameters 已变更,任务视图缓存失效
TaskQueryService.invalidate_task_view(task_id)
return {"status": "success", "data": data, "cam_preferences": cam_preferences}
except Exception as exc:
+5 -3
View File
@@ -1,15 +1,17 @@
# api/v1/debug_router.py
from fastapi import APIRouter
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
router = APIRouter()
@router.get("/debug/tasks")
@router.post("/debug/tasks")
async def debug_tasks():
"""调试接口:查看所有任务"""
async def debug_tasks(current_user: User = Depends(get_current_active_user)):
"""调试接口:查看所有任务(仅限 DEBUG 模式注册,且需登录)"""
all_tasks = await redis_task_manager.get_all_tasks()
return {
"total_tasks": len(all_tasks),
+21 -6
View File
@@ -4,17 +4,27 @@ import urllib.parse
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
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.utils.logger import get_logger
from sqlalchemy.ext.asyncio import AsyncSession
logger = get_logger(__name__)
router = APIRouter()
@router.get("/history")
@router.post("/history")
async def get_file_history(db_session: AsyncSession = Depends(get_db_session)):
"""获取按文件名分组的文件历史记录(支持多上传)"""
async def get_file_history(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
"""获取当前用户按文件名分组的文件历史记录(支持多上传)"""
storage_service = StorageIntegrationService()
file_groups = await storage_service.get_all_file_groups(db_session)
file_groups = await storage_service.get_all_file_groups(
db_session, user_id=current_user.id
)
return {
"total_files": len(file_groups),
@@ -24,14 +34,19 @@ async def get_file_history(db_session: AsyncSession = Depends(get_db_session)):
@router.get("/history/{filename}")
@router.post("/history/{filename}")
async def get_file_records(filename: str, db_session: AsyncSession = Depends(get_db_session)):
"""获取指定文件名的所有上传记录(支持多上传历史)"""
async def get_file_records(
filename: str,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
"""获取当前用户指定文件名的所有上传记录(支持多上传历史)"""
decoded_filename = urllib.parse.unquote(filename)
storage_service = StorageIntegrationService()
file_records = await storage_service.get_file_history_by_filename(
db_session,
decoded_filename
decoded_filename,
user_id=current_user.id,
)
return file_records
+2 -18
View File
@@ -2,11 +2,11 @@
from fastapi import APIRouter, UploadFile, File, HTTPException, Depends, Form
import uuid
from datetime import datetime
from pathlib import Path
from shared.models.schemas import ProcessingStatus, create_task_info
from shared.utils.file_handler import FileHandler
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
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
@@ -14,13 +14,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from shared.services.auth_service import get_current_active_user
from shared.models.database import User
try:
from celery_tasks import process_stp_task
_use_celery = True
except ImportError:
process_stp_task = None
_use_celery = False
logger = get_logger(__name__)
router = APIRouter()
@@ -96,16 +89,7 @@ async def upload_stp(
task_info["file_hash"] = file_meta["sha256"]
await redis_task_manager.set_task(task_id, task_info)
if _use_celery:
process_stp_task.delay(task_id, str(file_path), stp_file.id, process_params)
logger.info(f"[UPLOAD] Celery 任务已调度: task_id={task_id}")
else:
import asyncio
from moldinsight.services.processing_service import processing_service
asyncio.create_task(processing_service.process_file_with_storage(
task_id, str(file_path), stp_file.id, process_params
))
logger.info(f"[UPLOAD] 直接后台处理: task_id={task_id} (celery 未安装)")
dispatch_processing(task_id, str(file_path), stp_file.id, process_params)
return {
"task_id": task_id,
+140 -8
View File
@@ -38,6 +38,26 @@ logger = get_logger(__name__)
class CADExporter:
"""CAD 文件导出器"""
# 组件 -> (cavity_data 取值键, 显示名)
_SHAPE_MAP = {
"cavity": ("cavity", "型腔"),
"core": ("core", "型芯"),
"parting_surface": ("parting_surface", "分型面"),
"product": ("product", "产品本体"),
"a_plate": ("a_plate", "A板(上模)"),
"b_plate": ("b_plate", "B板(下模)"),
}
_COMPONENT_LABELS = {
"cavity": "型腔",
"core": "型芯",
"parting_surface": "分型面",
"product": "产品本体",
"a_plate": "A板(上模)",
"b_plate": "B板(下模)",
"assembly": "模具装配体",
}
def __init__(self, output_dir: str = "./exports"):
self.output_dir = output_dir
os.makedirs(output_dir, exist_ok=True)
@@ -71,6 +91,125 @@ class CADExporter:
relative = Path(os.path.basename(filepath))
return relative.as_posix()
def build_file_entry(self, component: str, fmt: str, filepath: str,
label: Optional[str] = None) -> Dict[str, Any]:
"""构造统一格式的导出文件条目(供导出/重建流程复用)。"""
file_size = os.path.getsize(filepath) if os.path.exists(filepath) else 0
return {
"component": component,
"component_label": label or self._COMPONENT_LABELS.get(component, component),
"format": fmt,
"filepath": filepath,
"relative_path": self.get_relative_path(filepath),
"filename": os.path.basename(filepath),
"size_bytes": file_size,
"size_readable": self._format_file_size(file_size),
}
def export_persisted_steps(
self,
cavity_data: Dict,
base_filename: str,
components: Optional[List[str]] = None,
task_id: Optional[str] = None,
scheme_id: Optional[str] = None,
) -> Dict[str, Any]:
"""持久化方案几何为 STEP:装配体单文件 + 逐组件单文件。
逐组件 STEP 是服务重启后按需重导出其他格式(STL/IGES/BRep)的
几何来源:读回单组件 STEP 即可现场转换,用户无需重新分析。
"""
if components is None:
components = list(self._SHAPE_MAP.keys())
export_dir = self.build_export_dir(
base_filename=base_filename,
task_id=task_id,
scheme_id=scheme_id,
)
os.makedirs(export_dir, exist_ok=True)
results = {"files": [], "errors": []}
shapes_with_names: List[Tuple[TopoDS_Shape, str]] = []
for comp in components:
data_key, label = self._SHAPE_MAP.get(comp, (comp, comp))
shape = cavity_data.get(data_key)
if shape is None:
results["errors"].append(f"{label}形状不可用")
continue
shapes_with_names.append((shape, label))
# 逐组件单文件 STEP(重启后转换其他格式的几何来源)
filepath = os.path.join(export_dir, f"{base_filename}_{comp}.step")
if self.export_step(shape, filepath):
results["files"].append(
self.build_file_entry(comp, "step", filepath, label)
)
# 装配体 STEP(所有组件写入同一文件)
if shapes_with_names:
filepath = os.path.join(export_dir, f"{base_filename}_mold.step")
if self.export_assembly_step(shapes_with_names, filepath):
results["files"].append(
self.build_file_entry("assembly", "step", filepath)
)
results["total_files"] = len(results["files"])
results["total_errors"] = len(results["errors"])
logger.info(
f"方案 STEP 持久化完成: {results['total_files']} 个文件, "
f"{results['total_errors']} 个错误"
)
return results
def convert_component_step(self, step_path: str, output_path: str, fmt: str) -> bool:
"""读回单组件 STEP,转换导出为其他格式(stl/iges/brep/step)。
用于内存 shape 缓存失效后的按需重导出。
"""
shape = self._read_step_shape(step_path)
if shape is None:
return False
if fmt == "stl":
return self.export_stl(shape, output_path)
if fmt == "iges":
return self.export_iges(shape, output_path)
if fmt == "brep":
return self.export_brep(shape, output_path)
if fmt == "step":
import shutil
try:
shutil.copyfile(step_path, output_path)
return os.path.exists(output_path) and os.path.getsize(output_path) > 0
except Exception as e:
logger.error(f"STEP 复制失败: {e}")
return False
logger.error(f"不支持的转换格式: {fmt}")
return False
def _read_step_shape(self, filepath: str) -> Optional[TopoDS_Shape]:
"""读回 STEP 文件为 OCC 形状"""
try:
from OCC.Core.STEPControl import STEPControl_Reader
from OCC.Core.IFSelect import IFSelect_RetDone
if not os.path.exists(filepath):
logger.error(f"STEP 文件不存在: {filepath}")
return None
reader = STEPControl_Reader()
if reader.ReadFile(filepath) != IFSelect_RetDone:
logger.error(f"STEP 读回失败(状态非 Done): {filepath}")
return None
reader.TransferRoots()
return reader.OneShape()
except Exception as e:
logger.error(f"STEP 读回失败: {filepath}: {e}")
return None
def export_step(self, shape: TopoDS_Shape, filepath: str,
schema: str = "AP214") -> bool:
"""
@@ -262,14 +401,7 @@ class CADExporter:
"errors": [],
}
shape_map = {
"cavity": ("cavity", "型腔"),
"core": ("core", "型芯"),
"parting_surface": ("parting_surface", "分型面"),
"product": ("product", "产品本体"),
"a_plate": ("a_plate", "A板(上模)"),
"b_plate": ("b_plate", "B板(下模)"),
}
shape_map = self._SHAPE_MAP
shapes_to_export: List[Tuple[str, str, TopoDS_Shape]] = []
assembly_shapes: List[Tuple[TopoDS_Shape, str]] = []
+23 -5
View File
@@ -524,11 +524,29 @@ class LLMService:
}
if expect_json:
payload["response_format"] = {"type": "json_object"}
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.post(url, json=payload, headers=headers)
resp.raise_for_status()
content = resp.json()["choices"][0]["message"]["content"]
return content.strip() if content else None
# 保持 per-call client:celery 每任务经 asyncio.run 新建事件循环,
# 模块级 AsyncClient 绑定旧循环会失效(与 redis_task_manager 同理)。
# 瞬态错误(网络/5xx/429)重试一次,其余直接抛出。
last_exc: Optional[Exception] = None
for attempt in range(2):
try:
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.post(url, json=payload, headers=headers)
resp.raise_for_status()
content = resp.json()["choices"][0]["message"]["content"]
return content.strip() if content else None
except httpx.TransportError as exc:
last_exc = exc
logger.warning(f"LLM 请求瞬态失败(第 {attempt + 1} 次): {exc}")
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
if status >= 500 or status == 429:
last_exc = exc
logger.warning(f"LLM 服务端错误 {status}(第 {attempt + 1} 次)")
else:
raise
raise last_exc
@staticmethod
def _prioritize_features_for_side_action(
+115 -8
View File
@@ -2,12 +2,14 @@
"""STP 文件处理流程编排器 — 协调解析、网格生成、型腔生成、保存、验证"""
import asyncio
import os
import time
import traceback
from collections import OrderedDict
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, List
from sqlalchemy.ext.asyncio import AsyncSession
@@ -40,12 +42,35 @@ class ProcessingService:
self.storage_service = StorageIntegrationService()
self.multi_scheme_planner = MultiSchemeMoldPlanner()
self.cad_exporter = CADExporter()
self._export_shapes_cache: Dict[str, Dict[str, Dict[str, Any]]] = {}
# TopoDS_Shape 为 C++ 原生内存对象,LRU 上限防止长期运行内存只涨不降
self._export_shapes_cache: "OrderedDict[str, Dict[str, Dict[str, Any]]]" = OrderedDict()
self._export_shapes_cache_max = 32
# OCC 非线程安全,max_workers=1 保证所有 OCC 操作序列化执行,避免偶发崩溃
self._occ_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="occ")
# ─── 对外入口 ───
def _reset_occ_executor(self):
"""超时后重建 OCC executor。
asyncio.wait_for 只能取消协程,正在执行 OCC 布尔运算的线程无法中断;
单 worker executor 中一个挂死线程会让后续任务永久排队直至重启。
代价是泄漏 1 个线程,收益是恢复服务可用性。
"""
old = self._occ_executor
self._occ_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="occ")
old.shutdown(wait=False)
logger.warning("OCC executor 已因处理超时重建(放弃等待旧线程,可能泄漏 1 个线程)")
async def run_occ(self, fn, *args):
"""在 OCC 单线程 executor 中执行同步几何操作。
OCC 非线程安全,所有几何计算(解析/布尔/三角化)统一经由本入口串行执行,
避免各调用方自行创建线程池造成并发崩溃。
"""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(self._occ_executor, fn, *args)
async def process_file_with_storage(
self,
task_id: str,
@@ -79,6 +104,8 @@ class ProcessingService:
)
except asyncio.TimeoutError:
logger.error(f"处理超时: {task_id}")
# OCC 线程无法取消:抛弃整个 executor,避免挂死线程堵死后续所有任务
self._reset_occ_executor()
raise Exception(f"处理超时,超过{timeout_seconds}秒未完成")
except Exception as e:
@@ -338,12 +365,12 @@ class ProcessingService:
},
)
# 更新任务缓存状态(仅保留轻量摘要,完整数据由PG+RustFS持久化)
# 更新任务缓存状态(仅保留轻量摘要,完整数据由PG+RustFS持久化;
# 完成态视图由 TaskQueryService 从 PG+RustFS 组装,Redis 不再存
# geometry_data / analysis_result 等 MB 级大对象)
await redis_task_manager.update_task(task_id, {
"status": ProcessingStatus.COMPLETED,
"completed_at": str(datetime.now()),
"geometry_data": geometry_data,
"analysis_result": analysis_result,
"key_info": best_key_info,
"best_scheme_id": detailed_cavity_json.get("best_scheme_id"),
"material": requested_material,
@@ -471,6 +498,10 @@ class ProcessingService:
def _cache_export_shapes(self, task_id: str, export_shapes: Dict[str, Dict[str, Any]]):
self._export_shapes_cache[task_id] = export_shapes
self._export_shapes_cache.move_to_end(task_id)
# 逐出最旧任务的形状缓存(连原生 OCC shape 引用一起释放)
while len(self._export_shapes_cache) > self._export_shapes_cache_max:
self._export_shapes_cache.popitem(last=False)
def _persist_step_exports(
self,
@@ -492,10 +523,11 @@ class ProcessingService:
for scheme_id, cavity_data in export_shapes.items():
try:
result = self.cad_exporter.export_mold_results(
# 持久化装配体 STEP + 逐组件 STEP(后者是重启后按需
# 重导出其他格式的几何来源,见 regenerate_export_from_persisted)
result = self.cad_exporter.export_persisted_steps(
cavity_data=cavity_data,
base_filename=base_filename,
formats=["step"],
components=components,
task_id=task_id,
scheme_id=scheme_id,
@@ -522,13 +554,88 @@ class ProcessingService:
return manifest
def get_export_shapes(self, task_id: str, scheme_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
scheme_map = self._export_shapes_cache.get(task_id, {})
scheme_map = self._export_shapes_cache.get(task_id)
if not scheme_map:
return None
self._export_shapes_cache.move_to_end(task_id) # LRU 热点保活
if scheme_id:
return scheme_map.get(scheme_id)
return next(iter(scheme_map.values()), None)
async def regenerate_export_from_persisted(
self,
task_id: str,
scheme_id: str,
formats: Optional[List[str]],
components: List[str],
base_filename: str,
scheme_files: List[Dict[str, Any]],
) -> Optional[Dict[str, Any]]:
"""内存导出缓存失效(如服务重启)后,从持久化 STEP 重建导出文件。
分析期已为每个方案持久化装配体 + 逐组件 STEP;
缺失格式(STL/IGES/BRep)读回单组件 STEP 现场转换,
用户无需重新分析。所有组件均不可用时返回 None。
"""
format_list = list(dict.fromkeys(formats or ["step", "stl"]))
step_files = {
f.get("component"): f
for f in scheme_files or []
if f.get("format") == "step" and f.get("component")
}
if not step_files:
return None
files: List[Dict[str, Any]] = []
errors: List[str] = []
if "step" in format_list:
assembly = step_files.get("assembly")
if assembly:
files.append(assembly)
else:
errors.append("模具装配体 (step) 不可用")
for comp in components:
comp_file = step_files.get(comp)
if comp_file is None:
errors.append(f"组件 {comp} 的持久化 STEP 不可用")
continue
for fmt in format_list:
if fmt == "step":
files.append(comp_file)
continue
step_path = os.path.join(
self.cad_exporter.output_dir,
str(comp_file.get("relative_path") or "").replace("/", os.sep),
)
out_path = os.path.join(
os.path.dirname(step_path), f"{base_filename}_{comp}.{fmt}"
)
ok = await self.run_occ(
self.cad_exporter.convert_component_step, step_path, out_path, fmt
)
if ok:
files.append(
self.cad_exporter.build_file_entry(comp, fmt, out_path)
)
else:
errors.append(f"组件 {comp} ({fmt}) 转换失败")
if not files:
return None
return {
"base_filename": base_filename,
"task_id": task_id,
"scheme_id": scheme_id,
"files": files,
"errors": errors,
"total_files": len(files),
"total_errors": len(errors),
"source": "regenerated",
}
@staticmethod
def _normalize_process_params(process_params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
payload = dict(process_params or {})
+88
View File
@@ -0,0 +1,88 @@
# services/shape_loader.py
"""按 task_id 从持久化存储重建 OCC 几何形状。
任务完成后 TopoDS_Shape 不驻留内存/Redis(原生内存与体积原因),
需要几何的端点(倒扣检测、按需重导出等)通过 STP 原件重建:
PG(object_key) -> RustFS 下载 -> 临时文件 -> OCC 单线程 executor 解析。
"""
import tempfile
from pathlib import Path
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.database import ProcessingTask, STPFile
from shared.utils.logger import get_logger
logger = get_logger(__name__)
class ShapeLoader:
"""任务几何重建器"""
def __init__(self):
from moldinsight.core.stp_parser import STPParser
from moldinsight.services.processing_service import processing_service
self._parser = STPParser()
self._processing = processing_service
async def load_shape_for_task(
self, db_session: AsyncSession, task_id: str
) -> Optional["object"]:
"""重建任务的产品几何。任务不存在或 STP 原件不可用时返回 None。"""
result = await db_session.execute(
select(ProcessingTask, STPFile)
.join(STPFile, ProcessingTask.stp_file_id == STPFile.id)
.where(ProcessingTask.task_id == task_id)
)
row = result.first()
if not row:
logger.warning(f"几何重建失败:任务不存在 {task_id}")
return None
_, stp_file = row
if not stp_file.object_key:
logger.warning(f"几何重建失败:任务缺少 object_key {task_id}")
return None
from moldinsight.storage.rustfs_storage import rustfs_manager
try:
data = await rustfs_manager.download_file(
file_type="stp_files", object_key=stp_file.object_key
)
except Exception as exc:
logger.error(f"几何重建失败:STP 原件下载失败 {task_id}: {exc}")
return None
with tempfile.NamedTemporaryFile(suffix=".stp", delete=False) as tmp:
tmp.write(data)
tmp_path = Path(tmp.name)
try:
shape = await self._processing.run_occ(
self._parser.load_step_file, tmp_path
)
return shape
except Exception as exc:
logger.error(f"几何重建失败:STP 解析失败 {task_id}: {exc}")
return None
finally:
try:
tmp_path.unlink(missing_ok=True)
except Exception:
pass
# 惰性单例:__init__ 会实例化 STPParser 并校验 OCC 可用性,
# 延迟到首次真实使用,避免模块导入期失败拖垮路由加载
_shape_loader: Optional[ShapeLoader] = None
def get_shape_loader() -> ShapeLoader:
global _shape_loader
if _shape_loader is None:
_shape_loader = ShapeLoader()
return _shape_loader
@@ -1,376 +0,0 @@
# services/storage_integration.py
"""存储集成服务 - 协调 PostgreSQL 和 MinIO"""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pathlib import Path
from typing import Optional, Dict, Any
import json
from shared.models.database import (
STPFile, GeometryData, MoldCavityData,
HTMLFile, ProcessingTask, User,
FeatureDetection, DesignRecommendation,
UserActivity, SystemLog
)
from moldinsight.storage.object_storage import storage_manager
from shared.utils.logger import get_logger
logger = get_logger(__name__)
class StorageIntegrationService:
"""存储集成服务"""
async def save_stp_file(self, session: AsyncSession,
file_path: Path,
original_filename: str,
user_id: Optional[int] = None) -> STPFile:
"""保存STP文件到PostgreSQL元数据 + MinIO对象存储"""
# 1. 上传到MinIO
upload_result = await storage_manager.upload_stp_file(
file_path,
original_filename
)
# 2. 创建PostgreSQL记录
stp_file = STPFile(
user_id=user_id,
object_key=upload_result['object_key'],
storage_bucket=storage_manager.buckets['stp_files'],
original_filename=original_filename,
file_size=upload_result['file_size'],
file_hash=upload_result['file_hash'],
status="uploaded",
file_path=str(file_path) # 保留本地路径以兼容
)
session.add(stp_file)
await session.commit()
await session.refresh(stp_file)
logger.info(f"STP文件保存成功: {stp_file.id}")
return stp_file
async def save_geometry_data(self, session: AsyncSession,
stp_file_id: int,
geometry_json: Dict[str, Any],
analysis_method: str = "pythonocc") -> GeometryData:
"""保存几何数据到PostgreSQL元数据 + MinIO对象存储"""
# 1. 获取文件哈希
stp_file = await session.get(STPFile, stp_file_id)
file_hash = stp_file.file_hash
# 2. 上传到MinIO
upload_result = await storage_manager.upload_geometry_data(
geometry_json,
file_hash
)
# 3. 创建PostgreSQL记录
geometry_data = GeometryData(
stp_file_id=stp_file_id,
object_key=upload_result['object_key'],
storage_bucket=storage_manager.buckets['geometry_data'],
analysis_method=analysis_method,
# 提取摘要字段
volume=geometry_json.get('geometry_data', {}).get('volume'),
surface_area=geometry_json.get('geometry_data', {}).get('surface_area'),
bounding_box_min=geometry_json.get('geometry_data', {}).get('bounding_box', {}).get('min'),
bounding_box_max=geometry_json.get('geometry_data', {}).get('bounding_box', {}).get('max'),
center_of_mass=geometry_json.get('geometry_data', {}).get('center_of_mass'),
topology_faces=geometry_json.get('geometry_data', {}).get('topology', {}).get('faces'),
topology_edges=geometry_json.get('geometry_data', {}).get('topology', {}).get('edges'),
topology_vertices=geometry_json.get('geometry_data', {}).get('topology', {}).get('vertices')
)
session.add(geometry_data)
await session.commit()
await session.refresh(geometry_data)
logger.info(f"几何数据保存成功: {geometry_data.id}")
return geometry_data
async def save_mold_cavity_data(self, session: AsyncSession,
stp_file_id: int,
cavity_json: Dict[str, Any]) -> MoldCavityData:
"""保存模具型腔数据到PostgreSQL元数据 + MinIO对象存储"""
# 1. 获取文件哈希
stp_file = await session.get(STPFile, stp_file_id)
file_hash = stp_file.file_hash
# 2. 上传到MinIO
upload_result = await storage_manager.upload_mold_cavity_data(
cavity_json,
file_hash
)
# 3. 提取关键信息
metadata = cavity_json.get('metadata', {})
product_analysis = cavity_json.get('product_analysis', {})
manufacturing_info = cavity_json.get('manufacturing_info', {})
mold_size = manufacturing_info.get('estimated_mold_size', {})
key_info = cavity_json.get('mold_cavities', {}).get('cavity_key_info', {})
# 4. 创建PostgreSQL记录
mold_cavity = MoldCavityData(
stp_file_id=stp_file_id,
detailed_object_key=upload_result['object_key'],
storage_bucket=storage_manager.buckets['mold_cavities'],
# 模具参数
mold_material=manufacturing_info.get('recommended_material', 'Aluminum Alloy 7075'),
shrinkage_rate=metadata.get('shrinkage_rate', 0.005),
draft_angle=metadata.get('draft_angle', 2.0),
# 提取的摘要字段
cavity_key_info=key_info,
mold_size_length=mold_size.get('length'),
mold_size_width=mold_size.get('width'),
mold_size_height=mold_size.get('height'),
estimated_clamping_force=manufacturing_info.get('estimated_clamping_force'),
product_volume=product_analysis.get('volume'),
# 从key_info中提取(如果存在)
product_weight=key_info.get('geometric_characteristics', {}).get('product_weight'),
wall_thickness_range=key_info.get('geometric_characteristics', {}).get('wall_thickness_range'),
complexity_score=key_info.get('geometric_characteristics', {}).get('complexity_score'),
# 质量评估
weld_line_risk=key_info.get('quality_considerations', {}).get('potential_weld_lines'),
sink_mark_risk=key_info.get('quality_considerations', {}).get('sink_mark_areas'),
warpage_risk=key_info.get('quality_considerations', {}).get('warpage_risk')
)
session.add(mold_cavity)
await session.commit()
await session.refresh(mold_cavity)
logger.info(f"模具型腔数据保存成功: {mold_cavity.id}")
return mold_cavity
async def save_html_file(self, session: AsyncSession,
stp_file_id: int,
html_content: str,
filename: str) -> HTMLFile:
"""保存HTML文件到PostgreSQL元数据 + MinIO对象存储"""
# 1. 获取文件哈希
stp_file = await session.get(STPFile, stp_file_id)
file_hash = stp_file.file_hash
# 2. 上传到MinIO
upload_result = await storage_manager.upload_html_file(
html_content,
filename,
file_hash
)
# 3. 创建PostgreSQL记录
html_file = HTMLFile(
stp_file_id=stp_file_id,
object_key=upload_result['object_key'],
storage_bucket=storage_manager.buckets['html_files'],
filename=filename,
file_path=str(Path('html_output') / filename), # 保留本地路径
html_content=html_content # 保留内容以兼容
)
session.add(html_file)
await session.commit()
await session.refresh(html_file)
logger.info(f"HTML文件保存成功: {html_file.id}")
return html_file
async def save_features_and_recommendations(
self, session: AsyncSession,
stp_file_id: int,
features: list,
recommendations: list
):
"""保存特征检测结果和设计建议"""
# 1. 保存特征
for feature in features:
feature_record = FeatureDetection(
stp_file_id=stp_file_id,
feature_type=feature.get('feature_type'),
confidence=feature.get('confidence'),
location=feature.get('location'),
dimensions=feature.get('dimensions'),
parameters=feature.get('parameters')
)
session.add(feature_record)
# 2. 保存建议
for rec in recommendations:
rec_record = DesignRecommendation(
stp_file_id=stp_file_id,
rec_type=rec.get('rec_type'),
priority=rec.get('priority'),
description=rec.get('description'),
reason=rec.get('reason'),
parameters=rec.get('parameters')
)
session.add(rec_record)
await session.commit()
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,
metadata=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文件及其所有关联数据"""
# 1. 获取STP文件记录
stp_file = await session.get(STPFile, stp_file_id)
if not stp_file:
raise ValueError(f"STP文件不存在: {stp_file_id}")
result = {
'metadata': {
'id': stp_file.id,
'original_filename': stp_file.original_filename,
'file_size': stp_file.file_size,
'file_hash': stp_file.file_hash,
'upload_time': stp_file.upload_time.isoformat() if stp_file.upload_time else None,
'status': stp_file.status,
'user_id': stp_file.user_id
},
'geometry_data': None,
'mold_cavity_data': None,
'html_file': None,
'features': [],
'recommendations': []
}
# 2. 从MinIO获取数据
try:
# 几何数据
if stp_file.geometry_data:
geo_data_bytes = await storage_manager.download_file(
'geometry_data',
stp_file.geometry_data.object_key
)
result['geometry_data'] = json.loads(geo_data_bytes.decode('utf-8'))
# 模具型腔数据
if stp_file.mold_cavity_data:
cavity_data_bytes = await storage_manager.download_file(
'mold_cavities',
stp_file.mold_cavity_data.detailed_object_key
)
result['mold_cavity_data'] = json.loads(cavity_data_bytes.decode('utf-8'))
# HTML文件
if stp_file.html_file:
html_bytes = await storage_manager.download_file(
'html_files',
stp_file.html_file.object_key
)
result['html_content'] = html_bytes.decode('utf-8')
except Exception as e:
logger.error(f"从MinIO获取数据失败: {e}")
# 3. 从PostgreSQL获取特征和建议
features = await session.execute(
select(FeatureDetection).where(FeatureDetection.stp_file_id == stp_file_id)
)
result['features'] = [
{
'feature_type': f.feature_type,
'confidence': f.confidence,
'location': f.location,
'dimensions': f.dimensions,
'parameters': f.parameters
}
for f in features.scalars().all()
]
recommendations = await session.execute(
select(DesignRecommendation).where(DesignRecommendation.stp_file_id == stp_file_id)
)
result['recommendations'] = [
{
'rec_type': r.rec_type,
'priority': r.priority,
'description': r.description,
'reason': r.reason,
'parameters': r.parameters
}
for r in recommendations.scalars().all()
]
return result
async def delete_stp_file_cascade(self, session: AsyncSession,
stp_file_id: int):
"""级联删除STP文件及其所有关联数据"""
stp_file = await session.get(STPFile, stp_file_id)
if not stp_file:
raise ValueError(f"STP文件不存在: {stp_file_id}")
# 1. 删除MinIO中的文件
try:
if stp_file.object_key:
await storage_manager.delete_file('stp_files', stp_file.object_key)
except Exception as e:
logger.error(f"删除MinIO文件失败: {e}")
try:
if stp_file.geometry_data:
await storage_manager.delete_file('geometry_data', stp_file.geometry_data.object_key)
except Exception as e:
logger.error(f"删除几何数据失败: {e}")
try:
if stp_file.mold_cavity_data:
await storage_manager.delete_file('mold_cavities', stp_file.mold_cavity_data.detailed_object_key)
except Exception as e:
logger.error(f"删除型腔数据失败: {e}")
try:
if stp_file.html_file:
await storage_manager.delete_file('html_files', stp_file.html_file.object_key)
except Exception as e:
logger.error(f"删除HTML文件失败: {e}")
# 2. 级联删除PostgreSQL记录(通过外键自动处理)
await session.delete(stp_file)
await session.commit()
logger.info(f"STP文件及其关联数据已删除: {stp_file_id}")
# 全局存储集成服务实例
storage_integration = StorageIntegrationService()
@@ -459,7 +459,9 @@ class StorageIntegrationService:
storage_bucket=upload_result['bucket'],
filename=filename,
file_path=file_path, # 保留本地路径
html_content=html_content, # 保留内容以兼容
# 停止双写完整 HTML 进 PG:读取路径走 RustFS(html_json.content),
# PG 仅存对象键与文件名,避免大文本撑爆表
html_content=None,
visualization_type=visualization_type
)
@@ -738,20 +740,24 @@ class StorageIntegrationService:
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:
count_query = select(func.count()).where(
STPFile.original_filename == f.original_filename
)
if user_id:
count_query = count_query.where(STPFile.user_id == user_id)
count_result = await session.execute(count_query)
upload_count = count_result.scalar()
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,
-296
View File
@@ -1,296 +0,0 @@
# services/storage_service.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from datetime import datetime
import hashlib
import json
from pathlib import Path
from typing import Optional, Dict, Any
from shared.models.database import STPFile, GeometryData, HTMLFile, ProcessingTask
from shared.utils.logger import get_logger
from shared.models.database import MoldCavityData
logger = get_logger(__name__)
class StorageService:
"""数据存储服务"""
def __init__(self, db_session: AsyncSession):
self.db_session = db_session
async def save_stp_file(
self,
filename: str,
file_path: str,
file_size: int,
file_content: Optional[bytes] = None
) -> STPFile:
"""保存STP文件信息到数据库"""
try:
# 计算文件哈希
file_hash = self._calculate_file_hash(file_path, file_content)
# 检查是否已存在相同文件
existing_file = await self.db_session.execute(
select(STPFile).where(STPFile.file_hash == file_hash)
)
existing_file = existing_file.scalar_one_or_none()
if existing_file:
logger.info(f"文件已存在,跳过保存: {filename}")
return existing_file
# 创建新的STP文件记录
stp_file = STPFile(
filename=filename,
original_filename=filename,
file_path=file_path,
file_size=file_size,
file_hash=file_hash,
file_content=file_content,
upload_time=datetime.now(),
status="pending",
# 必填字段提供默认值
object_key=f"stp_files/{file_hash}",
storage_bucket="default",
object_url=None
)
self.db_session.add(stp_file)
await self.db_session.commit()
await self.db_session.refresh(stp_file)
logger.info(f"STP文件保存成功: {filename} (ID: {stp_file.id})")
return stp_file
except Exception as e:
await self.db_session.rollback()
logger.error(f"保存STP文件失败: {e}")
raise
async def save_geometry_data(
self,
stp_file_id: int,
geometry_json: Dict[str, Any],
analysis_method: str
) -> GeometryData:
"""保存几何数据JSON到数据库"""
try:
# 提取关键几何属性用于快速查询
volume = geometry_json.get("volume")
surface_area = geometry_json.get("surface_area")
bounding_box = geometry_json.get("bounding_box", {})
geometry_data = GeometryData(
stp_file_id=stp_file_id,
analysis_method=analysis_method,
volume=volume,
surface_area=surface_area,
bounding_box_min=bounding_box.get("min"),
bounding_box_max=bounding_box.get("max"),
created_time=datetime.now(),
# 必填字段提供默认值
object_key=f"geometry_data/{stp_file_id}",
storage_bucket="default",
object_url=None
)
self.db_session.add(geometry_data)
await self.db_session.commit()
await self.db_session.refresh(geometry_data)
logger.info(f"几何数据保存成功: STP文件ID {stp_file_id}")
return geometry_data
except Exception as e:
await self.db_session.rollback()
logger.error(f"保存几何数据失败: {e}")
raise
async def save_html_file(
self,
stp_file_id: int,
filename: str,
file_path: str,
html_content: Optional[str] = None,
visualization_type: str = "3d_viewer"
) -> HTMLFile:
"""保存HTML文件信息到数据库"""
try:
html_file = HTMLFile(
stp_file_id=stp_file_id,
filename=filename,
file_path=file_path,
html_content=html_content,
visualization_type=visualization_type,
has_interactive_elements=True,
generated_time=datetime.now(),
# 必填字段提供默认值
object_key=f"html_files/{stp_file_id}",
storage_bucket="default",
object_url=None
)
self.db_session.add(html_file)
await self.db_session.commit()
await self.db_session.refresh(html_file)
logger.info(f"HTML文件保存成功: {filename} (STP文件ID: {stp_file_id})")
return html_file
except Exception as e:
await self.db_session.rollback()
logger.error(f"保存HTML文件失败: {e}")
raise
async def create_processing_task(
self,
task_id: str,
stp_file_id: int,
task_type: str = "stp_parsing"
) -> ProcessingTask:
"""创建处理任务记录"""
try:
task = ProcessingTask(
task_id=task_id,
stp_file_id=stp_file_id,
task_type=task_type,
status="pending",
started_time=datetime.now()
)
self.db_session.add(task)
await self.db_session.commit()
await self.db_session.refresh(task)
logger.info(f"处理任务创建成功: {task_id}")
return task
except Exception as e:
await self.db_session.rollback()
logger.error(f"创建处理任务失败: {e}")
raise
async def update_task_status(
self,
task_id: str,
status: str,
progress: Optional[int] = None,
current_step: Optional[str] = None,
error_message: Optional[str] = None
):
"""更新任务状态"""
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 self.db_session.execute(
update(ProcessingTask)
.where(ProcessingTask.task_id == task_id)
.values(**update_data)
)
await self.db_session.commit()
logger.info(f"任务状态更新: {task_id} -> {status}")
except Exception as e:
await self.db_session.rollback()
logger.error(f"更新任务状态失败: {e}")
raise
async def update_stp_file_status(self, stp_file_id: int, status: str):
"""更新STP文件状态"""
try:
await self.db_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 self.db_session.commit()
logger.info(f"STP文件状态更新: ID {stp_file_id} -> {status}")
except Exception as e:
await self.db_session.rollback()
logger.error(f"更新STP文件状态失败: {e}")
raise
async def get_stp_file_by_id(self, stp_file_id: int) -> Optional[STPFile]:
"""根据ID获取STP文件"""
try:
result = await self.db_session.execute(
select(STPFile).where(STPFile.id == stp_file_id)
)
return result.scalar_one_or_none()
except Exception as e:
logger.error(f"获取STP文件失败: {e}")
return None
async def get_geometry_data_by_stp_file_id(self, stp_file_id: int) -> Optional[GeometryData]:
"""根据STP文件ID获取几何数据"""
try:
result = await self.db_session.execute(
select(GeometryData).where(GeometryData.stp_file_id == stp_file_id)
)
return result.scalar_one_or_none()
except Exception as e:
logger.error(f"获取几何数据失败: {e}")
return None
def _calculate_file_hash(self, file_path: str, file_content: Optional[bytes] = None) -> str:
"""计算文件哈希值"""
sha256_hash = hashlib.sha256()
if file_content:
sha256_hash.update(file_content)
else:
# 从文件路径读取内容计算哈希
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
sha256_hash.update(chunk)
return sha256_hash.hexdigest()
async def save_mold_cavity_data(
self,
stp_file_id: int,
cavity_json: Dict[str, Any],
key_info: Dict[str, Any]
) -> MoldCavityData:
"""保存模具型腔数据"""
try:
mold_data = MoldCavityData(
stp_file_id=stp_file_id,
cavity_key_info=key_info,
shrinkage_rate=cavity_json["metadata"]["shrinkage_rate"],
draft_angle=cavity_json["metadata"]["draft_angle"],
generated_time=datetime.now(),
# 必填字段提供默认值
detailed_object_key=f"mold_cavity/{stp_file_id}",
storage_bucket="default"
)
self.db_session.add(mold_data)
await self.db_session.commit()
await self.db_session.refresh(mold_data)
logger.info(f"模具型腔数据保存成功: STP文件ID {stp_file_id}")
return mold_data
except Exception as e:
await self.db_session.rollback()
logger.error(f"保存模具型腔数据失败: {e}")
raise
@@ -0,0 +1,51 @@
# services/task_dispatcher.py
"""后台处理任务分派器 - 统一 upload/batch 路由的 Celery/asyncio 分派逻辑。
修复两个问题:
1. fire-and-forget:asyncio.create_task 返回值未持有引用,任务可能被 GC 中途回收,
异常也无从浮现(python 官方文档明确警告的模式);
2. 复制粘贴:upload_router 与 batch_router 各自维护一份相同的分派代码,易漂移。
"""
import asyncio
from shared.utils.logger import get_logger
logger = get_logger(__name__)
try:
from celery_tasks import process_stp_task
_use_celery = True
except ImportError:
process_stp_task = None
_use_celery = False
# 持有后台任务强引用,防止被 GC 回收;完成后自动移出
_background_tasks: set = set()
# API 进程内并发处理上限(celery 路径由 worker 并发数控制,不走这里)。
# asyncio.Semaphore 自 3.10 起惰性绑定事件循环,模块级创建安全;
# 本模块仅在 API 进程(单一事件循环)导入使用。
_dispatch_semaphore = asyncio.Semaphore(2)
async def _run_with_limit(task_id: str, file_path: str, stp_file_id: int, process_params: dict):
async with _dispatch_semaphore:
from moldinsight.services.processing_service import processing_service
await processing_service.process_file_with_storage(
task_id, file_path, stp_file_id, process_params
)
def dispatch_processing(task_id: str, file_path: str, stp_file_id: int, process_params: dict):
"""调度 STP 处理任务:优先 Celery(进程隔离),否则 API 进程内 asyncio 后台执行。"""
if _use_celery:
process_stp_task.delay(task_id, file_path, stp_file_id, process_params)
logger.info(f"[DISPATCH] Celery 任务已调度: task_id={task_id}")
return
task = asyncio.create_task(
_run_with_limit(task_id, file_path, stp_file_id, process_params)
)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
logger.info(f"[DISPATCH] 进程内后台处理: task_id={task_id} (celery 未安装)")
+54 -13
View File
@@ -1,7 +1,9 @@
# services/task_query_service.py
"""任务状态查询服务 — 从 task_router.py 中的持久化任务组装逻辑抽取"""
"""任务状态查询服务 - 从 task_router.py 中的持久化任务组装逻辑抽取"""
from typing import Optional, Dict, Any, List
import time
from collections import OrderedDict
from typing import Optional, Dict, Any, List, Tuple
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,12 +18,47 @@ logger = get_logger(__name__)
class TaskQueryService:
"""任务状态查询与视图组装"""
"""任务状态查询与视图组装
完成态任务的 PG+RustFS 组装成本高(全量下载 geometry/型腔/网格/HTML JSON),
而状态轮询高频触发。对 completed/failed 视图加进程内 LRU+TTL 缓存:
- processing 视图不缓存(数据持续变化,且通常由 Redis 直接提供);
- completed/failed 视图不可变(仅 parameters 会被 export/cam 端点更新,
更新方负责调用 invalidate_task_view 显式失效)。
"""
_VIEW_CACHE_TTL_SECONDS = 60.0
_VIEW_CACHE_MAX_ENTRIES = 16 # 视图为 MB 级 dict,上限控制内存占用
_view_cache: "OrderedDict[str, Tuple[float, Dict[str, Any]]]" = OrderedDict()
@classmethod
def _cache_get(cls, task_id: str) -> Optional[Dict[str, Any]]:
entry = cls._view_cache.get(task_id)
if entry is None:
return None
cached_at, view = entry
if time.monotonic() - cached_at > cls._VIEW_CACHE_TTL_SECONDS:
cls._view_cache.pop(task_id, None)
return None
cls._view_cache.move_to_end(task_id)
return view
@classmethod
def _cache_set(cls, task_id: str, view: Dict[str, Any]):
cls._view_cache[task_id] = (time.monotonic(), view)
cls._view_cache.move_to_end(task_id)
while len(cls._view_cache) > cls._VIEW_CACHE_MAX_ENTRIES:
cls._view_cache.popitem(last=False)
@classmethod
def invalidate_task_view(cls, task_id: str):
"""任务 parameters 被更新后调用(export-mold / cam 等),使缓存视图失效。"""
cls._view_cache.pop(task_id, None)
@staticmethod
async def get_task_view(db_session: AsyncSession, task_id: str) -> Optional[Dict[str, Any]]:
"""
获取任务视图 — 优先返回 Redis 缓存,否则从 PostgreSQL + RustFS 组装
获取任务视图 - 优先返回 Redis 缓存,否则从 PostgreSQL + RustFS 组装
Returns:
任务视图字典,如果任务不存在返回 None
@@ -34,7 +71,13 @@ class TaskQueryService:
logger.info(f"返回缓存任务状态:{task_id} - {status}")
return task
# 2. 持久化任务(已完成/失败,或服务重启后的任务)
# 2. 完成态视图缓存(命中则免 RustFS 全量下载)
cached = TaskQueryService._cache_get(task_id)
if cached is not None:
logger.info(f"返回缓存任务视图: {task_id}")
return cached
# 3. 持久化任务(已完成/失败,或服务重启后的任务)
storage_service = StorageIntegrationService()
# 查询任务和文件元数据(预加载 html_file 关联)
@@ -70,15 +113,9 @@ class TaskQueryService:
mesh_summary = await TaskQueryService._get_mesh_summary(db_session, stp_file.id)
# 构造 html_file 路径(与即时分析的 /html/xxx.html 格式保持一致)
# joinedload 已预加载 stp_file.html_file,无需重复单独查询
html_file_url = None
html_file_record = None
try:
html_file_record = await db_session.execute(
select(HTMLFile).where(HTMLFile.stp_file_id == stp_file.id)
)
html_file_record = html_file_record.scalar_one_or_none()
except Exception:
pass
html_file_record = stp_file.html_file
if html_file_record and html_file_record.filename:
html_file_url = f"/html/{html_file_record.filename}"
if cavity_view.get("html_file"):
@@ -133,6 +170,10 @@ class TaskQueryService:
"error": processing_task.error_message or stp_file.error_message or None,
}
# 仅缓存不可变的终态视图(processing 视图持续变化不缓存)
if processing_task.status in ("completed", "failed"):
TaskQueryService._cache_set(task_id, task_view)
logger.info(f"返回持久化任务状态: {task_id} - {processing_task.status}")
return task_view
-361
View File
@@ -1,361 +0,0 @@
# storage/object_storage.py
"""MinIO/S3 对象存储服务"""
from minio import Minio
from minio.error import S3Error
from pathlib import Path
from typing import Optional, BinaryIO
from io import BytesIO
from shared.utils.logger import get_logger
import hashlib
import uuid
logger = get_logger(__name__)
class ObjectStorageManager:
"""对象存储管理器 - MinIO/S3兼容"""
def __init__(self):
self.client: Optional[Minio] = None
self.is_connected = False
# 桶名称
self.buckets = {
'stp_files': 'moldinsight-stp-files', # STP/STEP文件
'geometry_data': 'moldinsight-geometry', # 几何数据JSON
'mold_cavities': 'moldinsight-mold-cavities', # 模具型腔数据
'html_files': 'moldinsight-html', # HTML报告文件
'user_files': 'moldinsight-user-files' # 用户上传的其他文件
}
async def connect(self, endpoint: str, access_key: str, secret_key: str,
secure: bool = False):
"""连接到MinIO/S3服务"""
try:
self.client = Minio(
endpoint,
access_key=access_key,
secret_key=secret_key,
secure=secure
)
# 测试连接
self.client.list_buckets()
self.is_connected = True
logger.info(f"对象存储连接成功: {endpoint}")
# 确保所有桶都存在
await self._ensure_buckets()
except S3Error as e:
logger.error(f"对象存储连接失败: {e}")
self.is_connected = False
raise
async def _ensure_buckets(self):
"""确保所有必要的桶都存在"""
for bucket_name in self.buckets.values():
try:
if not self.client.bucket_exists(bucket_name):
self.client.make_bucket(bucket_name)
logger.info(f"创建存储桶: {bucket_name}")
else:
logger.debug(f"存储桶已存在: {bucket_name}")
except S3Error as e:
logger.error(f"创建存储桶失败 {bucket_name}: {e}")
def _generate_object_key(self, original_filename: str, prefix: str = '') -> str:
"""生成对象存储的唯一键名"""
# 提取文件扩展名
ext = Path(original_filename).suffix
# 生成唯一ID
unique_id = str(uuid.uuid4())
# 生成键名: prefix/unique_id + original_ext
if prefix:
return f"{prefix}/{unique_id}{ext}"
return f"{unique_id}{ext}"
async def upload_stp_file(self, file_path: Path,
original_filename: str) -> dict:
"""上传STP文件到对象存储"""
if not self.is_connected:
raise RuntimeError("对象存储未连接")
bucket_name = self.buckets['stp_files']
# 计算文件哈希
file_hash = self._calculate_file_hash(file_path)
# 检查是否已存在
existing_key = await self._find_file_by_hash(bucket_name, file_hash)
if existing_key:
logger.info(f"文件已存在,跳过上传: {existing_key}")
return {
'object_key': existing_key,
'file_hash': file_hash,
'already_exists': True
}
# 生成唯一键名
object_key = self._generate_object_key(
original_filename,
prefix='stp'
)
# 上传文件
try:
result = self.client.fput_object(
bucket_name,
object_key,
str(file_path),
content_type='application/octet-stream'
)
logger.info(f"STP文件上传成功: {object_key}")
return {
'object_key': object_key,
'file_hash': file_hash,
'file_size': result.size,
'etag': result.etag,
'already_exists': False
}
except S3Error as e:
logger.error(f"STP文件上传失败: {e}")
raise
async def upload_geometry_data(self, geometry_json: dict,
file_hash: str) -> dict:
"""上传几何数据JSON到对象存储"""
if not self.is_connected:
raise RuntimeError("对象存储未连接")
bucket_name = self.buckets['geometry_data']
# 使用文件哈希作为键名的一部分
object_key = f"geometry/{file_hash}.json"
# 转换为字节
import json
json_bytes = json.dumps(geometry_json, ensure_ascii=False).encode('utf-8')
# 上传
try:
result = self.client.put_object(
bucket_name,
object_key,
BytesIO(json_bytes),
length=len(json_bytes),
content_type='application/json'
)
logger.info(f"几何数据上传成功: {object_key}")
return {
'object_key': object_key,
'file_size': result.size,
'etag': result.etag
}
except S3Error as e:
logger.error(f"几何数据上传失败: {e}")
raise
async def upload_mold_cavity_data(self, cavity_json: dict,
file_hash: str) -> dict:
"""上传模具型腔数据到对象存储"""
if not self.is_connected:
raise RuntimeError("对象存储未连接")
bucket_name = self.buckets['mold_cavities']
object_key = f"mold-cavity/{file_hash}.json"
import json
json_bytes = json.dumps(cavity_json, ensure_ascii=False).encode('utf-8')
try:
result = self.client.put_object(
bucket_name,
object_key,
BytesIO(json_bytes),
length=len(json_bytes),
content_type='application/json'
)
logger.info(f"模具型腔数据上传成功: {object_key}")
return {
'object_key': object_key,
'file_size': result.size,
'etag': result.etag
}
except S3Error as e:
logger.error(f"模具型腔数据上传失败: {e}")
raise
async def upload_html_file(self, html_content: str,
original_filename: str,
file_hash: str) -> dict:
"""上传HTML文件到对象存储"""
if not self.is_connected:
raise RuntimeError("对象存储未连接")
bucket_name = self.buckets['html_files']
object_key = f"html/{file_hash}.html"
html_bytes = html_content.encode('utf-8')
try:
result = self.client.put_object(
bucket_name,
object_key,
BytesIO(html_bytes),
length=len(html_bytes),
content_type='text/html; charset=utf-8'
)
logger.info(f"HTML文件上传成功: {object_key}")
return {
'object_key': object_key,
'file_size': result.size,
'etag': result.etag
}
except S3Error as e:
logger.error(f"HTML文件上传失败: {e}")
raise
async def download_file(self, bucket_type: str,
object_key: str) -> bytes:
"""从对象存储下载文件"""
if not self.is_connected:
raise RuntimeError("对象存储未连接")
bucket_name = self.buckets.get(bucket_type)
if not bucket_name:
raise ValueError(f"未知的桶类型: {bucket_type}")
try:
response = self.client.get_object(bucket_name, object_key)
data = response.read()
response.close()
response.release_conn()
logger.debug(f"文件下载成功: {object_key}")
return data
except S3Error as e:
logger.error(f"文件下载失败 {object_key}: {e}")
raise
async def get_presigned_url(self, bucket_type: str,
object_key: str,
expires: int = 3600) -> str:
"""生成预签名URL(临时访问链接)"""
if not self.is_connected:
raise RuntimeError("对象存储未连接")
bucket_name = self.buckets.get(bucket_type)
if not bucket_name:
raise ValueError(f"未知的桶类型: {bucket_type}")
try:
url = self.client.presigned_get_object(
bucket_name,
object_key,
expires=expires
)
return url
except S3Error as e:
logger.error(f"生成预签名URL失败: {e}")
raise
async def delete_file(self, bucket_type: str, object_key: str):
"""删除对象存储中的文件"""
if not self.is_connected:
raise RuntimeError("对象存储未连接")
bucket_name = self.buckets.get(bucket_type)
if not bucket_name:
raise ValueError(f"未知的桶类型: {bucket_type}")
try:
self.client.remove_object(bucket_name, object_key)
logger.info(f"文件删除成功: {object_key}")
except S3Error as e:
logger.error(f"文件删除失败 {object_key}: {e}")
raise
def _calculate_file_hash(self, file_path: Path) -> str:
"""计算文件的SHA256哈希"""
sha256_hash = hashlib.sha256()
with open(file_path, 'rb') as f:
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()
async def _find_file_by_hash(self, bucket_name: str,
file_hash: str) -> Optional[str]:
"""根据哈希查找已存在的文件"""
try:
objects = self.client.list_objects(bucket_name, recursive=True)
for obj in objects:
# 从对象键中提取哈希(如果有)
if file_hash in obj.object_name:
return obj.object_name
return None
except S3Error as e:
logger.warning(f"查找文件哈希失败: {e}")
return None
async def get_file_info(self, bucket_type: str,
object_key: str) -> dict:
"""获取文件信息"""
if not self.is_connected:
raise RuntimeError("对象存储未连接")
bucket_name = self.buckets.get(bucket_type)
if not bucket_name:
raise ValueError(f"未知的桶类型: {bucket_type}")
try:
stat = self.client.stat_object(bucket_name, object_key)
return {
'size': stat.size,
'etag': stat.etag,
'content_type': stat.content_type,
'last_modified': stat.last_modified
}
except S3Error as e:
logger.error(f"获取文件信息失败: {e}")
raise
async def list_files(self, bucket_type: str,
prefix: str = '') -> list:
"""列出存储桶中的文件"""
if not self.is_connected:
raise RuntimeError("对象存储未连接")
bucket_name = self.buckets.get(bucket_type)
if not bucket_name:
raise ValueError(f"未知的桶类型: {bucket_type}")
try:
objects = self.client.list_objects(bucket_name, prefix=prefix)
return [
{
'object_key': obj.object_name,
'size': obj.size,
'etag': obj.etag,
'last_modified': obj.last_modified
}
for obj in objects
]
except S3Error as e:
logger.error(f"列出文件失败: {e}")
raise
# 全局对象存储管理器实例
storage_manager = ObjectStorageManager()
+25 -20
View File
@@ -28,6 +28,7 @@ def create_app(
service_name: str,
version: str = "4.0.0",
mount_html: bool = False,
serve_frontend_static: bool = False,
startup_hooks: Optional[List[Callable[[], Awaitable[None]]]] = None,
register_routers: Optional[Callable[[FastAPI], None]] = None,
) -> FastAPI:
@@ -38,6 +39,7 @@ def create_app(
service_name: 服务名(用于 /health 响应)
version: 版本号
mount_html: 是否挂载 /html 静态目录(moldinsight 需要)
serve_frontend_static: 是否由后端托管 /static 与 SPA fallback(默认关闭,前端独立部署)
startup_hooks: 额外的 startup 钩子列表(在数据库/RustFS/Redis 初始化后执行)
register_routers: 回调函数,用于注册业务路由
"""
@@ -70,7 +72,7 @@ def create_app(
# 跳过静态资源和健康检查的详细日志
path = request.url.path
is_static = path.startswith("/static") or path == "/health"
is_static = (serve_frontend_static and path.startswith("/static")) or path == "/health"
if not is_static:
log_level = "warning" if response.status_code >= 400 else "info"
@@ -92,16 +94,18 @@ def create_app(
# ── 目录准备 ─────────────────────────────────────────────────
Path("uploads").mkdir(exist_ok=True)
Path("static").mkdir(exist_ok=True)
if serve_frontend_static:
Path("static").mkdir(exist_ok=True)
if mount_html:
Path("html_output").mkdir(exist_ok=True)
# ── 静态文件挂载 ─────────────────────────────────────────────
app.mount(
"/static",
StaticFiles(directory=os.path.join(os.getcwd(), "static")),
name="static",
)
if serve_frontend_static:
app.mount(
"/static",
StaticFiles(directory=os.path.join(os.getcwd(), "static")),
name="static",
)
if mount_html:
app.mount(
"/html",
@@ -189,19 +193,20 @@ def create_app(
"database_error": db_error,
}
# ── SPA fallback(排除 /api 前缀,避免吞掉 API 404)────────
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str):
# API 路径不走 SPA fallback,让 FastAPI 正常返回 404 JSON
if full_path.startswith("api/") or full_path.startswith("api"):
raise _api_not_found(full_path)
# 健康检查 / 文档路径也排除
if full_path.startswith("docs") or full_path.startswith("openapi"):
raise _api_not_found(full_path)
static_index = os.path.join(os.getcwd(), "static", "index.html")
if os.path.exists(static_index):
return FileResponse(static_index)
return JSONResponse({"detail": "SPA index not found"}, status_code=404)
# ── SPA fallback(独立前端部署时默认关闭)────────────────────
if serve_frontend_static:
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str):
# API 路径不走 SPA fallback,让 FastAPI 正常返回 404 JSON
if full_path.startswith("api/") or full_path.startswith("api"):
raise _api_not_found(full_path)
# 健康检查 / 文档路径也排除
if full_path.startswith("docs") or full_path.startswith("openapi"):
raise _api_not_found(full_path)
static_index = os.path.join(os.getcwd(), "static", "index.html")
if os.path.exists(static_index):
return FileResponse(static_index)
return JSONResponse({"detail": "SPA index not found"}, status_code=404)
return app
+25 -30
View File
@@ -13,6 +13,7 @@ class Settings:
self.HOST = os.getenv("HOST", "0.0.0.0")
self.PORT = int(os.getenv("PORT", "8000"))
self.DEBUG = os.getenv("DEBUG", "false").lower() == "true"
self.SERVE_FRONTEND_STATIC = os.getenv("SERVE_FRONTEND_STATIC", "false").lower() == "true"
self.UPLOAD_DIR = os.getenv("UPLOAD_DIR", "./uploads")
self.MAX_FILE_SIZE = int(os.getenv("MAX_FILE_SIZE", "104857600"))
@@ -28,32 +29,12 @@ class Settings:
self.RUSTFS_TIMEOUT = int(os.getenv("RUSTFS_TIMEOUT", "30"))
self.RUSTFS_PRESIGNED_URL_EXPIRES = int(os.getenv("RUSTFS_PRESIGNED_URL_EXPIRES", "3600"))
db_host = os.getenv("DB_HOST")
db_port_str = os.getenv("DB_PORT")
db_name = os.getenv("DB_NAME")
db_user = os.getenv("DB_USER")
db_password = os.getenv("DB_PASSWORD")
missing_configs = []
if not db_host:
missing_configs.append("DB_HOST")
if not db_port_str:
missing_configs.append("DB_PORT")
if not db_name:
missing_configs.append("DB_NAME")
if not db_user:
missing_configs.append("DB_USER")
if not db_password:
missing_configs.append("DB_PASSWORD")
if missing_configs:
raise ValueError(f"数据库配置缺失,请在.env文件中设置: {', '.join(missing_configs)}")
self.DB_HOST = db_host
self.DB_PORT = int(db_port_str)
self.DB_NAME = db_name
self.DB_USER = db_user
self.DB_PASSWORD = db_password
# 数据库配置改为惰性校验:允许在无 DB 环境下 import 项目模块(测试/静态分析)
self.DB_HOST = os.getenv("DB_HOST")
self.DB_PORT = int(os.getenv("DB_PORT")) if os.getenv("DB_PORT") else None
self.DB_NAME = os.getenv("DB_NAME")
self.DB_USER = os.getenv("DB_USER")
self.DB_PASSWORD = os.getenv("DB_PASSWORD")
self.SECRET_KEY = os.getenv("SECRET_KEY")
self.ALGORITHM = os.getenv("ALGORITHM", "HS256")
@@ -90,10 +71,24 @@ class Settings:
@property
def DATABASE_URL(self) -> str:
if self.DB_PASSWORD:
safe_password = urllib.parse.quote(self.DB_PASSWORD.encode("utf-8"), safe="")
else:
safe_password = ""
missing_configs = []
if not self.DB_HOST:
missing_configs.append("DB_HOST")
if not self.DB_PORT:
missing_configs.append("DB_PORT")
if not self.DB_NAME:
missing_configs.append("DB_NAME")
if not self.DB_USER:
missing_configs.append("DB_USER")
if self.DB_PASSWORD is None:
missing_configs.append("DB_PASSWORD")
if missing_configs:
raise ValueError(
f"数据库配置缺失,请在.env文件中设置: {', '.join(missing_configs)}"
)
safe_password = urllib.parse.quote((self.DB_PASSWORD or "").encode("utf-8"), safe="")
return f"postgresql+asyncpg://{self.DB_USER}:{safe_password}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
@property
+6 -4
View File
@@ -45,16 +45,18 @@ class DatabaseManager:
Args:
role: 连接角色,"web" 或 "celery",决定连接池大小
"""
if not settings.DATABASE_URL:
logger.warning("未配置数据库连接,跳过数据库初始化")
try:
database_url = settings.DATABASE_URL
except ValueError as e:
logger.warning(f"未配置数据库连接,跳过数据库初始化: {e}")
self.is_connected = False
return
try:
pool_cfg = _get_pool_config(role)
# 创建异步引擎
self.engine = create_async_engine(
settings.DATABASE_URL,
database_url,
echo=settings.DEBUG,
pool_size=pool_cfg["pool_size"],
max_overflow=pool_cfg["max_overflow"],
+121 -35
View File
@@ -1,20 +1,27 @@
# services/redis_task_manager.py
"""Redis 任务管理器 - 替代内存字典,支持 TTL 自动清理"""
"""Redis 任务管理器 - 替代内存字典,支持 TTL 自动清理。
存储格式:Redis Hash(field -> JSON 字符串)。
- update_task 走 HSET 字段级原子更新,消除旧 get->merge->set 三步竞态
(后台处理流程与导出端点并发写同一任务时丢更新);
- 进度 tick 只重写变化字段,不再全量重写整个任务 blob;
- 兼容读旧 string 格式(升级前写入的在途任务),新写入一律 Hash。
"""
import json
import os
from typing import Dict, Any, Optional
from datetime import datetime
import redis.asyncio as aioredis
from shared.config.settings import settings
from shared.utils.logger import get_logger
logger = get_logger(__name__)
class RedisTaskManager:
"""基于 Redis 的任务状态管理"""
"""基于 Redis Hash 的任务状态管理"""
_instance: Optional["RedisTaskManager"] = None
@@ -31,14 +38,14 @@ class RedisTaskManager:
return cls._instance
async def connect(self):
"""连接 Redis"""
"""连接 Redis(配置统一来自 shared.config.settings,不再硬编码主机名)"""
if self._connected and self._redis:
return
host = os.getenv("REDIS_HOST", "szcjw")
port = int(os.getenv("REDIS_PORT", "6379"))
password = os.getenv("REDIS_PASSWORD", "")
db = int(os.getenv("REDIS_DB", "0"))
host = settings.REDIS_HOST
port = settings.REDIS_PORT
password = settings.REDIS_PASSWORD
db = settings.REDIS_DB
try:
self._redis = aioredis.Redis(
@@ -84,6 +91,16 @@ class RedisTaskManager:
def is_connected(self) -> bool:
return self._connected and self._redis is not None
@property
def redis_client(self) -> aioredis.Redis:
"""暴露底层客户端(batch 元数据等非任务结构数据使用)。
未连接时抛出明确错误,而不是让调用方踩 AttributeError。
"""
if not self.is_connected or self._redis is None:
raise RuntimeError("Redis 未连接,无法直接访问 redis_client")
return self._redis
# ---- 内存回退 ----
_fallback_tasks: Dict[str, Dict[str, Any]] = {}
@@ -102,55 +119,128 @@ class RedisTaskManager:
def _fallback_count(self) -> int:
return len(self._fallback_tasks)
# ---- 内部工具 ----
def _key(self, task_id: str) -> str:
return f"{self._prefix}{task_id}"
@staticmethod
def _dump_mapping(data: Dict[str, Any]) -> Dict[str, str]:
"""把任务 dict 序列化为 Hash mapping(field -> JSON 字符串)"""
serializable = RedisTaskManager._make_serializable(data)
return {k: json.dumps(v, ensure_ascii=False) for k, v in serializable.items()}
async def _load_hash(self, key: str) -> Optional[Dict[str, Any]]:
raw = await self._redis.hgetall(key)
if not raw:
return None
result = {}
for field, value in raw.items():
try:
result[field] = json.loads(value)
except (json.JSONDecodeError, TypeError):
result[field] = value
return result
async def _load_any(self, key: str) -> Optional[Dict[str, Any]]:
"""读取任务数据,自动识别 Hash(新)与 string(旧)格式。"""
key_type = await self._redis.type(key)
if key_type == "hash":
return await self._load_hash(key)
if key_type == "string":
legacy = await self._redis.get(key)
if not legacy:
return None
try:
return json.loads(legacy)
except json.JSONDecodeError:
logger.warning(f"任务数据解析失败(旧 string 格式): {key}")
return None
return None
# ---- 公共接口 ----
async def set_task(self, task_id: str, data: Dict[str, Any], ttl: Optional[int] = None):
"""设置任务数据"""
"""整包写入任务数据(Hash,覆盖旧值,含旧 string 格式清理)"""
effective_ttl = ttl or self._ttl
# 确保数据可序列化
serializable = self._make_serializable(data)
mapping = self._dump_mapping(data)
if self.is_connected:
try:
key = f"{self._prefix}{task_id}"
await self._redis.setex(key, effective_ttl, json.dumps(serializable, ensure_ascii=False))
key = self._key(task_id)
# DEL 先清掉可能存在的旧 string/Hash,保证覆盖语义
pipe = self._redis.pipeline()
pipe.delete(key)
pipe.hset(key, mapping=mapping)
pipe.expire(key, effective_ttl)
await pipe.execute()
return
except Exception as e:
logger.warning(f"Redis 写入失败,回退到内存: {e}")
self._fallback_set(task_id, serializable)
self._fallback_set(task_id, self._make_serializable(data))
async def get_task(self, task_id: str) -> Optional[Dict[str, Any]]:
"""获取任务数据"""
"""获取任务数据(Hash / 旧 string 兼容)"""
if self.is_connected:
try:
key = f"{self._prefix}{task_id}"
raw = await self._redis.get(key)
if raw:
return json.loads(raw)
return None
return await self._load_any(self._key(task_id))
except Exception as e:
logger.warning(f"Redis 读取失败,回退到内存: {e}")
return self._fallback_get(task_id)
async def update_task(self, task_id: str, updates: Dict[str, Any]):
"""更新任务的部分字段"""
current = await self.get_task(task_id)
"""字段级原子更新(HSET),无读改写竞态。
兼容旧 string 格式:先迁移为 Hash 再更新。
"""
mapping = self._dump_mapping(updates)
if self.is_connected:
try:
key = self._key(task_id)
key_type = await self._redis.type(key)
if key_type == "none":
logger.warning(f"任务 {task_id} 不存在,无法更新")
return
if key_type == "string":
# 旧格式迁移:string -> Hash
legacy = await self._redis.get(key)
try:
base = json.loads(legacy) if legacy else {}
except json.JSONDecodeError:
base = {}
base.update(mapping)
pipe = self._redis.pipeline()
pipe.delete(key)
pipe.hset(key, mapping=self._dump_mapping(base))
pipe.expire(key, self._ttl)
await pipe.execute()
return
await self._redis.hset(key, mapping=mapping)
await self._redis.expire(key, self._ttl)
return
except Exception as e:
logger.warning(f"Redis 更新失败,回退到内存: {e}")
# 内存回退保持读改写语义(单进程内存无并发竞态)
current = self._fallback_get(task_id)
if current is None:
logger.warning(f"任务 {task_id} 不存在,无法更新")
return
current.update(self._make_serializable(updates))
await self.set_task(task_id, current)
self._fallback_set(task_id, current)
async def delete_task(self, task_id: str):
"""删除任务"""
"""删除任务(DEL 对 Hash/string 均有效)"""
if self.is_connected:
try:
key = f"{self._prefix}{task_id}"
await self._redis.delete(key)
await self._redis.delete(self._key(task_id))
return
except Exception as e:
logger.warning(f"Redis 删除失败,回退到内存: {e}")
@@ -162,16 +252,12 @@ class RedisTaskManager:
if self.is_connected:
try:
pattern = f"{self._prefix}*"
keys = []
async for key in self._redis.scan_iter(match=pattern):
keys.append(key)
result = {}
for key in keys:
async for key in self._redis.scan_iter(match=pattern):
task_id = key.replace(self._prefix, "")
raw = await self._redis.get(key)
if raw:
result[task_id] = json.loads(raw)
task = await self._load_any(key)
if task:
result[task_id] = task
return result
except Exception as e:
logger.warning(f"Redis 扫描失败,回退到内存: {e}")
-265
View File
@@ -1,265 +0,0 @@
import io
import importlib.util
import sys
import types
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import FastAPI, UploadFile
from httpx import ASGITransport, AsyncClient
from services.calculation_service import CalculationService
from utils.file_handler import FileHandler
VALID_STEP_BYTES = (
b"ISO-10303-21;\n"
b"HEADER;\n"
b"FILE_DESCRIPTION(('STEP AP214'),'1');\n"
b"ENDSEC;\n"
b"DATA;\n"
b"ENDSEC;\n"
b"END-ISO-10303-21;\n"
)
def _load_module_from_path(module_name: str, file_path: str, stub_modules: dict[str, object]):
originals = {}
for name, module in stub_modules.items():
originals[name] = sys.modules.get(name)
sys.modules[name] = module
try:
spec = importlib.util.spec_from_file_location(module_name, file_path)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
finally:
for name, original in originals.items():
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
@pytest.mark.asyncio
async def test_file_handler_sanitizes_step_filename(tmp_path):
handler = FileHandler(upload_dir=str(tmp_path))
upload = UploadFile(filename="../../bad name?.step", file=io.BytesIO(VALID_STEP_BYTES))
file_path, file_size, meta = await handler.save_uploaded_file(upload)
assert file_path.exists()
assert file_size == len(VALID_STEP_BYTES)
assert file_path.parent == tmp_path
assert ".." not in file_path.name
assert meta["safe_original_name"] == "bad_name.step"
assert len(meta["sha256"]) == 64
@pytest.mark.asyncio
async def test_file_handler_rejects_invalid_step_content(tmp_path):
handler = FileHandler(upload_dir=str(tmp_path))
upload = UploadFile(filename="fake.step", file=io.BytesIO(b"not-a-step"))
with pytest.raises(ValueError, match="不是有效的 STP/STEP 数据"):
await handler.save_uploaded_file(upload)
@pytest.mark.asyncio
async def test_export_route_requires_cached_shapes(monkeypatch):
fake_processing_service = types.ModuleType("services.processing_service")
fake_processing_service.processing_service = SimpleNamespace(
get_export_shapes=lambda task_id, scheme_id=None: None,
)
fake_auth_service = types.ModuleType("services.auth_service")
async def fake_current_user():
return SimpleNamespace(id=1)
fake_auth_service.get_current_active_user = fake_current_user
fake_redis_task_manager = types.ModuleType("services.redis_task_manager")
fake_redis_task_manager.redis_task_manager = SimpleNamespace(get_task=None)
fake_models_database = types.ModuleType("models.database")
fake_models_database.User = SimpleNamespace
advanced_router = _load_module_from_path(
"temp_advanced_router",
"d:\\Project\\geMoldInsight\\src\\api\\v1\\advanced_router.py",
{
"services.processing_service": fake_processing_service,
"services.auth_service": fake_auth_service,
"services.redis_task_manager": fake_redis_task_manager,
"models.database": fake_models_database,
},
)
app = FastAPI()
app.include_router(advanced_router.router)
app.dependency_overrides[advanced_router.get_current_active_user] = lambda: SimpleNamespace(id=1)
async def fake_get_task_data(task_id):
return {
"task_id": task_id,
"filename": "demo.step",
"best_scheme_id": "scheme_1",
}
monkeypatch.setattr(advanced_router, "_get_task_data", fake_get_task_data)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/export-mold",
json={"task_id": "task-1", "scheme_id": "scheme_1", "formats": ["step"]},
)
assert response.status_code == 409
assert "导出缓存已失效" in response.json()["detail"]
def test_calculation_service_attaches_injection_system_summary():
plan_result = {
"best_scheme_id": "scheme_1",
"candidate_schemes": [
{
"scheme_id": "scheme_1",
"cavity_data": {
"product_analysis": {
"bounding_box": {"dimensions": [100, 80, 30]}
},
"manufacturing_info": {
"estimated_mold_size": {"length": 200, "width": 180, "height": 110}
},
"mold_cavities": {"cavity_count": 1},
},
"key_info": {},
}
],
}
result = CalculationService.attach_injection_system_summaries(plan_result, "ABS")
best_scheme = result["candidate_schemes"][0]
assert "injection_system" in best_scheme["cavity_data"]
assert best_scheme["cavity_data"]["manufacturing_info"]["cooling_summary"]["channel_count"] >= 1
assert best_scheme["cavity_data"]["manufacturing_info"]["gating_summary"]["gate_type"] in {"auto", "side", "center", "submarine", "fan"}
assert "injection_system" in result
@pytest.mark.asyncio
async def test_upload_route_persists_process_parameters(monkeypatch, tmp_path):
captured = {}
class DummyStorageService:
async def save_stp_file(self, session, file_path, original_filename, user_id):
captured["saved_file"] = {
"file_path": str(file_path),
"original_filename": original_filename,
"user_id": user_id,
}
return SimpleNamespace(id=42)
async def create_processing_task(self, session, task_id, stp_file_id, task_type="stp_parsing", parameters=None):
captured["task"] = {
"task_id": task_id,
"stp_file_id": stp_file_id,
"task_type": task_type,
"parameters": parameters,
}
async def fake_save_uploaded_file(file):
target = Path(tmp_path) / "cached_demo.step"
target.write_bytes(VALID_STEP_BYTES)
return target, len(VALID_STEP_BYTES), {
"safe_original_name": "demo.step",
"sha256": "a" * 64,
"original_filename": "demo.step",
"stored_filename": target.name,
}
async def fake_set_task(task_id, task_info):
captured["redis"] = {"task_id": task_id, "task_info": task_info}
async def fake_process_file_with_storage(task_id, file_path, stp_file_id, process_params):
captured["background"] = {
"task_id": task_id,
"file_path": str(file_path),
"stp_file_id": stp_file_id,
"process_params": process_params,
}
fake_processing_service_module = types.ModuleType("services.processing_service")
fake_processing_service_module.processing_service = SimpleNamespace(
process_file_with_storage=fake_process_file_with_storage,
)
fake_storage_module = types.ModuleType("services.storage_integration_rustfs")
fake_storage_module.StorageIntegrationService = lambda: DummyStorageService()
fake_redis_task_manager = types.ModuleType("services.redis_task_manager")
fake_redis_task_manager.redis_task_manager = SimpleNamespace(
set_task=fake_set_task,
)
fake_database_module = types.ModuleType("database.database")
async def override_get_db_session():
yield object()
fake_database_module.get_db_session = override_get_db_session
fake_auth_service = types.ModuleType("services.auth_service")
async def override_get_current_user():
return SimpleNamespace(id=7, username="tester")
fake_auth_service.get_current_active_user = override_get_current_user
fake_models_database = types.ModuleType("models.database")
fake_models_database.User = SimpleNamespace
upload_router = _load_module_from_path(
"temp_upload_router",
"d:\\Project\\geMoldInsight\\src\\api\\v1\\upload_router.py",
{
"services.processing_service": fake_processing_service_module,
"services.storage_integration_rustfs": fake_storage_module,
"services.redis_task_manager": fake_redis_task_manager,
"database.database": fake_database_module,
"services.auth_service": fake_auth_service,
"models.database": fake_models_database,
},
)
monkeypatch.setattr(upload_router.file_handler, "save_uploaded_file", fake_save_uploaded_file)
app = FastAPI()
app.include_router(upload_router.router)
app.dependency_overrides[upload_router.get_db_session] = override_get_db_session
app.dependency_overrides[upload_router.get_current_active_user] = override_get_current_user
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/upload",
files={"file": ("demo.step", VALID_STEP_BYTES, "application/step")},
data={
"material": "ABS",
"draft_angle": "3.5",
"shrinkage_rate": "0.8",
"parting_precision": "0.05",
"cavity_match": "96",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["parameters"] == {
"material": "ABS",
"draft_angle": 3.5,
"shrinkage_rate": 0.8,
"parting_precision": 0.05,
"cavity_match": 96,
}
assert captured["task"]["parameters"] == payload["parameters"]
assert captured["redis"]["task_info"]["parameters"] == payload["parameters"]
assert captured["background"]["process_params"] == payload["parameters"]
+233
View File
@@ -0,0 +1,233 @@
import io
import pytest
from fastapi import UploadFile
from moldinsight.core.parting_candidate_generator import PartingCandidateGenerator
from moldinsight.core.parting_scheme_scorer import PartingSchemeScorer
from moldinsight.services.calculation_service import CalculationService
from moldinsight.services.cost_estimate_service import estimate_cost_by_rules
from moldinsight.services.material_service import MaterialService
from shared.services.redis_task_manager import RedisTaskManager
from shared.utils.file_handler import FileHandler
VALID_STEP_BYTES = (
b"ISO-10303-21;\n"
b"HEADER;\n"
b"FILE_DESCRIPTION(('STEP AP214'),'1');\n"
b"ENDSEC;\n"
b"DATA;\n"
b"ENDSEC;\n"
b"END-ISO-10303-21;\n"
)
@pytest.mark.asyncio
async def test_file_handler_sanitizes_step_filename(tmp_path):
handler = FileHandler(upload_dir=str(tmp_path))
upload = UploadFile(filename="../../bad name?.step", file=io.BytesIO(VALID_STEP_BYTES))
file_path, file_size, meta = await handler.save_uploaded_file(upload)
assert file_path.exists()
assert file_size == len(VALID_STEP_BYTES)
assert file_path.parent == tmp_path
assert ".." not in file_path.name
assert meta["safe_original_name"] == "bad_name.step"
assert len(meta["sha256"]) == 64
@pytest.mark.asyncio
async def test_file_handler_rejects_invalid_step_content(tmp_path):
handler = FileHandler(upload_dir=str(tmp_path))
upload = UploadFile(filename="fake.step", file=io.BytesIO(b"not-a-step"))
with pytest.raises(ValueError, match="不是有效的 STP/STEP 数据"):
await handler.save_uploaded_file(upload)
def test_calculation_service_attaches_injection_system_summary():
plan_result = {
"best_scheme_id": "scheme_1",
"candidate_schemes": [
{
"scheme_id": "scheme_1",
"cavity_data": {
"product_analysis": {
"bounding_box": {"dimensions": [100, 80, 30]}
},
"manufacturing_info": {
"estimated_mold_size": {"length": 200, "width": 180, "height": 110}
},
"mold_cavities": {"cavity_count": 1},
},
"key_info": {},
}
],
}
result = CalculationService.attach_injection_system_summaries(plan_result, "ABS")
best_scheme = result["candidate_schemes"][0]
assert "injection_system" in best_scheme["cavity_data"]
assert best_scheme["cavity_data"]["manufacturing_info"]["cooling_summary"]["channel_count"] >= 1
assert best_scheme["cavity_data"]["manufacturing_info"]["gating_summary"]["gate_type"] in {
"auto", "side", "center", "submarine", "fan"
}
assert "injection_system" in result
def test_material_service_falls_back_to_abs_for_unknown_material():
material = MaterialService.get_material("UNKNOWN")
assert material["name"] == "ABS"
assert MaterialService.resolve_material("UNKNOWN") == "ABS"
assert MaterialService.is_foam_material("UNKNOWN") is False
def test_parting_candidate_generator_prioritizes_z_for_foam_material():
generator = PartingCandidateGenerator()
analysis = {
"bounding_box": {"dimensions": [120, 80, 60]},
"inertia_matrix": [[10, 0, 0], [0, 12, 0], [0, 0, 8]],
"axis_normal_stats": {"X": 20.0, "Y": 30.0, "Z": 50.0},
}
candidates = generator.generate_candidates(analysis, is_foam_material=True)
assert candidates[0]["axis"] == "Z"
assert candidates[0]["method"] == "foam_axis_rule"
assert "泡沫模具优先上下开模" in candidates[0]["reason"]
def test_parting_scheme_scorer_prefers_scheme_without_undercuts():
scorer = PartingSchemeScorer()
schemes = [
{
"scheme_id": "clean",
"priority_score": 90,
"offset_ratio": 0.0,
"method": "geometric_primary",
"parting": {"line": [[0, 0, 0], [10, 0, 0]]},
"core_required": True,
"cavity_data": {
"mold_cavities": {
"cavity": {"vertex_count": 120},
"core": {"vertex_count": 120},
},
"quality_checks": {
"parting_line_smoothness": 92,
"undercut_regions": [],
"side_actions": {
"summary": {"total_mechanism_count": 0, "complexity": "simple"},
"slider_mechanisms": [],
"lifter_mechanisms": [],
"undercut_analysis": {"total_undercut_area": 0},
},
},
"manufacturing_info": {
"estimated_mold_size": {"length": 220, "width": 180, "height": 120},
"estimated_clamping_force": "180",
},
"metadata": {"draft_angle": 2.0},
},
"key_info": {
"quality_considerations": {"warpage_risk": "low"},
"geometric_characteristics": {"wall_thickness_range": "1.8-3.2mm"},
},
},
{
"scheme_id": "complex",
"priority_score": 85,
"offset_ratio": 0.1,
"method": "geometric_primary",
"parting": {"line": [[0, 0, 0], [100, 0, 0], [100, 50, 0]]},
"core_required": True,
"cavity_data": {
"mold_cavities": {
"cavity": {"vertex_count": 120},
"core": {"vertex_count": 120},
},
"quality_checks": {
"parting_line_smoothness": 75,
"undercut_regions": [{"id": 1}, {"id": 2}],
"side_actions": {
"summary": {"total_mechanism_count": 2, "complexity": "complex"},
"slider_mechanisms": [{"actuation": "pneumatic"}],
"lifter_mechanisms": [{"actuation": "mechanical"}],
"undercut_analysis": {"total_undercut_area": 1800},
},
},
"manufacturing_info": {
"estimated_mold_size": {"length": 420, "width": 360, "height": 180},
"estimated_clamping_force": "620",
},
"metadata": {"draft_angle": 2.0},
},
"key_info": {
"quality_considerations": {"warpage_risk": "high"},
"geometric_characteristics": {"wall_thickness_range": "0.8-7.0mm"},
},
},
]
ranked = scorer.score_schemes(schemes)
assert ranked[0]["scheme_id"] == "clean"
assert ranked[0]["rank"] == 1
assert ranked[0]["title"] == "推荐方案"
assert ranked[0]["score"] > ranked[1]["score"]
assert ranked[0]["dfm_violation_count"] <= ranked[1]["dfm_violation_count"]
def test_cost_estimate_rules_returns_expected_shape():
analysis_result = {
"geometry_data": {
"bounding_box": {"dimensions": [100, 80, 30]},
"volume": 24000,
}
}
detailed = {
"candidate_schemes": [
{
"cavity_data": {
"mold_cavities": {"cavity_count": 2},
"manufacturing_info": {
"mold_material": "P20",
"estimated_mold_size": {"length": 320, "width": 260, "height": 140},
"estimated_cycle_time": "35秒",
},
"metadata": {"selected_material": "ABS"},
"side_actions": {
"summary": {"total_slider_count": 1, "total_lifter_count": 0}
},
}
}
]
}
result = estimate_cost_by_rules(analysis_result, detailed)
assert result["source"] == "rules"
assert "mold_cost" in result
assert "part_cost" in result
assert result["confidence"] == 0.55
assert result["total_mold_cost"].startswith("¥")
assert result["cost_per_part"].startswith("¥")
def test_redis_task_manager_make_serializable_handles_enums_and_datetime():
from datetime import datetime
from shared.models.schemas import ProcessingStatus
payload = {
"status": ProcessingStatus.COMPLETED,
"completed_at": datetime(2026, 8, 31, 12, 0, 0),
"nested": {"flag": True},
}
serialized = RedisTaskManager._make_serializable(payload)
assert serialized["status"] == "completed"
assert serialized["completed_at"] == "2026-08-31T12:00:00"
assert serialized["nested"] == {"flag": True}