352 lines
9.5 KiB
Python
352 lines
9.5 KiB
Python
|
|
"""MinIO对象存储客户端 - 文档文件存储"""
|
|||
|
|
|
|||
|
|
from minio import Minio
|
|||
|
|
from minio.error import S3Error
|
|||
|
|
from typing import Optional, Dict, Any
|
|||
|
|
from loguru import logger
|
|||
|
|
from io import BytesIO
|
|||
|
|
import os
|
|||
|
|
|
|||
|
|
from app.config.settings import settings
|
|||
|
|
|
|||
|
|
|
|||
|
|
class MinIOClient:
|
|||
|
|
"""MinIO对象存储客户端"""
|
|||
|
|
|
|||
|
|
def __init__(
|
|||
|
|
self,
|
|||
|
|
endpoint: str = None,
|
|||
|
|
access_key: str = None,
|
|||
|
|
secret_key: str = None,
|
|||
|
|
bucket: str = None,
|
|||
|
|
secure: bool = None
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
初始化MinIO客户端
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
endpoint: MinIO服务地址
|
|||
|
|
access_key: 访问密钥
|
|||
|
|
secret_key: 秘密密钥
|
|||
|
|
bucket: 存储桶名称
|
|||
|
|
secure: 是否使用HTTPS
|
|||
|
|
"""
|
|||
|
|
self.endpoint = endpoint or settings.minio_endpoint
|
|||
|
|
self.access_key = access_key or settings.minio_access_key
|
|||
|
|
self.secret_key = secret_key or settings.minio_secret_key
|
|||
|
|
self.bucket = bucket or settings.minio_bucket
|
|||
|
|
self.secure = secure or settings.minio_secure
|
|||
|
|
|
|||
|
|
self.client: Optional[Minio] = None
|
|||
|
|
self.connected = False
|
|||
|
|
|
|||
|
|
logger.info(f"MinIO客户端配置: {self.endpoint}, bucket={self.bucket}")
|
|||
|
|
|
|||
|
|
def connect(self) -> bool:
|
|||
|
|
"""连接MinIO服务"""
|
|||
|
|
try:
|
|||
|
|
self.client = Minio(
|
|||
|
|
self.endpoint,
|
|||
|
|
access_key=self.access_key,
|
|||
|
|
secret_key=self.secret_key,
|
|||
|
|
secure=self.secure
|
|||
|
|
)
|
|||
|
|
self.connected = True
|
|||
|
|
logger.success(f"MinIO连接成功: {self.endpoint}")
|
|||
|
|
return True
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"MinIO连接失败: {e}")
|
|||
|
|
self.connected = False
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def ensure_bucket(self) -> bool:
|
|||
|
|
"""确保存储桶存在"""
|
|||
|
|
if not self.connected:
|
|||
|
|
logger.warning("未连接MinIO,请先调用connect()")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
if not self.client.bucket_exists(self.bucket):
|
|||
|
|
self.client.make_bucket(self.bucket)
|
|||
|
|
logger.success(f"创建存储桶: {self.bucket}")
|
|||
|
|
else:
|
|||
|
|
logger.info(f"存储桶已存在: {self.bucket}")
|
|||
|
|
return True
|
|||
|
|
except S3Error as e:
|
|||
|
|
logger.error(f"存储桶操作失败: {e}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def upload_file(
|
|||
|
|
self,
|
|||
|
|
file_path: str,
|
|||
|
|
object_name: str,
|
|||
|
|
metadata: Dict[str, Any] = None
|
|||
|
|
) -> bool:
|
|||
|
|
"""
|
|||
|
|
上传本地文件到MinIO
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
file_path: 本地文件路径
|
|||
|
|
object_name: MinIO对象名称
|
|||
|
|
metadata: 元数据
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
bool: 是否成功
|
|||
|
|
"""
|
|||
|
|
if not self.connected:
|
|||
|
|
self.connect()
|
|||
|
|
self.ensure_bucket()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
file_size = os.stat(file_path).st_size
|
|||
|
|
content_type = self._get_content_type(file_path)
|
|||
|
|
|
|||
|
|
with open(file_path, 'rb') as f:
|
|||
|
|
self.client.put_object(
|
|||
|
|
self.bucket,
|
|||
|
|
object_name,
|
|||
|
|
f,
|
|||
|
|
file_size,
|
|||
|
|
content_type=content_type,
|
|||
|
|
metadata=metadata
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
logger.success(f"文件上传成功: {object_name}, 大小={file_size}")
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
except S3Error as e:
|
|||
|
|
logger.error(f"文件上传失败: {e}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def upload_bytes(
|
|||
|
|
self,
|
|||
|
|
data: bytes,
|
|||
|
|
object_name: str,
|
|||
|
|
content_type: str = "application/octet-stream",
|
|||
|
|
metadata: Dict[str, Any] = None
|
|||
|
|
) -> bool:
|
|||
|
|
"""
|
|||
|
|
上传字节数据到MinIO
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
data: 文件字节数据
|
|||
|
|
object_name: MinIO对象名称
|
|||
|
|
content_type: 内容类型
|
|||
|
|
metadata: 元数据(注意:MinIO仅支持US-ASCII字符)
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
bool: 是否成功
|
|||
|
|
"""
|
|||
|
|
if not self.connected:
|
|||
|
|
self.connect()
|
|||
|
|
self.ensure_bucket()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
data_stream = BytesIO(data)
|
|||
|
|
|
|||
|
|
# 处理metadata:仅保留ASCII安全字符
|
|||
|
|
safe_metadata = None
|
|||
|
|
if metadata:
|
|||
|
|
safe_metadata = {}
|
|||
|
|
for key, value in metadata.items():
|
|||
|
|
if isinstance(value, str):
|
|||
|
|
# 只保留ASCII字符或转换为安全格式
|
|||
|
|
try:
|
|||
|
|
value.encode('ascii')
|
|||
|
|
safe_metadata[key] = value
|
|||
|
|
except UnicodeEncodeError:
|
|||
|
|
# 中文字符跳过或用占位符
|
|||
|
|
safe_metadata[key] = ""
|
|||
|
|
else:
|
|||
|
|
safe_metadata[key] = str(value)
|
|||
|
|
|
|||
|
|
self.client.put_object(
|
|||
|
|
self.bucket,
|
|||
|
|
object_name,
|
|||
|
|
data_stream,
|
|||
|
|
len(data),
|
|||
|
|
content_type=content_type,
|
|||
|
|
metadata=safe_metadata
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
logger.success(f"数据上传成功: {object_name}, 大小={len(data)}")
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
except S3Error as e:
|
|||
|
|
logger.error(f"数据上传失败: {e}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def download_file(
|
|||
|
|
self,
|
|||
|
|
object_name: str,
|
|||
|
|
file_path: str
|
|||
|
|
) -> bool:
|
|||
|
|
"""
|
|||
|
|
从MinIO下载文件到本地
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
object_name: MinIO对象名称
|
|||
|
|
file_path: 本地保存路径
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
bool: 是否成功
|
|||
|
|
"""
|
|||
|
|
if not self.connected:
|
|||
|
|
self.connect()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
self.client.fget_object(
|
|||
|
|
self.bucket,
|
|||
|
|
object_name,
|
|||
|
|
file_path
|
|||
|
|
)
|
|||
|
|
logger.success(f"文件下载成功: {object_name} -> {file_path}")
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
except S3Error as e:
|
|||
|
|
logger.error(f"文件下载失败: {e}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def get_object_url(
|
|||
|
|
self,
|
|||
|
|
object_name: str,
|
|||
|
|
expires: int = 3600
|
|||
|
|
) -> Optional[str]:
|
|||
|
|
"""
|
|||
|
|
获取对象下载URL(临时URL)
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
object_name: MinIO对象名称
|
|||
|
|
expires: URL有效期(秒)
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
str: 下载URL
|
|||
|
|
"""
|
|||
|
|
if not self.connected:
|
|||
|
|
self.connect()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
url = self.client.presigned_get_object(
|
|||
|
|
self.bucket,
|
|||
|
|
object_name,
|
|||
|
|
expires=expires
|
|||
|
|
)
|
|||
|
|
return url
|
|||
|
|
|
|||
|
|
except S3Error as e:
|
|||
|
|
logger.error(f"获取URL失败: {e}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def get_object_data(self, object_name: str) -> Optional[bytes]:
|
|||
|
|
"""
|
|||
|
|
获取对象数据(字节)
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
object_name: MinIO对象名称
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
bytes: 文件数据
|
|||
|
|
"""
|
|||
|
|
if not self.connected:
|
|||
|
|
self.connect()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
response = self.client.get_object(self.bucket, object_name)
|
|||
|
|
data = response.read()
|
|||
|
|
response.close()
|
|||
|
|
response.release_conn()
|
|||
|
|
return data
|
|||
|
|
|
|||
|
|
except S3Error as e:
|
|||
|
|
logger.error(f"获取对象数据失败: {e}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def delete_object(self, object_name: str) -> bool:
|
|||
|
|
"""
|
|||
|
|
删除对象
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
object_name: MinIO对象名称
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
bool: 是否成功
|
|||
|
|
"""
|
|||
|
|
if not self.connected:
|
|||
|
|
self.connect()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
self.client.remove_object(self.bucket, object_name)
|
|||
|
|
logger.info(f"对象删除成功: {object_name}")
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
except S3Error as e:
|
|||
|
|
logger.error(f"对象删除失败: {e}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def list_objects(self, prefix: str = "") -> list:
|
|||
|
|
"""
|
|||
|
|
列出存储桶中的对象
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
prefix: 对象名称前缀
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
list: 对象列表
|
|||
|
|
"""
|
|||
|
|
if not self.connected:
|
|||
|
|
self.connect()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
objects = self.client.list_objects(self.bucket, prefix=prefix)
|
|||
|
|
return [obj.object_name for obj in objects]
|
|||
|
|
|
|||
|
|
except S3Error as e:
|
|||
|
|
logger.error(f"列出对象失败: {e}")
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
def object_exists(self, object_name: str) -> bool:
|
|||
|
|
"""
|
|||
|
|
检查对象是否存在
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
object_name: MinIO对象名称
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
bool: 是否存在
|
|||
|
|
"""
|
|||
|
|
if not self.connected:
|
|||
|
|
self.connect()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
self.client.stat_object(self.bucket, object_name)
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
except S3Error:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def _get_content_type(self, file_path: str) -> str:
|
|||
|
|
"""根据文件扩展名获取Content-Type"""
|
|||
|
|
ext = os.path.splitext(file_path)[1].lower()
|
|||
|
|
content_types = {
|
|||
|
|
'.pdf': 'application/pdf',
|
|||
|
|
'.doc': 'application/msword',
|
|||
|
|
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|||
|
|
'.txt': 'text/plain',
|
|||
|
|
'.json': 'application/json',
|
|||
|
|
'.xml': 'application/xml',
|
|||
|
|
}
|
|||
|
|
return content_types.get(ext, 'application/octet-stream')
|
|||
|
|
|
|||
|
|
def close(self):
|
|||
|
|
"""关闭连接(MinIO客户端无需显式关闭)"""
|
|||
|
|
self.connected = False
|
|||
|
|
logger.info("MinIO客户端已关闭")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def create_minio_client() -> MinIOClient:
|
|||
|
|
"""便捷函数:创建MinIO客户端"""
|
|||
|
|
client = MinIOClient()
|
|||
|
|
client.connect()
|
|||
|
|
client.ensure_bucket()
|
|||
|
|
return client
|