x
This commit is contained in:
+6
-2
@@ -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
@@ -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"]
|
||||
@@ -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
@@ -3,7 +3,9 @@
|
||||
# ============================================
|
||||
# 复制为 .env 并按服务器实际服务地址修改
|
||||
|
||||
# API 对外端口
|
||||
# API / 前端对外端口
|
||||
FRONTEND_PORT=80
|
||||
BACKEND_PORT=8000
|
||||
MOLDINSIGHT_PORT=8000
|
||||
INVENTORY_PORT=8001
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 路径级业务分流”的长期维护问题。
|
||||
@@ -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` 停写后,历史行中的旧数据仍可读(列保留),仅新行不再写入
|
||||
@@ -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 独立部署端口
|
||||
|
||||
示例:
|
||||
|
||||
|
||||
@@ -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` 容器独立提供,并通过同域反代转发到后端。
|
||||
|
||||
示例:
|
||||
|
||||
|
||||
@@ -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
@@ -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 分析产物)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -25,5 +25,6 @@ app = create_app(
|
||||
title="Gemold - 进销存管理系统",
|
||||
service_name="inventory",
|
||||
mount_html=False,
|
||||
serve_frontend_static=False,
|
||||
register_routers=_register_routers,
|
||||
)
|
||||
|
||||
@@ -25,5 +25,6 @@ app = create_app(
|
||||
title="Gemold - 模具分析引擎",
|
||||
service_name="moldinsight",
|
||||
mount_html=True,
|
||||
serve_frontend_static=False,
|
||||
register_routers=_register_routers,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
@@ -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", "调试")
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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, "无权访问该批量任务")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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]] = []
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 未安装)")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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"]
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user