This commit is contained in:
2026-03-07 03:04:15 +08:00
parent bfe6da3376
commit 557fcd578a
10 changed files with 1469 additions and 201 deletions
+107
View File
@@ -0,0 +1,107 @@
# 更新日志 (Changelog)
## [4.0.0] - 2026-03-07
### 新增功能 ✨
#### 前端重构
- **白色简约现代UI** - 完全重构前端为白色简约现代化风格
- 緔洁的白色背景配色
- 緩和阴影和圆角设计
- 娡块化CSS变量系统
- 响应式布局优化
#### 3D可视化
- **PythonOCC点云模型** - 从STP文件提取真实点云数据生成3D模型
- **模具型腔可视化** - 3D预览显示型腔、型芯、分型面
- **交互式控制** - 支持旋转、缩放、平移操作
- **视图切换** - 显示/隐藏产品、模具、分型面、点云
#### 验证工具
- **STP验证脚本** - 使用FreeCAD命令行验证几何数据准确性
- 对比FreeCAD和PythonOCC的测量结果
- 生成详细的验证报告
#### 分析功能
- **产品特征识别** - 检测壁厚、加强筋、孔洞、倒扣等特征
- **泡沫包装设计决策** - 噃度、布局、定位结构建议
- **模具工程决策** - 型腔数量、模架尺寸、材料选择
- **质量指标** - 体积利用率、拓扑复杂度、壁厚均匀性
### 优化改进 🔧
#### 数据处理
- **点云数据提取优化** - 修复PythonOCC网格提取的正确实现
- **数据持久化** - 添加AnalysisMetrics表存储分析指标
- **API响应优化** - 修复字段名不匹配问题
#### 用户体验
- **分析摘要显示** - 在结果页面顶部显示分析摘要
- **设计建议列表** - 按优先级排序显示所有建议
- **关键信息完善** - 显示更多模具参数和制造要求
### 问题修复 🐛
- 修复 `getPriorityText` 函数未定义错误
- 修复 `controls` 变量初始化顺序错误
- 修复 HTML生成器JavaScript语法错误
- 修复历史记录API路径重复前缀问题
- 修复点云数据传递逻辑错误
---
## [3.0.0] - 2026-03-06
### 新增功能 ✨
#### 用户系统
- **用户认证** - JWT令牌认证机制
- **角色权限** - 基于角色的访问控制
- **管理员账户** - 支持环境变量配置管理员
#### 进销存模块
- **仪表盘** - 库存概览和统计数据
- **产品管理** - 产品CRUD操作
- **库存预警** - 低库存提醒功能
#### 项目架构
- **新主页** - Gemold官网主页
- **模块化路由** - 分离认证、进销存、分析模块
### 优化改进 🔧
- **UI主题** - 从深色主题改为现代蓝紫色渐变
- **按钮样式** - 修复按钮大小和可见性问题
- **数据库连接** - 优化异步会话管理
---
## [2.0.0] - 2026-03-05
### 新增功能 ✨
#### 核心分析
- **STP文件解析** - 使用PythonOCC解析STP文件
- **几何分析** - 计算体积、表面积、边界框等
- **模具设计建议** - 自动生成设计建议
#### Web界面
- **文件上传** - 拖拽上传支持
- **任务状态** - 实时进度显示
- **结果展示** - 分析结果可视化
#### 数据存储
- **PostgreSQL集成** - 数据持久化存储
- **RustFS集成** - 文件存储服务
---
## [1.0.0] - 2026-03-04
### 初始版本 🎉
- 项目初始化
- 基础架构搭建
- STP文件解析功能
- 基础Web界面
+104
View File
@@ -0,0 +1,104 @@
# 更新日志 (Changelog)
本文档记录 geMoldInsight 项目的所有重要变更。
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
---
## [Unreleased]
### 计划中
- 支持更多CAD格式(IGES、BREP)
- 模具成本估算模块
- 批量分析功能
- API文档自动生成
---
## [4.0.0] - 2026-03-07
### 新增
- **白色简约现代UI** - 完全重构前端为白色简约现代化风格
- **PythonOCC点云3D模型** - 从STP文件提取真实点云数据生成3D模型
- **模具型腔可视化** - 3D预览显示型腔、型芯、分型面
- **STP验证脚本** - 使用FreeCAD命令行验证几何数据准确性
- **PyCharm运行配置** - 添加IDE一键启动支持
- **Windows启动脚本** - 添加Windows平台启动脚本
### 优化
- **网格生成算法** - 优化PythonOCC网格提取,支持位置变换
- **点云采样** - 动态调整采样点数,优化渲染性能
- **相机自动对准** - 根据点云边界框自动调整相机位置
### 修复
- 修复 `getPriorityText` 函数未定义错误
- 修复 `controls` 变量初始化顺序错误
- 修复历史记录API路由重复前缀问题
- 修复点云数据结构不匹配问题
- 修复HTML生成器JavaScript语法错误
---
## [3.0.0] - 2026-03-06
### 新增
- **用户权限管理系统** - 完整的角色权限控制
- **管理员账户** - 支持环境变量配置管理员
- **进销存模块** - 基础库存管理功能
- **Gemold主页** - 新增系统主页
### 优化
- **UI主题** - 从深色改为现代蓝紫色渐变主题
- **数据库模型** - 添加用户、角色、权限表
### 修复
- 修复数据库会话管理问题
- 修复bcrypt版本兼容性问题
- 修复密码长度限制错误
- 修复email-validator缺失问题
---
## [2.0.0] - 2026-03-05
### 新增
- **分析结果详情** - 产品特征识别、泡沫包装设计、模具工程决策
- **质量指标显示** - 体积利用率、拓扑复杂度、壁厚均匀性
- **设计建议列表** - 按优先级排序的完整建议
### 优化
- **数据持久化** - 分析结果保存到数据库
- **API响应结构** - 统一数据返回格式
### 修复
- 修复分析结果字段名不匹配问题
- 修复特征类型字段名错误
---
## [1.0.0] - 2026-03-04
### 新增
- **STP文件上传** - 支持拖拽上传
- **几何分析** - 体积、表面积、边界框计算
- **模具型腔生成** - 自动生成型腔数据
- **3D可视化** - HTML格式3D预览
- **任务状态跟踪** - 实时进度显示
- **PostgreSQL存储** - 数据持久化
- **RustFS集成** - 文件存储服务
### 技术栈
- **后端**: FastAPI + PythonOCC + SQLAlchemy
- **前端**: Vue.js 3 + Three.js
- **数据库**: PostgreSQL
- **存储**: RustFS (S3兼容)
---
## 版本说明
- **[Major]**: 重大架构变更或不兼容的API修改
- **[Minor]**: 新增功能,向后兼容
- **[Patch]**: Bug修复,向后兼容
+263 -46
View File
@@ -1,77 +1,294 @@
# MoldInsight - 模具几何分析系统 # Gemold - 模具制造管理系统
<div align="center">
![Version](https://img.shields.io/badge/version-v4.0.0-blue)
![Python](https://img.shields.io/badge/python-3.12-blue)
![FastAPI](https://img.shields.io/badge/fastapi-0.100.0-green)
![Vue.js](https://img.shields.io/badge/vue.js-3-green)
![PostgreSQL](https://img.shields.io/badge/postgresql-15-blue)
![License](https://img.shields.io/badge/license-Mit-green)
</div>
## 项目简介
**Gemold** 是一个面向模具制造行业的综合性管理系统,集成了STP文件分析、模具设计建议、用户权限管理和进销存功能。 系统采用现代化的技术栈,提供高效、稳定、易扩展的解决方案。
### 核心功能
| 功能模块 | 描述 |
|---------|------|
| 🔷 STP文件分析 | 使用PythonOCC解析STP文件,提取几何特征 |
| 📊 模具设计建议 | 自动生成型腔、型芯、工艺参数等设计方案 |
| 🎨 3D可视化 | 实时预览产品模型和模具结构 |
| 👥 用户权限管理 | 完整的角色权限控制系统 |
| 📦 进销存管理 | 库存、供应商、客户管理 |
| 💾 数据持久化 | PostgreSQL + RustFS存储 |
---
## 技术栈
### 后端
- **FastAPI** - 笷性能异步Web框架
- **PythonOCC** - 专业CAD几何处理
- **SQLAlchemy** - ORM框架
- **PostgreSQL** - 关系型数据库
- **RustFS** - 高性能文件存储
### 前端
- **Vue.js 3** - 渐进式JavaScript框架
- **Three.js** - 3D可视化库
- **原生CSS** - 白色简约现代风格
### 基础设施
- **Docker** - 容器化部署
- **Systemd** - 服务管理
- **Conda** - 环境管理
---
## 项目结构 ## 项目结构
``` ```
moldinsight_project/ geMoldInsight/
├── src/ # 源代码目录 ├── src/ # 源代码目录
│ ├── main.py # 主程序入口 │ ├── main.py # 主程序入口
│ ├── api/ # API路由 │ ├── api/ # API路由
│ │ └── routes.py │ │ ├── routes.py # 主API路由
│ │ ├── auth_routes.py # 认证API
│ │ └── inventory_routes.py # 进销存API
│ ├── core/ # 核心业务逻辑 │ ├── core/ # 核心业务逻辑
│ │ ├── stp_parser.py │ │ ├── stp_parser.py # STP文件解析
│ │ ├── geometry_analyzer.py │ │ ├── geometry_analyzer.py # 几何分析
│ │ └── mesh_generator.py │ │ ├── mesh_generator.py # 网格生成
│ │ └── mold_generator.py # 模具生成
│ ├── models/ # 数据模型 │ ├── models/ # 数据模型
│ │ ├── schemas.py │ │ ├── database.py # 数据库模型
│ │ └── database.py │ │ └── schemas.py # 数据模式
│ ├── services/ # 业务服务 │ ├── services/ # 服务层
│ │ └── storage_service.py │ │ ├── auth_service.py # 认证服务
│ │ ├── storage_service.py # 存储服务
│ │ └── storage_integration_rustfs.py # RustFS集成
│ ├── database/ # 数据库管理 │ ├── database/ # 数据库管理
│ │ ├── database.py │ │ ├── database.py # 数据库连接
│ │ └── init_db.py │ │ └── init_db.py # 数据库初始化
│ └── utils/ # 工具类 │ └── utils/ # 工具类
│ ├── logger.py │ ├── logger.py # 日志工具
│ ├── file_handler.py │ ├── file_handler.py # 文件处理
│ └── html_generator.py │ └── html_generator.py # HTML生成
├── config/ # 配置文件
│ └── settings.py
├── static/ # 静态文件 ├── static/ # 静态文件
│ ├── script.js │ ├── vue-app.js # Vue应用
│ └── style.css │ ├── style.css # 样式文件
├── templates/ # HTML模板 │ └── index.html # 主页面
│ └── index.html ├── scripts/ # 脚本工具
│ └── verify_stp.py # STP验证脚本
├── uploads/ # 上传文件目录 ├── uploads/ # 上传文件目录
├── html_output/ # 生成的HTML文件 ├── html_output/ # 生成的HTML文件
├── logs/ # 日志文件 ├── logs/ # 日志文件
├── tests/ # 测试文件
├── docs/ # 文档
├── requirements.txt # 依赖包 ├── requirements.txt # 依赖包
├── docker-compose.yml # Docker配置 ├── docker-compose.yml # Docker配置
├── start.sh # Linux启动脚本
├── start.bat # Windows启动脚本
└── .env # 环境变量 └── .env # 环境变量
``` ```
## 功能特性 ---
- ✅ STP文件解析和几何分析
- ✅ JSON数据导出
- ✅ PostgreSQL数据库存储
- ✅ 3D可视化HTML生成
- ✅ Web界面文件上传
- ✅ 任务状态跟踪
## 这是一个模具设计项目:
- 输入: 产品的三维模型(STP文件)
- 输出: 模具设计方案(型腔、型芯、工艺参数等)
- 目标: 为泡沫产品(ABS、PP、PC等材料)设计铝制模具
## 快速开始 ## 快速开始
1. 安装依赖: ### 1. 环境要求
- Python 3.10+
- PostgreSQL 13+
- Conda (推荐)
### 2. 安装依赖
```bash ```bash
# 创建Conda环境
conda create -n py_3.12 python=3.12
# 激活环境
conda activate py_3.12
# 安装依赖
pip install -r requirements.txt pip install -r requirements.txt
``` ```
### 3. 配置环境变量
2. 配置数据库连接(修改.env文件) 创建 `.env` 文件:
3. 启动服务: ```env
```bash # 数据库配置
python src/main.py DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/gemold
# 服务配置
SECRET_KEY=your-secret-key-here
PORT=8000
# 管理员配置
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
``` ```
4. 访问服务 ### 4. 初始化数据库
- 查看端口配置:打开 `.env` 文件查看 `PORT` 的值
- 默认端口:8000 ```bash
- 访问地址:http://localhost:${PORT} python src/database/init_db.py
```
### 5. 启动服务
**Linux:**
```bash
chmod +x start.sh
./start.sh
```
**Windows:**
```cmd
start.bat
```
**Docker:**
```bash
docker-compose up -d
```
### 6. 访问服务
- 服务地址: http://localhost:8000
- 默认管理员: admin / admin123
---
## 核心功能详解
### STP文件分析
系统使用PythonOCC解析STP文件,提取以下几何特征:
| 特征类型 | 描述 |
|---------|------|
| 边界框 | 产品整体尺寸 |
| 体积 | 产品体积计算 |
| 表面积 | 产品表面积计算 |
| 壁厚 | 壁厚分布分析 |
| 加强筋 | 加强筋位置和密度 |
| 孔洞 | 孔洞和凹槽位置 |
| 倒扣 | 倒扣区域检测 |
| 对称性 | 对称性分析 |
| 重心 | 重心位置计算 |
### 模具设计建议
系统自动生成以下设计建议。
| 建议类型 | 描述 |
|---------|------|
| 型腔数量 | 单腔或多腔建议 |
| 模架尺寸 | 基于产品尺寸推荐 |
| 顶出系统 | 顶针顶出布局 |
| 冷却水路 | 冷却需求分析 |
| 材料选择 | 基于产量推荐材料 |
### 3D可视化
- **点云模型** - 从STP提取的真实几何形状
- **模具型腔** - 型腔和型芯可视化
- **分型面** - 分型面位置显示
- **交互控制** - 旋转、缩放、平移
- **视图切换** - 显示/隐藏各组件
---
## API文档
### 认证API
```
POST /api/auth/login # 用户登录
POST /api/auth/logout # 用户登出
GET /api/auth/me # 获取当前用户信息
```
### 文件分析API
```
POST /api/upload # 上传STP文件
POST /api/status/{task_id} # 获取分析状态
GET /api/history # 获取分析历史
```
### 进销存API
```
GET /api/inventory/dashboard # 仪表盘数据
GET /api/inventory/products # 产品列表
POST /api/inventory/products # 创建产品
```
---
## 部署指南
### Systemd服务 (Linux)
```bash
# 复制服务文件
sudo cp gemoldinsight.service /etc/systemd/system/
# 启用服务
sudo systemctl enable gemoldinsight
# 启动服务
sudo systemctl start gemoldinsight
```
### Docker部署
```bash
# 构建镜像
docker build -t gemold:latest .
# 启动容器
docker-compose up -d
```
---
## 开发指南
### 代码风格
- 遵循PEP 8规范
- 使用类型注解
- 保持函数简洁
### 提交规范
- feat: 新功能
- fix: 修复bug
- docs: 文档更新
- refactor: 代码重构
- test: 测试相关
---
## 许可证
本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件
---
## 贡献者
感谢所有为这个项目做出贡献的开发者。
---
## 联系方式
- 项目地址: [GitHub](https://github.com/your-org/gemold)
- 问题反馈: [Issues](https://github.com/your-org/gemold/issues)
+72 -31
View File
@@ -1,43 +1,84 @@
# requirements.txt # ============================================
# 几何处理核心 # geMoldInsight 项目依赖
# ============================================
# 使用阿里云镜像加速
-i https://mirrors.aliyun.com/pypi/simple/ -i https://mirrors.aliyun.com/pypi/simple/
--trusted-host mirrors.aliyun.com --trusted-host mirrors.aliyun.com
# pythonocc-core==7.7.0
pyvista # ============================================
trimesh # 几何处理核心
numpy # ============================================
scipy # PythonOCC - CAD几何处理
dotenv pythonocc-core>=7.7.0
fastapi # 网格处理
numpy trimesh>=3.21.0
OCC # 科学计算
numpy>=1.24.0
scipy>=1.10.0
# 3D可视化
pyvista>=0.38.0
# ============================================
# Web框架 # Web框架
fastapi # ============================================
uvicorn fastapi>=0.100.0
pydantic uvicorn[standard]>=0.22.0
python-multipart pydantic>=2.0.0
python-multipart>=0.0.6
# ============================================
# 数据库 # 数据库
sqlalchemy # ============================================
psycopg2-binary sqlalchemy>=2.0.0
asyncpg psycopg2-binary>=2.9.0
asyncpg>=0.28.0
alembic>=1.11.0
# 对象存储 (RustFS S3v4) # ============================================
minio # 对象存储
aiohttp # ============================================
minio>=7.1.0
aiohttp>=3.8.0
# ============================================
# 消息队列 # 消息队列
kafka-python # ============================================
redis kafka-python>=2.0.2
redis>=4.5.0
# ============================================
# 认证与安全
# ============================================
python-jose[cryptography]>=3.3.0
bcrypt>=4.0.0
passlib>=1.7.4
email-validator>=2.0.0
# ============================================
# 工具库 # 工具库
aiofiles # ============================================
python-dotenv aiofiles>=23.0.0
jinja2 python-dotenv>=1.0.0
jinja2>=3.1.0
pyyaml>=6.0
python-dateutil>=2.8.0
# JWT认证 # ============================================
python-jose[cryptography] # 日志与监控
bcrypt # ============================================
loguru>=0.7.0
# 邮箱验证 # ============================================
email-validator # 测试工具
# ============================================
pytest>=7.0.0
pytest-asyncio>=0.21.0
httpx>=0.24.0
# ============================================
# 开发工具
# ============================================
black>=23.0.0
flake8>=6.0.0
mypy>=1.0.0
+209
View File
@@ -0,0 +1,209 @@
"""
数据库迁移脚本 - 添加多上传支持字段
运行方式: python scripts/migrate_multi_upload.py
此脚本将:
1. 添加 upload_batch 字段到 stp_files 表
2. 添加 volume, surface_area, product_weight 快速查询字段到 stp_files 表
3. 移除 file_hash 字段的唯一约束(如果存在)
4. 为新字段创建索引
"""
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy import text
from database.database import async_engine, get_db_session
from utils.logger import get_logger
logger = get_logger(__name__)
async def check_column_exists(conn, table_name: str, column_name: str) -> bool:
"""检查列是否存在"""
result = await conn.execute(text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = :table_name
AND column_name = :column_name
"""), {"table_name": table_name, "column_name": column_name})
return result.fetchone() is not None
async def check_index_exists(conn, index_name: str) -> bool:
"""检查索引是否存在"""
result = await conn.execute(text("""
SELECT indexname
FROM pg_indexes
WHERE indexname = :index_name
"""), {"index_name": index_name})
return result.fetchone() is not None
async def check_unique_constraint_exists(conn, table_name: str, column_name: str) -> bool:
"""检查唯一约束是否存在"""
result = await conn.execute(text("""
SELECT conname
FROM pg_constraint
WHERE conrelid = :table_name::regclass
AND contype = 'u'
AND conname LIKE :pattern
"""), {"table_name": table_name, "pattern": f"%{column_name}%"})
return result.fetchone() is not None
async def run_migration():
"""执行迁移"""
logger.info("开始数据库迁移 - 多上传支持...")
async with async_engine.begin() as conn:
try:
await conn.execute(text("SELECT 1"))
logger.info("数据库连接成功")
except Exception as e:
logger.error(f"数据库连接失败: {e}")
return False
async with async_engine.begin() as conn:
migration_steps = []
if not await check_column_exists(conn, "stp_files", "upload_batch"):
migration_steps.append("添加 upload_batch 字段")
await conn.execute(text("""
ALTER TABLE stp_files
ADD COLUMN upload_batch VARCHAR(36)
"""))
logger.info("✓ 添加 upload_batch 字段")
if not await check_column_exists(conn, "stp_files", "volume"):
migration_steps.append("添加 volume 字段")
await conn.execute(text("""
ALTER TABLE stp_files
ADD COLUMN volume FLOAT
"""))
logger.info("✓ 添加 volume 字段")
if not await check_column_exists(conn, "stp_files", "surface_area"):
migration_steps.append("添加 surface_area 字段")
await conn.execute(text("""
ALTER TABLE stp_files
ADD COLUMN surface_area FLOAT
"""))
logger.info("✓ 添加 surface_area 字段")
if not await check_column_exists(conn, "stp_files", "product_weight"):
migration_steps.append("添加 product_weight 字段")
await conn.execute(text("""
ALTER TABLE stp_files
ADD COLUMN product_weight FLOAT
"""))
logger.info("✓ 添加 product_weight 字段")
if not await check_index_exists(conn, "ix_stp_files_upload_batch"):
migration_steps.append("创建 upload_batch 索引")
await conn.execute(text("""
CREATE INDEX ix_stp_files_upload_batch
ON stp_files(upload_batch)
"""))
logger.info("✓ 创建 upload_batch 索引")
if not await check_index_exists(conn, "ix_stp_files_file_hash"):
migration_steps.append("创建 file_hash 索引")
await conn.execute(text("""
CREATE INDEX ix_stp_files_file_hash
ON stp_files(file_hash)
"""))
logger.info("✓ 创建 file_hash 索引")
if not await check_index_exists(conn, "ix_stp_files_original_filename"):
migration_steps.append("创建 original_filename 索引")
await conn.execute(text("""
CREATE INDEX ix_stp_files_original_filename
ON stp_files(original_filename)
"""))
logger.info("✓ 创建 original_filename 索引")
try:
result = await conn.execute(text("""
SELECT conname
FROM pg_constraint
WHERE conrelid = 'stp_files'::regclass
AND contype = 'u'
"""))
unique_constraints = result.fetchall()
for constraint in unique_constraints:
constraint_name = constraint[0]
if 'file_hash' in constraint_name.lower():
migration_steps.append(f"删除唯一约束 {constraint_name}")
await conn.execute(text(f"""
ALTER TABLE stp_files
DROP CONSTRAINT {constraint_name}
"""))
logger.info(f"✓ 删除唯一约束: {constraint_name}")
except Exception as e:
logger.warning(f"检查唯一约束时出错(可能不存在): {e}")
if migration_steps:
logger.info(f"\n迁移完成,执行了 {len(migration_steps)} 个步骤:")
for step in migration_steps:
logger.info(f" - {step}")
else:
logger.info("\n无需迁移,所有字段和索引已存在")
logger.info("数据库迁移完成!")
return True
async def rollback_migration():
"""回滚迁移"""
logger.info("开始回滚数据库迁移...")
async with async_engine.begin() as conn:
try:
if await check_index_exists(conn, "ix_stp_files_upload_batch"):
await conn.execute(text("DROP INDEX IF EXISTS ix_stp_files_upload_batch"))
logger.info("✓ 删除 upload_batch 索引")
if await check_index_exists(conn, "ix_stp_files_file_hash"):
await conn.execute(text("DROP INDEX IF EXISTS ix_stp_files_file_hash"))
logger.info("✓ 删除 file_hash 索引")
if await check_column_exists(conn, "stp_files", "upload_batch"):
await conn.execute(text("ALTER TABLE stp_files DROP COLUMN upload_batch"))
logger.info("✓ 删除 upload_batch 字段")
if await check_column_exists(conn, "stp_files", "volume"):
await conn.execute(text("ALTER TABLE stp_files DROP COLUMN volume"))
logger.info("✓ 删除 volume 字段")
if await check_column_exists(conn, "stp_files", "surface_area"):
await conn.execute(text("ALTER TABLE stp_files DROP COLUMN surface_area"))
logger.info("✓ 删除 surface_area 字段")
if await check_column_exists(conn, "stp_files", "product_weight"):
await conn.execute(text("ALTER TABLE stp_files DROP COLUMN product_weight"))
logger.info("✓ 删除 product_weight 字段")
logger.info("回滚完成!")
except Exception as e:
logger.error(f"回滚失败: {e}")
return False
return True
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="数据库迁移脚本 - 多上传支持")
parser.add_argument("--rollback", action="store_true", help="回滚迁移")
args = parser.parse_args()
if args.rollback:
asyncio.run(rollback_migration())
else:
asyncio.run(run_migration())
+439
View File
@@ -0,0 +1,439 @@
#!/usr/bin/env python3
"""
STP文件几何验证脚本
使用FreeCAD命令行验证PythonOCC提取的几何数据是否正确
使用方法:
freecad -c verify_stp.py <stp_file_path>
或直接运行:
python verify_stp.py <stp_file_path>
"""
import sys
import os
import json
from datetime import datetime
from pathlib import Path
# 添加项目路径
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "src"))
# 全局结果字典
verification_result = {
"file": "",
"timestamp": "",
"freecad": {},
"pythonocc": {},
"comparison": {},
"status": "pending"
}
def verify_with_freecad(stp_path):
"""使用FreeCAD验证STP文件"""
try:
import FreeCAD
import Part
import Mesh
import MeshPart
print(f"\n{'='*70}")
print(f"FreeCAD 验证")
print(f"{'='*70}\n")
# 创建新文档
doc = FreeCAD.newDocument("Verification")
# 导入STP文件
Part.insert(stp_path, "Verification")
# 获取所有形状对象
shapes = []
for obj in doc.Objects:
if hasattr(obj, 'Shape') and not obj.Shape.isNull():
shapes.append(obj.Shape)
if not shapes:
print("❌ 未找到有效的几何形状")
return None
# 合并所有形状
compound = shapes[0]
for s in shapes[1:]:
compound = compound.fuse(s)
# 提取几何数据
bbox = compound.BoundBox
# 计算体积和表面积
volume_mm3 = compound.Volume
surface_area_mm2 = compound.Area
# 获取质心
com = compound.CenterOfMass
# 拓扑信息
topology = {
"faces": len(compound.Faces),
"edges": len(compound.Edges),
"vertices": len(compound.Vertexes),
"wires": len(compound.Wires),
"shells": len(compound.Shells),
"solids": len(compound.Solids)
}
# 生成网格用于点云验证
mesh = MeshPart.meshFromShape(compound, LinearDeflection=0.1, AngularDeflection=0.5)
result = {
"volume_mm3": float(volume_mm3),
"volume_cm3": float(volume_mm3 / 1000),
"surface_area_mm2": float(surface_area_mm2),
"surface_area_cm2": float(surface_area_mm2 / 100),
"bounding_box": {
"x_min": float(bbox.XMin),
"x_max": float(bbox.XMax),
"y_min": float(bbox.YMin),
"y_max": float(bbox.YMax),
"z_min": float(bbox.ZMin),
"z_max": float(bbox.ZMax),
"x_length": float(bbox.XLength),
"y_length": float(bbox.YLength),
"z_length": float(bbox.ZLength),
"center": [float(bbox.Center.x), float(bbox.Center.y), float(bbox.Center.z)]
},
"center_of_mass": [float(com.x), float(com.y), float(com.z)],
"topology": topology,
"mesh": {
"points": mesh.CountPoints,
"facets": mesh.CountFacets
}
}
# 打印结果
print(f"体积: {result['volume_cm3']:.4f} cm³ ({result['volume_mm3']:.2f} mm³)")
print(f"表面积: {result['surface_area_cm2']:.4f} cm² ({result['surface_area_mm2']:.2f} mm²)")
print(f"\n边界框:")
print(f" X: {result['bounding_box']['x_length']:.4f} mm ({result['bounding_box']['x_min']:.2f} ~ {result['bounding_box']['x_max']:.2f})")
print(f" Y: {result['bounding_box']['y_length']:.4f} mm ({result['bounding_box']['y_min']:.2f} ~ {result['bounding_box']['y_max']:.2f})")
print(f" Z: {result['bounding_box']['z_length']:.4f} mm ({result['bounding_box']['z_min']:.2f} ~ {result['bounding_box']['z_max']:.2f})")
print(f" 中心: ({result['bounding_box']['center'][0]:.2f}, {result['bounding_box']['center'][1]:.2f}, {result['bounding_box']['center'][2]:.2f})")
print(f"\n质心: ({result['center_of_mass'][0]:.4f}, {result['center_of_mass'][1]:.4f}, {result['center_of_mass'][2]:.4f})")
print(f"\n拓扑信息:")
print(f" 面: {topology['faces']}")
print(f" 边: {topology['edges']}")
print(f" 顶点: {topology['vertices']}")
print(f" 线框: {topology['wires']}")
print(f" 壳体: {topology['shells']}")
print(f" 实体: {topology['solids']}")
print(f"\n网格:")
print(f" 点数: {result['mesh']['points']}")
print(f" 面数: {result['mesh']['facets']}")
# 关闭文档
FreeCAD.closeDocument("Verification")
return result
except Exception as e:
print(f"❌ FreeCAD验证失败: {e}")
import traceback
traceback.print_exc()
return None
def verify_with_pythonocc(stp_path):
"""使用PythonOCC验证STP文件"""
try:
from OCC.Core.STEPControl import STEPControl_Reader
from OCC.Core.IFSelect import IFSelect_RetDone
from OCC.Core.BRepTools import breptools_Read
from OCC.Core.TopExp import TopExp_Explorer
from OCC.Core.TopAbs import TopAbs_SOLID, TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
from OCC.Core.BRepGProp import brepgprop_VolumeProperties, brepgprop_SurfaceProperties
from OCC.Core.GProp import GProp_GProps
from OCC.Core.Bnd import Bnd_Box
from OCC.Core.BRepBndLib import brepbndlib_Add
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
from OCC.Core.TopLoc import TopLoc_Location
from OCC.Core.BRep import BRep_Tool
print(f"\n{'='*70}")
print(f"PythonOCC 验证")
print(f"{'='*70}\n")
# 读取STP文件
reader = STEPControl_Reader()
status = reader.ReadFile(stp_path)
if status != IFSelect_RetDone:
print("❌ 无法读取STP文件")
return None
reader.TransferRoots()
shape = reader.OneShape()
# 生成网格
mesh_gen = BRepMesh_IncrementalMesh(shape, 0.1)
mesh_gen.Perform()
# 计算体积
vol_props = GProp_GProps()
brepgprop_VolumeProperties(shape, vol_props)
volume_mm3 = vol_props.Mass()
com = vol_props.CentreOfMass()
# 计算表面积
surf_props = GProp_GProps()
brepgprop_SurfaceProperties(shape, surf_props)
surface_area_mm2 = surf_props.Mass()
# 计算边界框
bbox = Bnd_Box()
brepbndlib_Add(shape, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
# 拓扑统计
def count_topology(shape, top_type):
explorer = TopExp_Explorer(shape, top_type)
count = 0
while explorer.More():
count += 1
explorer.Next()
return count
topology = {
"faces": count_topology(shape, TopAbs_FACE),
"edges": count_topology(shape, TopAbs_EDGE),
"vertices": count_topology(shape, TopAbs_VERTEX),
"solids": count_topology(shape, TopAbs_SOLID)
}
# 统计网格顶点和三角形
mesh_points = 0
mesh_facets = 0
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
face = explorer.Current()
location = TopLoc_Location()
triangulation = BRep_Tool.Triangulation(face, location)
if triangulation:
mesh_points += triangulation.NbNodes()
mesh_facets += triangulation.NbTriangles()
explorer.Next()
result = {
"volume_mm3": float(volume_mm3),
"volume_cm3": float(volume_mm3 / 1000),
"surface_area_mm2": float(surface_area_mm2),
"surface_area_cm2": float(surface_area_mm2 / 100),
"bounding_box": {
"x_min": float(xmin),
"x_max": float(xmax),
"y_min": float(ymin),
"y_max": float(ymax),
"z_min": float(zmin),
"z_max": float(zmax),
"x_length": float(xmax - xmin),
"y_length": float(ymax - ymin),
"z_length": float(zmax - zmin),
"center": [float((xmin + xmax) / 2), float((ymin + ymax) / 2), float((zmin + zmax) / 2)]
},
"center_of_mass": [float(com.X()), float(com.Y()), float(com.Z())],
"topology": topology,
"mesh": {
"points": mesh_points,
"facets": mesh_facets
}
}
# 打印结果
print(f"体积: {result['volume_cm3']:.4f} cm³ ({result['volume_mm3']:.2f} mm³)")
print(f"表面积: {result['surface_area_cm2']:.4f} cm² ({result['surface_area_mm2']:.2f} mm²)")
print(f"\n边界框:")
print(f" X: {result['bounding_box']['x_length']:.4f} mm ({result['bounding_box']['x_min']:.2f} ~ {result['bounding_box']['x_max']:.2f})")
print(f" Y: {result['bounding_box']['y_length']:.4f} mm ({result['bounding_box']['y_min']:.2f} ~ {result['bounding_box']['y_max']:.2f})")
print(f" Z: {result['bounding_box']['z_length']:.4f} mm ({result['bounding_box']['z_min']:.2f} ~ {result['bounding_box']['z_max']:.2f})")
print(f" 中心: ({result['bounding_box']['center'][0]:.2f}, {result['bounding_box']['center'][1]:.2f}, {result['bounding_box']['center'][2]:.2f})")
print(f"\n质心: ({result['center_of_mass'][0]:.4f}, {result['center_of_mass'][1]:.4f}, {result['center_of_mass'][2]:.4f})")
print(f"\n拓扑信息:")
print(f" 面: {topology['faces']}")
print(f" 边: {topology['edges']}")
print(f" 顶点: {topology['vertices']}")
print(f" 实体: {topology['solids']}")
print(f"\n网格:")
print(f" 点数: {result['mesh']['points']}")
print(f" 面数: {result['mesh']['facets']}")
return result
except Exception as e:
print(f"❌ PythonOCC验证失败: {e}")
import traceback
traceback.print_exc()
return None
def compare_results(freecad_result, pythonocc_result):
"""对比FreeCAD和PythonOCC的结果"""
print(f"\n{'='*70}")
print(f"对比结果")
print(f"{'='*70}\n")
if not freecad_result or not pythonocc_result:
print("❌ 无法对比,缺少数据")
return None
comparison = {}
# 体积对比
vol_diff = abs(freecad_result['volume_mm3'] - pythonocc_result['volume_mm3'])
vol_diff_percent = (vol_diff / freecad_result['volume_mm3']) * 100 if freecad_result['volume_mm3'] > 0 else 0
comparison['volume'] = {
"freecad": freecad_result['volume_cm3'],
"pythonocc": pythonocc_result['volume_cm3'],
"difference_mm3": vol_diff,
"difference_percent": vol_diff_percent
}
print(f"体积对比:")
print(f" FreeCAD: {freecad_result['volume_cm3']:.4f} cm³")
print(f" PythonOCC: {pythonocc_result['volume_cm3']:.4f} cm³")
print(f" 差异: {vol_diff:.4f} mm³ ({vol_diff_percent:.4f}%)")
print(f" 状态: {'✅ 通过' if vol_diff_percent < 1 else '❌ 超差'}")
# 表面积对比
area_diff = abs(freecad_result['surface_area_mm2'] - pythonocc_result['surface_area_mm2'])
area_diff_percent = (area_diff / freecad_result['surface_area_mm2']) * 100 if freecad_result['surface_area_mm2'] > 0 else 0
comparison['surface_area'] = {
"freecad": freecad_result['surface_area_cm2'],
"pythonocc": pythonocc_result['surface_area_cm2'],
"difference_mm2": area_diff,
"difference_percent": area_diff_percent
}
print(f"\n表面积对比:")
print(f" FreeCAD: {freecad_result['surface_area_cm2']:.4f} cm²")
print(f" PythonOCC: {pythonocc_result['surface_area_cm2']:.4f} cm²")
print(f" 差异: {area_diff:.4f} mm² ({area_diff_percent:.4f}%)")
print(f" 状态: {'✅ 通过' if area_diff_percent < 2 else '❌ 超差'}")
# 边界框对比
bbox_fc = freecad_result['bounding_box']
bbox_occ = pythonocc_result['bounding_box']
x_diff = abs(bbox_fc['x_length'] - bbox_occ['x_length'])
y_diff = abs(bbox_fc['y_length'] - bbox_occ['y_length'])
z_diff = abs(bbox_fc['z_length'] - bbox_occ['z_length'])
comparison['bounding_box'] = {
"x": {"freecad": bbox_fc['x_length'], "pythonocc": bbox_occ['x_length'], "difference": x_diff},
"y": {"freecad": bbox_fc['y_length'], "pythonocc": bbox_occ['y_length'], "difference": y_diff},
"z": {"freecad": bbox_fc['z_length'], "pythonocc": bbox_occ['z_length'], "difference": z_diff}
}
print(f"\n边界框对比:")
print(f" X轴: FreeCAD={bbox_fc['x_length']:.4f}, PythonOCC={bbox_occ['x_length']:.4f}, 差异={x_diff:.4f} mm")
print(f" Y轴: FreeCAD={bbox_fc['y_length']:.4f}, PythonOCC={bbox_occ['y_length']:.4f}, 差异={y_diff:.4f} mm")
print(f" Z轴: FreeCAD={bbox_fc['z_length']:.4f}, PythonOCC={bbox_occ['z_length']:.4f}, 差异={z_diff:.4f} mm")
# 质心对比
com_fc = freecad_result['center_of_mass']
com_occ = pythonocc_result['center_of_mass']
com_diff = [
abs(com_fc[0] - com_occ[0]),
abs(com_fc[1] - com_occ[1]),
abs(com_fc[2] - com_occ[2])
]
comparison['center_of_mass'] = {
"freecad": com_fc,
"pythonocc": com_occ,
"difference": com_diff
}
print(f"\n质心对比:")
print(f" FreeCAD: ({com_fc[0]:.4f}, {com_fc[1]:.4f}, {com_fc[2]:.4f})")
print(f" PythonOCC: ({com_occ[0]:.4f}, {com_occ[1]:.4f}, {com_occ[2]:.4f})")
print(f" 差异: ({com_diff[0]:.4f}, {com_diff[1]:.4f}, {com_diff[2]:.4f}) mm")
# 拓扑对比
topo_fc = freecad_result['topology']
topo_occ = pythonocc_result['topology']
comparison['topology'] = {
"faces": {"freecad": topo_fc['faces'], "pythonocc": topo_occ['faces']},
"edges": {"freecad": topo_fc['edges'], "pythonocc": topo_occ['edges']},
"vertices": {"freecad": topo_fc['vertices'], "pythonocc": topo_occ['vertices']}
}
print(f"\n拓扑对比:")
print(f" 面: FreeCAD={topo_fc['faces']}, PythonOCC={topo_occ['faces']}")
print(f" 边: FreeCAD={topo_fc['edges']}, PythonOCC={topo_occ['edges']}")
print(f" 顶点: FreeCAD={topo_fc['vertices']}, PythonOCC={topo_occ['vertices']}")
# 总体评估
passed = vol_diff_percent < 1 and area_diff_percent < 2
print(f"\n{'='*70}")
print(f"验证结果: {'✅ 通过' if passed else '❌ 失败'}")
print(f"{'='*70}\n")
comparison['overall_status'] = "passed" if passed else "failed"
return comparison
def main():
"""主函数"""
if len(sys.argv) < 2:
print("用法: python verify_stp.py <stp_file_path>")
print(" 或: freecad -c verify_stp.py <stp_file_path>")
sys.exit(1)
stp_path = sys.argv[1]
if not os.path.exists(stp_path):
print(f"❌ 文件不存在: {stp_path}")
sys.exit(1)
# 初始化结果
verification_result["file"] = stp_path
verification_result["timestamp"] = datetime.now().isoformat()
print(f"\n{'#'*70}")
print(f"# STP文件几何验证报告")
print(f"# 文件: {stp_path}")
print(f"# 时间: {verification_result['timestamp']}")
print(f"{'#'*70}")
# FreeCAD验证
freecad_result = verify_with_freecad(stp_path)
verification_result["freecad"] = freecad_result or {}
# PythonOCC验证
pythonocc_result = verify_with_pythonocc(stp_path)
verification_result["pythonocc"] = pythonocc_result or {}
# 对比结果
comparison = compare_results(freecad_result, pythonocc_result)
verification_result["comparison"] = comparison or {}
verification_result["status"] = comparison.get('overall_status', 'failed') if comparison else 'failed'
# 保存JSON报告
report_path = Path(stp_path).stem + "_verification_report.json"
with open(report_path, 'w', encoding='utf-8') as f:
json.dump(verification_result, f, indent=2, ensure_ascii=False)
print(f"\n验证报告已保存: {report_path}")
return verification_result
if __name__ == "__main__":
result = main()
sys.exit(0 if result['status'] == 'passed' else 1)
+19 -88
View File
@@ -218,111 +218,33 @@ async def debug_tasks():
@router.get("/history") @router.get("/history")
@router.post("/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)):
"""获取按文件名分组的文件历史记录""" """获取按文件名分组的文件历史记录(支持多上传)"""
from sqlalchemy import select storage_service = StorageIntegrationService()
from models.database import ProcessingTask, STPFile
# 从数据库查询所有处理任务 file_groups = await storage_service.get_all_file_groups(db_session)
result = await db_session.execute(
select(ProcessingTask, STPFile)
.join(STPFile, ProcessingTask.stp_file_id == STPFile.id)
.order_by(ProcessingTask.created_time.desc())
)
tasks = result.all()
# 按文件名分组
file_groups = {}
for task, stp_file in tasks:
filename = stp_file.original_filename
if filename not in file_groups:
file_groups[filename] = []
file_groups[filename].append({
"task_id": task.task_id,
"filename": filename,
"upload_time": task.created_time.isoformat() if task.created_time else "",
"status": task.status,
"file_size": stp_file.file_size
})
# 构建返回数据
files = []
for filename, file_tasks in file_groups.items():
# 按上传时间排序
file_tasks.sort(key=lambda x: x.get("upload_time", ""), reverse=True)
files.append({
"filename": filename,
"record_count": len(file_tasks),
"last_upload": file_tasks[0].get("upload_time", ""),
"first_upload": file_tasks[-1].get("upload_time", "") if len(file_tasks) > 1 else ""
})
# 按最后上传时间排序
files.sort(key=lambda x: x["last_upload"], reverse=True)
return { return {
"total_files": len(files), "total_files": len(file_groups),
"files": files "files": file_groups
} }
@router.get("/history/{filename}") @router.get("/history/{filename}")
@router.post("/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)):
"""获取指定文件名的所有记录""" """获取指定文件名的所有上传记录(支持多上传历史)"""
# URL解码文件名
import urllib.parse import urllib.parse
decoded_filename = urllib.parse.unquote(filename) decoded_filename = urllib.parse.unquote(filename)
# 从数据库查询指定文件名的所有处理任务 storage_service = StorageIntegrationService()
from sqlalchemy import select file_records = await storage_service.get_file_history_by_filename(
from models.database import ProcessingTask, STPFile db_session,
decoded_filename
result = await db_session.execute(
select(ProcessingTask, STPFile)
.join(STPFile, ProcessingTask.stp_file_id == STPFile.id)
.where(STPFile.original_filename == decoded_filename)
.order_by(ProcessingTask.created_time.desc())
) )
tasks = result.all()
# 构建返回数据
file_records = []
for task, stp_file in tasks:
file_records.append({
"task_id": task.task_id,
"filename": stp_file.original_filename,
"file_size": stp_file.file_size,
"upload_time": task.created_time.isoformat() if task.created_time else "",
"status": task.status,
"completed_at": task.completed_time.isoformat() if task.completed_time else ""
})
# 按上传时间排序(最新的在前)
file_records.sort(key=lambda x: x.get("upload_time", ""), reverse=True)
return file_records return file_records
@router.get("/history")
@router.post("/history")
async def history_page(request: Request):
"""历史记录页面"""
from fastapi.templating import Jinja2Templates
import os
# 简化路径配置,直接使用当前工作目录下的templates文件夹
templates_dir = os.path.join(os.getcwd(), "templates")
templates = Jinja2Templates(directory=templates_dir)
return templates.TemplateResponse("history.html", {
"request": request,
"pythonocc_available": True,
"version": "3.0.0"
})
@router.get("/result/{task_id}") @router.get("/result/{task_id}")
@router.post("/result/{task_id}") @router.post("/result/{task_id}")
async def result_page(request: Request, task_id: str, db_session: AsyncSession = Depends(get_db_session)): async def result_page(request: Request, task_id: str, db_session: AsyncSession = Depends(get_db_session)):
@@ -698,6 +620,15 @@ async def process_file_core(
# 保存质量指标和分析摘要到数据库 # 保存质量指标和分析摘要到数据库
await _save_analysis_metrics(db_session, stp_file_id, analysis_result) await _save_analysis_metrics(db_session, stp_file_id, analysis_result)
# 9.6 更新STP文件的分析摘要字段(用于快速查询)
await storage_service.update_stp_file_analysis_summary(
db_session,
stp_file_id,
volume=volume_mm3,
surface_area=surface_area_mm2,
product_weight=product_weight_g
)
# 10. 完成处理 # 10. 完成处理
await storage_service.update_stp_file_status(db_session, stp_file_id, "completed") await storage_service.update_stp_file_status(db_session, stp_file_id, "completed")
await storage_service.update_task_status( await storage_service.update_task_status(
+21 -6
View File
@@ -115,7 +115,7 @@ class RolePermission(Base):
return f"<RolePermission(role_id={self.role_id}, permission_id={self.permission_id})>" return f"<RolePermission(role_id={self.role_id}, permission_id={self.permission_id})>"
class STPFile(Base): class STPFile(Base):
"""STP源文件元数据表""" """STP源文件元数据表 - 支持同一文件多次上传"""
__tablename__ = "stp_files" __tablename__ = "stp_files"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
@@ -127,19 +127,27 @@ class STPFile(Base):
object_url = Column(String(1000), nullable=True) # 预签名URL(可选) object_url = Column(String(1000), nullable=True) # 预签名URL(可选)
# 文件信息 # 文件信息
original_filename = Column(String(255), nullable=False) original_filename = Column(String(255), nullable=False, index=True) # 添加索引支持按文件名查询
file_size = Column(Integer, nullable=False) file_size = Column(Integer, nullable=False)
file_hash = Column(String(64), unique=True, index=True) # SHA256 hash file_hash = Column(String(64), index=True) # 移除unique约束,允许同一文件多次上传
mime_type = Column(String(50), default="application/octet-stream") mime_type = Column(String(50), default="application/octet-stream")
# 上传批次标识 - 用于区分同一文件的多次上传
upload_batch = Column(String(36), index=True) # UUID批次号
# 时间戳 # 时间戳
upload_time = Column(DateTime, default=func.now()) upload_time = Column(DateTime, default=func.now())
processed_time = Column(DateTime, nullable=True) processed_time = Column(DateTime, nullable=True)
# 状态 # 状态
status = Column(String(20), default="pending") # pending, processing, completed, failed status = Column(String(20), default="pending", index=True) # pending, processing, completed, failed
error_message = Column(Text, nullable=True) error_message = Column(Text, nullable=True)
# 分析摘要 - 快速查询字段
volume = Column(Float, nullable=True) # 体积 mm³
surface_area = Column(Float, nullable=True) # 表面积 mm²
product_weight = Column(Float, nullable=True) # 产品重量 g
# 保留旧字段以兼容 # 保留旧字段以兼容
file_path = Column(String(500), nullable=True) # 本地路径(已弃用) file_path = Column(String(500), nullable=True) # 本地路径(已弃用)
file_content = Column(LargeBinary, nullable=True) # 本地存储(已弃用) file_content = Column(LargeBinary, nullable=True) # 本地存储(已弃用)
@@ -148,14 +156,15 @@ class STPFile(Base):
# 关联关系 # 关联关系
user = relationship("User", back_populates="stp_files") user = relationship("User", back_populates="stp_files")
geometry_data = relationship("GeometryData", back_populates="stp_file", uselist=False) geometry_data = relationship("GeometryData", back_populates="stp_file", uselist=False)
# 网格数据:一条 STP 记录对应一条网格摘要记录(详细 JSON 在 RustFS)
mesh_data = relationship("MeshData", back_populates="stp_file", uselist=False) mesh_data = relationship("MeshData", back_populates="stp_file", uselist=False)
mold_cavity_data = relationship("MoldCavityData", back_populates="stp_file", uselist=False) mold_cavity_data = relationship("MoldCavityData", back_populates="stp_file", uselist=False)
html_file = relationship("HTMLFile", back_populates="stp_file", uselist=False) html_file = relationship("HTMLFile", back_populates="stp_file", uselist=False)
analysis_metrics = relationship("AnalysisMetrics", back_populates="stp_file", uselist=False) analysis_metrics = relationship("AnalysisMetrics", back_populates="stp_file", uselist=False)
feature_detections = relationship("FeatureDetection", back_populates="stp_file")
design_recommendations = relationship("DesignRecommendation", back_populates="stp_file")
def __repr__(self): def __repr__(self):
return f"<STPFile(id={self.id}, object_key='{self.object_key}', status='{self.status}')>" return f"<STPFile(id={self.id}, original_filename='{self.original_filename}', status='{self.status}')>"
class GeometryData(Base): class GeometryData(Base):
"""几何数据JSON元数据表""" """几何数据JSON元数据表"""
@@ -360,6 +369,9 @@ class FeatureDetection(Base):
# 关联的几何数据 # 关联的几何数据
geometry_data_id = Column(Integer, ForeignKey("geometry_data.id"), nullable=True) geometry_data_id = Column(Integer, ForeignKey("geometry_data.id"), nullable=True)
# 关联关系
stp_file = relationship("STPFile", back_populates="feature_detections")
def __repr__(self): def __repr__(self):
return f"<FeatureDetection(id={self.id}, feature_type='{self.feature_type}', confidence={self.confidence})>" return f"<FeatureDetection(id={self.id}, feature_type='{self.feature_type}', confidence={self.confidence})>"
@@ -388,6 +400,9 @@ class DesignRecommendation(Base):
created_at = Column(DateTime, default=func.now()) created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, nullable=True) updated_at = Column(DateTime, nullable=True)
# 关联关系
stp_file = relationship("STPFile", back_populates="design_recommendations")
def __repr__(self): def __repr__(self):
return f"<DesignRecommendation(id={self.id}, rec_type='{self.rec_type}', priority='{self.priority}')>" return f"<DesignRecommendation(id={self.id}, rec_type='{self.rec_type}', priority='{self.priority}')>"
+149 -17
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
import json import json
from datetime import datetime from datetime import datetime
import uuid
from models.database import ( from models.database import (
STPFile, GeometryData, MeshData, MoldCavityData, STPFile, GeometryData, MeshData, MoldCavityData,
@@ -25,8 +26,12 @@ class StorageIntegrationService:
async def save_stp_file(self, session: AsyncSession, async def save_stp_file(self, session: AsyncSession,
file_path: Path, file_path: Path,
original_filename: str, original_filename: str,
user_id: Optional[int] = None) -> STPFile: user_id: Optional[int] = None,
"""保存STP文件到PostgreSQL元数据 + RustFS对象存储""" upload_batch: Optional[str] = None) -> STPFile:
"""保存STP文件到PostgreSQL元数据 + RustFS对象存储
支持同一文件多次上传,每次上传都会创建新记录
"""
# 1. 上传到RustFS # 1. 上传到RustFS
upload_result = await rustfs_manager.upload_file( upload_result = await rustfs_manager.upload_file(
@@ -35,23 +40,15 @@ class StorageIntegrationService:
original_filename=original_filename, original_filename=original_filename,
metadata={ metadata={
'original_filename': original_filename, 'original_filename': original_filename,
'user_id': str(user_id) if user_id else 'anonymous' 'user_id': str(user_id) if user_id else 'anonymous',
'upload_batch': upload_batch or str(uuid.uuid4())
} }
) )
file_hash = upload_result['file_hash'] file_hash = upload_result['file_hash']
batch_id = upload_batch or str(uuid.uuid4())
# 2. 检查是否已存在相同文件 # 2. 创建新PostgreSQL记录(每次上传都创建新记录)
existing_file = await session.execute(
select(STPFile).where(STPFile.file_hash == file_hash)
)
existing_file = existing_file.scalar_one_or_none()
if existing_file:
logger.info(f"文件已存在,返回现有记录: {existing_file.id}")
return existing_file
# 3. 创建新PostgreSQL记录
stp_file = STPFile( stp_file = STPFile(
user_id=user_id, user_id=user_id,
object_key=upload_result['object_key'], object_key=upload_result['object_key'],
@@ -59,15 +56,16 @@ class StorageIntegrationService:
original_filename=original_filename, original_filename=original_filename,
file_size=upload_result['file_size'], file_size=upload_result['file_size'],
file_hash=file_hash, file_hash=file_hash,
upload_batch=batch_id,
status="uploaded", status="uploaded",
file_path=str(file_path) # 保留本地路径以兼容 file_path=str(file_path)
) )
session.add(stp_file) session.add(stp_file)
await session.commit() await session.commit()
await session.refresh(stp_file) await session.refresh(stp_file)
logger.info(f"STP文件保存成功 RustFS: {stp_file.id}") logger.info(f"STP文件保存成功 RustFS: {stp_file.id}, 批次: {batch_id}")
return stp_file return stp_file
async def create_processing_task(self, session: AsyncSession, async def create_processing_task(self, session: AsyncSession,
@@ -415,7 +413,7 @@ class StorageIntegrationService:
resource_type=resource_type, resource_type=resource_type,
resource_id=resource_id, resource_id=resource_id,
description=description, description=description,
metadata=metadata, meta_data=metadata,
ip_address=ip_address, ip_address=ip_address,
user_agent=user_agent user_agent=user_agent
) )
@@ -529,6 +527,140 @@ class StorageIntegrationService:
return result return result
async def get_file_history_by_filename(
self,
session: AsyncSession,
filename: str,
user_id: Optional[int] = None,
limit: int = 50
) -> list:
"""获取同一文件名的所有上传历史记录"""
query = select(STPFile).where(
STPFile.original_filename == filename
).order_by(STPFile.upload_time.desc())
if user_id:
query = query.where(STPFile.user_id == user_id)
query = query.limit(limit)
result = await session.execute(query)
files = result.scalars().all()
return [
{
'id': f.id,
'upload_batch': f.upload_batch,
'upload_time': f.upload_time.isoformat() if f.upload_time else None,
'file_size': f.file_size,
'status': f.status,
'volume': f.volume,
'surface_area': f.surface_area,
'product_weight': f.product_weight,
'has_analysis': f.status == 'completed'
}
for f in files
]
async def get_all_file_groups(
self,
session: AsyncSession,
user_id: Optional[int] = None,
limit: int = 100
) -> list:
"""获取所有文件分组(按文件名分组),包含每个文件的最新分析结果"""
from sqlalchemy import func, desc
# 子查询:获取每个文件名的最新上传
subquery = (
select(
STPFile.original_filename,
func.max(STPFile.upload_time).label('latest_upload')
)
.group_by(STPFile.original_filename)
.order_by(desc('latest_upload'))
.limit(limit)
)
if user_id:
subquery = subquery.where(STPFile.user_id == user_id)
subquery = subquery.subquery()
# 主查询:获取最新记录和统计信息
query = (
select(STPFile)
.join(
subquery,
(STPFile.original_filename == subquery.c.original_filename) &
(STPFile.upload_time == subquery.c.latest_upload)
)
.order_by(STPFile.upload_time.desc())
)
result = await session.execute(query)
latest_files = result.scalars().all()
# 获取每个文件名的上传次数
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()
file_groups.append({
'filename': f.original_filename,
'latest_id': f.id,
'latest_upload_time': f.upload_time.isoformat() if f.upload_time else None,
'latest_status': f.status,
'upload_count': upload_count,
'file_size': f.file_size,
'volume': f.volume,
'surface_area': f.surface_area,
'product_weight': f.product_weight
})
return file_groups
async def update_stp_file_analysis_summary(
self,
session: AsyncSession,
stp_file_id: int,
volume: Optional[float] = None,
surface_area: Optional[float] = None,
product_weight: Optional[float] = None
):
"""更新STP文件的分析摘要字段(用于快速查询)"""
try:
update_data = {}
if volume is not None:
update_data['volume'] = volume
if surface_area is not None:
update_data['surface_area'] = surface_area
if product_weight is not None:
update_data['product_weight'] = product_weight
if update_data:
await session.execute(
update(STPFile)
.where(STPFile.id == stp_file_id)
.values(**update_data)
)
await session.commit()
logger.info(f"STP文件分析摘要更新: ID {stp_file_id}")
except Exception as e:
await session.rollback()
logger.error(f"更新STP文件分析摘要失败: {e}")
raise
async def delete_stp_file_cascade(self, session: AsyncSession, async def delete_stp_file_cascade(self, session: AsyncSession,
stp_file_id: int): stp_file_id: int):
"""级联删除STP文件及其所有关联数据""" """级联删除STP文件及其所有关联数据"""
+86 -13
View File
@@ -760,7 +760,10 @@ const MoldInsightView = {
polling: false, polling: false,
dragOver: false, dragOver: false,
progress: 0, progress: 0,
history: null history: null,
showFileHistory: false,
selectedFileHistory: null,
fileHistoryRecords: []
}); });
const loadHistory = async () => { const loadHistory = async () => {
@@ -771,6 +774,21 @@ const MoldInsightView = {
} }
}; };
const viewFileHistory = async (filename) => {
try {
state.selectedFileHistory = filename;
state.fileHistoryRecords = await apiRequest(`/api/history/${encodeURIComponent(filename)}`);
state.showFileHistory = true;
} catch (e) {
handleApiError(e, '加载文件历史');
}
};
const viewResult = (record) => {
state.showFileHistory = false;
router.push(`/moldinsight/result/${record.id}`);
};
const handleFileChange = (event) => { const handleFileChange = (event) => {
const file = event.target.files[0]; const file = event.target.files[0];
if (!file) return; if (!file) return;
@@ -844,6 +862,7 @@ const MoldInsightView = {
state.polling = false; state.polling = false;
state.progress = 100; state.progress = 100;
addNotification('分析完成', 'success'); addNotification('分析完成', 'success');
loadHistory();
router.push(`/moldinsight/result/${taskId}`); router.push(`/moldinsight/result/${taskId}`);
return; return;
} }
@@ -879,7 +898,9 @@ const MoldInsightView = {
handleDrop, handleDrop,
uploadFile, uploadFile,
formatFileSize, formatFileSize,
formatDateTime formatDateTime,
viewFileHistory,
viewResult
}; };
}, },
template: ` template: `
@@ -933,38 +954,90 @@ const MoldInsightView = {
</div> </div>
<div v-if="state.history?.files?.length" class="section"> <div v-if="state.history?.files?.length" class="section">
<h2 class="section-title">最近分析</h2> <h2 class="section-title">分析历史</h2>
<div class="table-container"> <div class="table-container">
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>文件名</th> <th>文件名</th>
<th>大小</th> <th>上传次数</th>
<th>状态</th> <th>文件大小</th>
<th>分析时间</th> <th>最新状态</th>
<th>最新分析时间</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="file in state.history.files.slice(0, 5)" :key="file.task_id"> <tr v-for="file in state.history.files" :key="file.filename">
<td>{{ file.filename }}</td> <td>{{ file.filename }}</td>
<td>
<span class="badge badge-info">{{ file.upload_count }} 次</span>
</td>
<td>{{ formatFileSize(file.file_size) }}</td> <td>{{ formatFileSize(file.file_size) }}</td>
<td> <td>
<span :class="['badge', file.status === 'completed' ? 'badge-success' : file.status === 'failed' ? 'badge-error' : 'badge-warning']"> <span :class="['badge', file.latest_status === 'completed' ? 'badge-success' : file.latest_status === 'failed' ? 'badge-error' : 'badge-warning']">
{{ file.status }} {{ file.latest_status }}
</span> </span>
</td> </td>
<td>{{ formatDateTime(file.created_at) }}</td> <td>{{ formatDateTime(file.latest_upload_time) }}</td>
<td> <td>
<button v-if="file.status === 'completed'" class="btn-sm btn-primary" @click="$router.push('/moldinsight/result/' + file.task_id)"> <div class="action-buttons">
查看 <button v-if="file.latest_status === 'completed'" class="btn-sm btn-primary" @click="router.push('/moldinsight/result/' + file.latest_id)">
</button> 查看最新
</button>
<button class="btn-sm btn-secondary" @click="viewFileHistory(file.filename)">
历史记录
</button>
</div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<div v-if="state.showFileHistory" class="modal-overlay" @click.self="state.showFileHistory = false">
<div class="modal-content" style="max-width: 800px;">
<div class="modal-header">
<h2>{{ state.selectedFileHistory }} - 历史记录</h2>
<button class="modal-close" @click="state.showFileHistory = false">×</button>
</div>
<div class="modal-body">
<table class="data-table">
<thead>
<tr>
<th>上传时间</th>
<th>文件大小</th>
<th>状态</th>
<th>体积 (mm³)</th>
<th>表面积 (mm²)</th>
<th>重量 (g)</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="record in state.fileHistoryRecords" :key="record.id">
<td>{{ formatDateTime(record.upload_time) }}</td>
<td>{{ formatFileSize(record.file_size) }}</td>
<td>
<span :class="['badge', record.status === 'completed' ? 'badge-success' : record.status === 'failed' ? 'badge-error' : 'badge-warning']">
{{ record.status }}
</span>
</td>
<td>{{ record.volume ? formatNumber(record.volume) : '-' }}</td>
<td>{{ record.surface_area ? formatNumber(record.surface_area) : '-' }}</td>
<td>{{ record.product_weight ? record.product_weight.toFixed(2) : '-' }}</td>
<td>
<button v-if="record.has_analysis" class="btn-sm btn-primary" @click="viewResult(record)">
查看详情
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div> </div>
` `
}; };