diff --git a/CHANGE.log.md b/CHANGE.log.md new file mode 100644 index 0000000..399f72c --- /dev/null +++ b/CHANGE.log.md @@ -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界面 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7f45aa9 --- /dev/null +++ b/CHANGELOG.md @@ -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修复,向后兼容 diff --git a/README.md b/README.md index 5c2b294..fe2255c 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,294 @@ -# MoldInsight - 模具几何分析系统 +# Gemold - 模具制造管理系统 + +
+ +![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) + +
+ +## 项目简介 + +**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} \ No newline at end of file +### 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) diff --git a/requirements.txt b/requirements.txt index c6e0500..a04bfd2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/scripts/migrate_multi_upload.py b/scripts/migrate_multi_upload.py new file mode 100644 index 0000000..73bd628 --- /dev/null +++ b/scripts/migrate_multi_upload.py @@ -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()) diff --git a/scripts/verify_stp.py b/scripts/verify_stp.py new file mode 100644 index 0000000..9c802ee --- /dev/null +++ b/scripts/verify_stp.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +""" +STP文件几何验证脚本 +使用FreeCAD命令行验证PythonOCC提取的几何数据是否正确 + +使用方法: + freecad -c verify_stp.py + +或直接运行: + python verify_stp.py +""" + +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 ") + print(" 或: freecad -c verify_stp.py ") + 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) diff --git a/src/api/routes.py b/src/api/routes.py index 50f36fb..13a4ae3 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -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( diff --git a/src/models/database.py b/src/models/database.py index e9a07c9..000a065 100644 --- a/src/models/database.py +++ b/src/models/database.py @@ -115,7 +115,7 @@ class RolePermission(Base): return f"" 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"" + return f"" 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"" @@ -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"" diff --git a/src/services/storage_integration_rustfs.py b/src/services/storage_integration_rustfs.py index 4095bbb..024a191 100644 --- a/src/services/storage_integration_rustfs.py +++ b/src/services/storage_integration_rustfs.py @@ -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文件及其所有关联数据""" diff --git a/static/vue-app.js b/static/vue-app.js index bdb5a12..93d64d6 100644 --- a/static/vue-app.js +++ b/static/vue-app.js @@ -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 = {
-

最近分析

+

分析历史

- - - + + + + - + + - +
文件名大小状态分析时间上传次数文件大小最新状态最新分析时间 操作
{{ file.filename }} + {{ file.upload_count }} 次 + {{ formatFileSize(file.file_size) }} - - {{ file.status }} + + {{ file.latest_status }} {{ formatDateTime(file.created_at) }}{{ formatDateTime(file.latest_upload_time) }} - +
+ + +
+ + ` };