# storage/object_storage.py """MinIO/S3 对象存储服务""" from minio import Minio from minio.error import S3Error from pathlib import Path from typing import Optional, BinaryIO from io import BytesIO from utils.logger import get_logger import hashlib import uuid logger = get_logger(__name__) class ObjectStorageManager: """对象存储管理器 - MinIO/S3兼容""" def __init__(self): self.client: Optional[Minio] = None self.is_connected = False # 桶名称 self.buckets = { 'stp_files': 'moldinsight-stp-files', # STP/STEP文件 'geometry_data': 'moldinsight-geometry', # 几何数据JSON 'mold_cavities': 'moldinsight-mold-cavities', # 模具型腔数据 'html_files': 'moldinsight-html', # HTML报告文件 'user_files': 'moldinsight-user-files' # 用户上传的其他文件 } async def connect(self, endpoint: str, access_key: str, secret_key: str, secure: bool = False): """连接到MinIO/S3服务""" try: self.client = Minio( endpoint, access_key=access_key, secret_key=secret_key, secure=secure ) # 测试连接 self.client.list_buckets() self.is_connected = True logger.info(f"对象存储连接成功: {endpoint}") # 确保所有桶都存在 await self._ensure_buckets() except S3Error as e: logger.error(f"对象存储连接失败: {e}") self.is_connected = False raise async def _ensure_buckets(self): """确保所有必要的桶都存在""" for bucket_name in self.buckets.values(): try: if not self.client.bucket_exists(bucket_name): self.client.make_bucket(bucket_name) logger.info(f"创建存储桶: {bucket_name}") else: logger.debug(f"存储桶已存在: {bucket_name}") except S3Error as e: logger.error(f"创建存储桶失败 {bucket_name}: {e}") def _generate_object_key(self, original_filename: str, prefix: str = '') -> str: """生成对象存储的唯一键名""" # 提取文件扩展名 ext = Path(original_filename).suffix # 生成唯一ID unique_id = str(uuid.uuid4()) # 生成键名: prefix/unique_id + original_ext if prefix: return f"{prefix}/{unique_id}{ext}" return f"{unique_id}{ext}" async def upload_stp_file(self, file_path: Path, original_filename: str) -> dict: """上传STP文件到对象存储""" if not self.is_connected: raise RuntimeError("对象存储未连接") bucket_name = self.buckets['stp_files'] # 计算文件哈希 file_hash = self._calculate_file_hash(file_path) # 检查是否已存在 existing_key = await self._find_file_by_hash(bucket_name, file_hash) if existing_key: logger.info(f"文件已存在,跳过上传: {existing_key}") return { 'object_key': existing_key, 'file_hash': file_hash, 'already_exists': True } # 生成唯一键名 object_key = self._generate_object_key( original_filename, prefix='stp' ) # 上传文件 try: result = self.client.fput_object( bucket_name, object_key, str(file_path), content_type='application/octet-stream' ) logger.info(f"STP文件上传成功: {object_key}") return { 'object_key': object_key, 'file_hash': file_hash, 'file_size': result.size, 'etag': result.etag, 'already_exists': False } except S3Error as e: logger.error(f"STP文件上传失败: {e}") raise async def upload_geometry_data(self, geometry_json: dict, file_hash: str) -> dict: """上传几何数据JSON到对象存储""" if not self.is_connected: raise RuntimeError("对象存储未连接") bucket_name = self.buckets['geometry_data'] # 使用文件哈希作为键名的一部分 object_key = f"geometry/{file_hash}.json" # 转换为字节 import json json_bytes = json.dumps(geometry_json, ensure_ascii=False).encode('utf-8') # 上传 try: result = self.client.put_object( bucket_name, object_key, BytesIO(json_bytes), length=len(json_bytes), content_type='application/json' ) logger.info(f"几何数据上传成功: {object_key}") return { 'object_key': object_key, 'file_size': result.size, 'etag': result.etag } except S3Error as e: logger.error(f"几何数据上传失败: {e}") raise async def upload_mold_cavity_data(self, cavity_json: dict, file_hash: str) -> dict: """上传模具型腔数据到对象存储""" if not self.is_connected: raise RuntimeError("对象存储未连接") bucket_name = self.buckets['mold_cavities'] object_key = f"mold-cavity/{file_hash}.json" import json json_bytes = json.dumps(cavity_json, ensure_ascii=False).encode('utf-8') try: result = self.client.put_object( bucket_name, object_key, BytesIO(json_bytes), length=len(json_bytes), content_type='application/json' ) logger.info(f"模具型腔数据上传成功: {object_key}") return { 'object_key': object_key, 'file_size': result.size, 'etag': result.etag } except S3Error as e: logger.error(f"模具型腔数据上传失败: {e}") raise async def upload_html_file(self, html_content: str, original_filename: str, file_hash: str) -> dict: """上传HTML文件到对象存储""" if not self.is_connected: raise RuntimeError("对象存储未连接") bucket_name = self.buckets['html_files'] object_key = f"html/{file_hash}.html" html_bytes = html_content.encode('utf-8') try: result = self.client.put_object( bucket_name, object_key, BytesIO(html_bytes), length=len(html_bytes), content_type='text/html; charset=utf-8' ) logger.info(f"HTML文件上传成功: {object_key}") return { 'object_key': object_key, 'file_size': result.size, 'etag': result.etag } except S3Error as e: logger.error(f"HTML文件上传失败: {e}") raise async def download_file(self, bucket_type: str, object_key: str) -> bytes: """从对象存储下载文件""" if not self.is_connected: raise RuntimeError("对象存储未连接") bucket_name = self.buckets.get(bucket_type) if not bucket_name: raise ValueError(f"未知的桶类型: {bucket_type}") try: response = self.client.get_object(bucket_name, object_key) data = response.read() response.close() response.release_conn() logger.debug(f"文件下载成功: {object_key}") return data except S3Error as e: logger.error(f"文件下载失败 {object_key}: {e}") raise async def get_presigned_url(self, bucket_type: str, object_key: str, expires: int = 3600) -> str: """生成预签名URL(临时访问链接)""" if not self.is_connected: raise RuntimeError("对象存储未连接") bucket_name = self.buckets.get(bucket_type) if not bucket_name: raise ValueError(f"未知的桶类型: {bucket_type}") try: url = self.client.presigned_get_object( bucket_name, object_key, expires=expires ) return url except S3Error as e: logger.error(f"生成预签名URL失败: {e}") raise async def delete_file(self, bucket_type: str, object_key: str): """删除对象存储中的文件""" if not self.is_connected: raise RuntimeError("对象存储未连接") bucket_name = self.buckets.get(bucket_type) if not bucket_name: raise ValueError(f"未知的桶类型: {bucket_type}") try: self.client.remove_object(bucket_name, object_key) logger.info(f"文件删除成功: {object_key}") except S3Error as e: logger.error(f"文件删除失败 {object_key}: {e}") raise def _calculate_file_hash(self, file_path: Path) -> str: """计算文件的SHA256哈希""" sha256_hash = hashlib.sha256() with open(file_path, 'rb') as f: for byte_block in iter(lambda: f.read(4096), b""): sha256_hash.update(byte_block) return sha256_hash.hexdigest() async def _find_file_by_hash(self, bucket_name: str, file_hash: str) -> Optional[str]: """根据哈希查找已存在的文件""" try: objects = self.client.list_objects(bucket_name, recursive=True) for obj in objects: # 从对象键中提取哈希(如果有) if file_hash in obj.object_name: return obj.object_name return None except S3Error as e: logger.warning(f"查找文件哈希失败: {e}") return None async def get_file_info(self, bucket_type: str, object_key: str) -> dict: """获取文件信息""" if not self.is_connected: raise RuntimeError("对象存储未连接") bucket_name = self.buckets.get(bucket_type) if not bucket_name: raise ValueError(f"未知的桶类型: {bucket_type}") try: stat = self.client.stat_object(bucket_name, object_key) return { 'size': stat.size, 'etag': stat.etag, 'content_type': stat.content_type, 'last_modified': stat.last_modified } except S3Error as e: logger.error(f"获取文件信息失败: {e}") raise async def list_files(self, bucket_type: str, prefix: str = '') -> list: """列出存储桶中的文件""" if not self.is_connected: raise RuntimeError("对象存储未连接") bucket_name = self.buckets.get(bucket_type) if not bucket_name: raise ValueError(f"未知的桶类型: {bucket_type}") try: objects = self.client.list_objects(bucket_name, prefix=prefix) return [ { 'object_key': obj.object_name, 'size': obj.size, 'etag': obj.etag, 'last_modified': obj.last_modified } for obj in objects ] except S3Error as e: logger.error(f"列出文件失败: {e}") raise # 全局对象存储管理器实例 storage_manager = ObjectStorageManager()