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/ # 源代码目录
│ ├── main.py # 主程序入口
│ ├── api/ # API路由
│ │ └── routes.py
│ │ ├── routes.py # 主API路由
│ │ ├── auth_routes.py # 认证API
│ │ └── inventory_routes.py # 进销存API
│ ├── core/ # 核心业务逻辑
│ │ ├── stp_parser.py
│ │ ├── geometry_analyzer.py
│ │ └── mesh_generator.py
│ │ ├── stp_parser.py # STP文件解析
│ │ ├── geometry_analyzer.py # 几何分析
│ │ ├── mesh_generator.py # 网格生成
│ │ └── mold_generator.py # 模具生成
│ ├── models/ # 数据模型
│ │ ├── schemas.py
│ │ └── database.py
│ ├── services/ # 业务服务
│ │ └── storage_service.py
│ │ ├── database.py # 数据库模型
│ │ └── schemas.py # 数据模式
│ ├── services/ # 服务层
│ │ ├── auth_service.py # 认证服务
│ │ ├── storage_service.py # 存储服务
│ │ └── storage_integration_rustfs.py # RustFS集成
│ ├── database/ # 数据库管理
│ │ ├── database.py
│ │ └── init_db.py
│ │ ├── database.py # 数据库连接
│ │ └── init_db.py # 数据库初始化
│ └── utils/ # 工具类
│ ├── logger.py
│ ├── file_handler.py
│ └── html_generator.py
├── config/ # 配置文件
│ └── settings.py
│ ├── logger.py # 日志工具
│ ├── file_handler.py # 文件处理
│ └── html_generator.py # HTML生成
├── static/ # 静态文件
│ ├── script.js
│ └── style.css
├── templates/ # HTML模板
│ └── index.html
│ ├── vue-app.js # Vue应用
│ ├── style.css # 样式文件
│ └── index.html # 主页面
├── scripts/ # 脚本工具
│ └── verify_stp.py # STP验证脚本
├── uploads/ # 上传文件目录
├── html_output/ # 生成的HTML文件
├── logs/ # 日志文件
├── tests/ # 测试文件
├── docs/ # 文档
├── requirements.txt # 依赖包
├── docker-compose.yml # Docker配置
├── start.sh # Linux启动脚本
├── start.bat # Windows启动脚本
└── .env # 环境变量
```
## 功能特性
- ✅ STP文件解析和几何分析
- ✅ JSON数据导出
- ✅ PostgreSQL数据库存储
- ✅ 3D可视化HTML生成
- ✅ Web界面文件上传
- ✅ 任务状态跟踪
## 这是一个模具设计项目:
- 输入: 产品的三维模型(STP文件)
- 输出: 模具设计方案(型腔、型芯、工艺参数等)
- 目标: 为泡沫产品(ABS、PP、PC等材料)设计铝制模具
---
## 快速开始
1. 安装依赖:
### 1. 环境要求
- Python 3.10+
- PostgreSQL 13+
- Conda (推荐)
### 2. 安装依赖
```bash
# 创建Conda环境
conda create -n py_3.12 python=3.12
# 激活环境
conda activate py_3.12
# 安装依赖
pip install -r requirements.txt
```
### 3. 配置环境变量
2. 配置数据库连接(修改.env文件)
创建 `.env` 文件:
3. 启动服务:
```bash
python src/main.py
```env
# 数据库配置
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/gemold
# 服务配置
SECRET_KEY=your-secret-key-here
PORT=8000
# 管理员配置
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
```
4. 访问服务
- 查看端口配置:打开 `.env` 文件查看 `PORT` 的值
- 默认端口:8000
- 访问地址:http://localhost:${PORT}
### 4. 初始化数据库
```bash
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/
--trusted-host mirrors.aliyun.com
# pythonocc-core==7.7.0
pyvista
trimesh
numpy
scipy
dotenv
fastapi
numpy
OCC
# ============================================
# 几何处理核心
# ============================================
# PythonOCC - CAD几何处理
pythonocc-core>=7.7.0
# 网格处理
trimesh>=3.21.0
# 科学计算
numpy>=1.24.0
scipy>=1.10.0
# 3D可视化
pyvista>=0.38.0
# ============================================
# Web框架
fastapi
uvicorn
pydantic
python-multipart
# ============================================
fastapi>=0.100.0
uvicorn[standard]>=0.22.0
pydantic>=2.0.0
python-multipart>=0.0.6
# ============================================
# 数据库
sqlalchemy
psycopg2-binary
asyncpg
# ============================================
sqlalchemy>=2.0.0
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
jinja2
# ============================================
aiofiles>=23.0.0
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.post("/history")
async def get_file_history(db_session: AsyncSession = Depends(get_db_session)):
"""获取按文件名分组的文件历史记录"""
from sqlalchemy import select
from models.database import ProcessingTask, STPFile
"""获取按文件名分组的文件历史记录(支持多上传)"""
storage_service = StorageIntegrationService()
# 从数据库查询所有处理任务
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)
file_groups = await storage_service.get_all_file_groups(db_session)
return {
"total_files": len(files),
"files": files
"total_files": len(file_groups),
"files": file_groups
}
@router.get("/history/{filename}")
@router.post("/history/{filename}")
async def get_file_records(filename: str, db_session: AsyncSession = Depends(get_db_session)):
"""获取指定文件名的所有记录"""
# URL解码文件名
"""获取指定文件名的所有上传记录(支持多上传历史)"""
import urllib.parse
decoded_filename = urllib.parse.unquote(filename)
# 从数据库查询指定文件名的所有处理任务
from sqlalchemy import select
from models.database import ProcessingTask, STPFile
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())
storage_service = StorageIntegrationService()
file_records = await storage_service.get_file_history_by_filename(
db_session,
decoded_filename
)
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
@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.post("/result/{task_id}")
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)
# 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. 完成处理
await storage_service.update_stp_file_status(db_session, stp_file_id, "completed")
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})>"
class STPFile(Base):
"""STP源文件元数据表"""
"""STP源文件元数据表 - 支持同一文件多次上传"""
__tablename__ = "stp_files"
id = Column(Integer, primary_key=True, index=True)
@@ -127,19 +127,27 @@ class STPFile(Base):
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_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")
# 上传批次标识 - 用于区分同一文件的多次上传
upload_batch = Column(String(36), index=True) # UUID批次号
# 时间戳
upload_time = Column(DateTime, default=func.now())
processed_time = Column(DateTime, nullable=True)
# 状态
status = Column(String(20), default="pending") # pending, processing, completed, failed
status = Column(String(20), default="pending", index=True) # pending, processing, completed, failed
error_message = Column(Text, nullable=True)
# 分析摘要 - 快速查询字段
volume = Column(Float, nullable=True) # 体积 mm³
surface_area = Column(Float, nullable=True) # 表面积 mm²
product_weight = Column(Float, nullable=True) # 产品重量 g
# 保留旧字段以兼容
file_path = Column(String(500), nullable=True) # 本地路径(已弃用)
file_content = Column(LargeBinary, nullable=True) # 本地存储(已弃用)
@@ -148,14 +156,15 @@ class STPFile(Base):
# 关联关系
user = relationship("User", back_populates="stp_files")
geometry_data = relationship("GeometryData", back_populates="stp_file", uselist=False)
# 网格数据:一条 STP 记录对应一条网格摘要记录(详细 JSON 在 RustFS)
mesh_data = relationship("MeshData", back_populates="stp_file", uselist=False)
mold_cavity_data = relationship("MoldCavityData", back_populates="stp_file", uselist=False)
html_file = relationship("HTMLFile", back_populates="stp_file", uselist=False)
analysis_metrics = relationship("AnalysisMetrics", back_populates="stp_file", uselist=False)
feature_detections = relationship("FeatureDetection", back_populates="stp_file")
design_recommendations = relationship("DesignRecommendation", back_populates="stp_file")
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):
"""几何数据JSON元数据表"""
@@ -360,6 +369,9 @@ class FeatureDetection(Base):
# 关联的几何数据
geometry_data_id = Column(Integer, ForeignKey("geometry_data.id"), nullable=True)
# 关联关系
stp_file = relationship("STPFile", back_populates="feature_detections")
def __repr__(self):
return f"<FeatureDetection(id={self.id}, feature_type='{self.feature_type}', confidence={self.confidence})>"
@@ -388,6 +400,9 @@ class DesignRecommendation(Base):
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, nullable=True)
# 关联关系
stp_file = relationship("STPFile", back_populates="design_recommendations")
def __repr__(self):
return f"<DesignRecommendation(id={self.id}, rec_type='{self.rec_type}', priority='{self.priority}')>"
+149 -17
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from typing import Optional, Dict, Any
import json
from datetime import datetime
import uuid
from models.database import (
STPFile, GeometryData, MeshData, MoldCavityData,
@@ -25,8 +26,12 @@ class StorageIntegrationService:
async def save_stp_file(self, session: AsyncSession,
file_path: Path,
original_filename: str,
user_id: Optional[int] = None) -> STPFile:
"""保存STP文件到PostgreSQL元数据 + RustFS对象存储"""
user_id: Optional[int] = None,
upload_batch: Optional[str] = None) -> STPFile:
"""保存STP文件到PostgreSQL元数据 + RustFS对象存储
支持同一文件多次上传,每次上传都会创建新记录
"""
# 1. 上传到RustFS
upload_result = await rustfs_manager.upload_file(
@@ -35,23 +40,15 @@ class StorageIntegrationService:
original_filename=original_filename,
metadata={
'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']
# 2. 检查是否已存在相同文件
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
batch_id = upload_batch or str(uuid.uuid4())
# 3. 创建新PostgreSQL记录
# 2. 创建新PostgreSQL记录(每次上传都创建新记录)
stp_file = STPFile(
user_id=user_id,
object_key=upload_result['object_key'],
@@ -59,15 +56,16 @@ class StorageIntegrationService:
original_filename=original_filename,
file_size=upload_result['file_size'],
file_hash=file_hash,
upload_batch=batch_id,
status="uploaded",
file_path=str(file_path) # 保留本地路径以兼容
file_path=str(file_path)
)
session.add(stp_file)
await session.commit()
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
async def create_processing_task(self, session: AsyncSession,
@@ -415,7 +413,7 @@ class StorageIntegrationService:
resource_type=resource_type,
resource_id=resource_id,
description=description,
metadata=metadata,
meta_data=metadata,
ip_address=ip_address,
user_agent=user_agent
)
@@ -529,6 +527,140 @@ class StorageIntegrationService:
return result
async def get_file_history_by_filename(
self,
session: AsyncSession,
filename: str,
user_id: Optional[int] = None,
limit: int = 50
) -> list:
"""获取同一文件名的所有上传历史记录"""
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,
stp_file_id: int):
"""级联删除STP文件及其所有关联数据"""
+86 -13
View File
@@ -760,7 +760,10 @@ const MoldInsightView = {
polling: false,
dragOver: false,
progress: 0,
history: null
history: null,
showFileHistory: false,
selectedFileHistory: null,
fileHistoryRecords: []
});
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 file = event.target.files[0];
if (!file) return;
@@ -844,6 +862,7 @@ const MoldInsightView = {
state.polling = false;
state.progress = 100;
addNotification('分析完成', 'success');
loadHistory();
router.push(`/moldinsight/result/${taskId}`);
return;
}
@@ -879,7 +898,9 @@ const MoldInsightView = {
handleDrop,
uploadFile,
formatFileSize,
formatDateTime
formatDateTime,
viewFileHistory,
viewResult
};
},
template: `
@@ -933,38 +954,90 @@ const MoldInsightView = {
</div>
<div v-if="state.history?.files?.length" class="section">
<h2 class="section-title">最近分析</h2>
<h2 class="section-title">分析历史</h2>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>文件名</th>
<th>大小</th>
<th>状态</th>
<th>分析时间</th>
<th>上传次数</th>
<th>文件大小</th>
<th>最新状态</th>
<th>最新分析时间</th>
<th>操作</th>
</tr>
</thead>
<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>
<span class="badge badge-info">{{ file.upload_count }} 次</span>
</td>
<td>{{ formatFileSize(file.file_size) }}</td>
<td>
<span :class="['badge', file.status === 'completed' ? 'badge-success' : file.status === 'failed' ? 'badge-error' : 'badge-warning']">
{{ file.status }}
<span :class="['badge', file.latest_status === 'completed' ? 'badge-success' : file.latest_status === 'failed' ? 'badge-error' : 'badge-warning']">
{{ file.latest_status }}
</span>
</td>
<td>{{ formatDateTime(file.created_at) }}</td>
<td>{{ formatDateTime(file.latest_upload_time) }}</td>
<td>
<button v-if="file.status === 'completed'" class="btn-sm btn-primary" @click="$router.push('/moldinsight/result/' + file.task_id)">
查看
</button>
<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 class="btn-sm btn-secondary" @click="viewFileHistory(file.filename)">
历史记录
</button>
</div>
</td>
</tr>
</tbody>
</table>
</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>
`
};