feat:新增minio相关接口
parent
8c5b39d03b
commit
a1eb2315fa
@ -0,0 +1,13 @@
|
||||
from app.core.base_crud import CRUDBase
|
||||
from app.core.base_schema import AuthSchema
|
||||
|
||||
from .model import FileModel
|
||||
from .schema import FileCreateSchema, FileUpdateSchema
|
||||
|
||||
|
||||
class FileCRUD(CRUDBase[FileModel, FileCreateSchema, FileUpdateSchema]):
|
||||
"""通用文件记录数据层。"""
|
||||
|
||||
def __init__(self, auth: AuthSchema) -> None:
|
||||
super().__init__(model=FileModel, auth=auth)
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
from sqlalchemy import BigInteger, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
|
||||
|
||||
|
||||
class FileModel(ModelMixin, TenantMixin, UserMixin):
|
||||
"""通用文件记录模型。"""
|
||||
|
||||
__tablename__: str = "common_file"
|
||||
__table_args__: dict[str, str] = {"comment": "通用文件记录表"}
|
||||
__loader_options__: list[str] = [
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"deleted_by",
|
||||
"tenant_by",
|
||||
]
|
||||
|
||||
storage_type: Mapped[str] = mapped_column(String(20), nullable=False, default="local", comment="存储类型: local/minio", index=True)
|
||||
bucket_name: Mapped[str | None] = mapped_column(String(128), default=None, nullable=True, comment="MinIO存储桶")
|
||||
object_name: Mapped[str | None] = mapped_column(String(512), default=None, nullable=True, comment="MinIO对象名称")
|
||||
original_name: Mapped[str] = mapped_column(String(255), nullable=False, comment="原始文件名")
|
||||
file_name: Mapped[str] = mapped_column(String(255), nullable=False, comment="存储文件名")
|
||||
file_ext: Mapped[str | None] = mapped_column(String(32), default=None, nullable=True, comment="文件扩展名", index=True)
|
||||
content_type: Mapped[str | None] = mapped_column(String(128), default=None, nullable=True, comment="文件MIME类型")
|
||||
file_size: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False, comment="文件大小,单位字节")
|
||||
file_path: Mapped[str | None] = mapped_column(String(512), default=None, nullable=True, comment="本地路径或对象路径")
|
||||
file_url: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="文件访问地址")
|
||||
upload_type: Mapped[str] = mapped_column(String(32), default="file", nullable=False, comment="上传类型", index=True)
|
||||
business_type: Mapped[str | None] = mapped_column(String(64), default=None, nullable=True, comment="业务类型", index=True)
|
||||
business_id: Mapped[int | None] = mapped_column(Integer, default=None, nullable=True, comment="业务ID", index=True)
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.common.enums import QueueEnum
|
||||
from app.core.base_params import BaseQueryParam, TenantByQueryParam, UserByQueryParam
|
||||
from app.core.base_schema import BaseSchema, TenantBySchema, UserBySchema
|
||||
|
||||
|
||||
class FileCreateSchema(BaseModel):
|
||||
"""文件记录创建模型。"""
|
||||
|
||||
storage_type: str = Field(default="local", max_length=20, description="存储类型: local/minio")
|
||||
bucket_name: str | None = Field(default=None, max_length=128, description="MinIO存储桶")
|
||||
object_name: str | None = Field(default=None, max_length=512, description="MinIO对象名称")
|
||||
original_name: str = Field(..., max_length=255, description="原始文件名")
|
||||
file_name: str = Field(..., max_length=255, description="存储文件名")
|
||||
file_ext: str | None = Field(default=None, max_length=32, description="文件扩展名")
|
||||
content_type: str | None = Field(default=None, max_length=128, description="文件MIME类型")
|
||||
file_size: int = Field(default=0, ge=0, description="文件大小,单位字节")
|
||||
file_path: str | None = Field(default=None, max_length=512, description="本地路径或对象路径")
|
||||
file_url: str | None = Field(default=None, description="文件访问地址")
|
||||
upload_type: str = Field(default="file", max_length=32, description="上传类型")
|
||||
business_type: str | None = Field(default=None, max_length=64, description="业务类型")
|
||||
business_id: int | None = Field(default=None, ge=0, description="业务ID")
|
||||
|
||||
|
||||
class FileUpdateSchema(FileCreateSchema):
|
||||
"""文件记录更新模型。"""
|
||||
|
||||
|
||||
class FileOutSchema(FileCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
|
||||
"""文件记录响应模型。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
origin_name: str | None = Field(default=None, description="兼容旧接口的原始文件名")
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
|
||||
"""文件记录查询参数。"""
|
||||
|
||||
storage_type: str | None = Query(None, description="存储类型: local/minio")
|
||||
original_name: str | None = Query(None, description="原始文件名")
|
||||
file_name: str | None = Query(None, description="存储文件名")
|
||||
file_ext: str | None = Query(None, description="文件扩展名")
|
||||
upload_type: str | None = Query(None, description="上传类型")
|
||||
business_type: str | None = Query(None, description="业务类型")
|
||||
business_id: int | None = Query(None, ge=0, description="业务ID")
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.storage_type:
|
||||
self.storage_type = (QueueEnum.eq.value, self.storage_type)
|
||||
if self.original_name:
|
||||
self.original_name = (QueueEnum.like.value, self.original_name)
|
||||
if self.file_name:
|
||||
self.file_name = (QueueEnum.like.value, self.file_name)
|
||||
if self.file_ext:
|
||||
self.file_ext = (QueueEnum.eq.value, self.file_ext)
|
||||
if self.upload_type:
|
||||
self.upload_type = (QueueEnum.eq.value, self.upload_type)
|
||||
if self.business_type:
|
||||
self.business_type = (QueueEnum.eq.value, self.business_type)
|
||||
if isinstance(self.business_id, int):
|
||||
self.business_id = (QueueEnum.eq.value, self.business_id)
|
||||
|
||||
@ -0,0 +1,143 @@
|
||||
from datetime import timedelta
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from app.config.setting import settings
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import logger
|
||||
|
||||
|
||||
class MinioUtil:
|
||||
"""MinIO对象存储工具类。"""
|
||||
|
||||
_client = None
|
||||
|
||||
@staticmethod
|
||||
def _check_enable() -> None:
|
||||
if not settings.MINIO_ENABLE:
|
||||
raise CustomException(msg="请先开启MinIO对象存储", data="请启用配置项 MINIO_ENABLE")
|
||||
|
||||
@staticmethod
|
||||
def _region() -> str | None:
|
||||
return settings.MINIO_REGION or None
|
||||
|
||||
@staticmethod
|
||||
def _load_minio_classes():
|
||||
try:
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
except ImportError as e:
|
||||
raise CustomException(msg="MinIO依赖未安装", data="请先执行 pip install minio 或更新项目依赖") from e
|
||||
return Minio, S3Error
|
||||
|
||||
@classmethod
|
||||
def get_client(cls):
|
||||
cls._check_enable()
|
||||
if cls._client is None:
|
||||
Minio, _ = cls._load_minio_classes()
|
||||
cls._client = Minio(
|
||||
endpoint=settings.MINIO_ENDPOINT,
|
||||
access_key=settings.MINIO_ACCESS_KEY,
|
||||
secret_key=settings.MINIO_SECRET_KEY,
|
||||
secure=settings.MINIO_SECURE,
|
||||
region=cls._region(),
|
||||
)
|
||||
return cls._client
|
||||
|
||||
@classmethod
|
||||
def ensure_bucket(cls, bucket_name: str | None = None) -> None:
|
||||
bucket = bucket_name or settings.MINIO_BUCKET_NAME
|
||||
client = cls.get_client()
|
||||
_, S3Error = cls._load_minio_classes()
|
||||
try:
|
||||
if not client.bucket_exists(bucket):
|
||||
client.make_bucket(bucket, location=cls._region())
|
||||
except S3Error as e:
|
||||
logger.error(f"MinIO存储桶初始化失败: {e}")
|
||||
raise CustomException(msg="MinIO存储桶初始化失败", data=str(e)) from e
|
||||
|
||||
@classmethod
|
||||
def upload_file(
|
||||
cls,
|
||||
object_name: str,
|
||||
file_path: str | Path,
|
||||
bucket_name: str | None = None,
|
||||
content_type: str | None = None,
|
||||
) -> str:
|
||||
bucket = bucket_name or settings.MINIO_BUCKET_NAME
|
||||
client = cls.get_client()
|
||||
_, S3Error = cls._load_minio_classes()
|
||||
try:
|
||||
cls.ensure_bucket(bucket)
|
||||
client.fput_object(
|
||||
bucket_name=bucket,
|
||||
object_name=object_name,
|
||||
file_path=str(file_path),
|
||||
content_type=content_type or "application/octet-stream",
|
||||
)
|
||||
return cls.get_public_url(object_name=object_name, bucket_name=bucket)
|
||||
except S3Error as e:
|
||||
logger.error(f"MinIO文件上传失败: {e}")
|
||||
raise CustomException(msg="MinIO文件上传失败", data=str(e)) from e
|
||||
|
||||
@classmethod
|
||||
def upload_bytes(
|
||||
cls,
|
||||
object_name: str,
|
||||
data: bytes,
|
||||
bucket_name: str | None = None,
|
||||
content_type: str = "application/octet-stream",
|
||||
) -> str:
|
||||
bucket = bucket_name or settings.MINIO_BUCKET_NAME
|
||||
client = cls.get_client()
|
||||
_, S3Error = cls._load_minio_classes()
|
||||
try:
|
||||
cls.ensure_bucket(bucket)
|
||||
client.put_object(
|
||||
bucket_name=bucket,
|
||||
object_name=object_name,
|
||||
data=BytesIO(data),
|
||||
length=len(data),
|
||||
content_type=content_type,
|
||||
)
|
||||
return cls.get_public_url(object_name=object_name, bucket_name=bucket)
|
||||
except S3Error as e:
|
||||
logger.error(f"MinIO字节流上传失败: {e}")
|
||||
raise CustomException(msg="MinIO字节流上传失败", data=str(e)) from e
|
||||
|
||||
@classmethod
|
||||
def remove_object(cls, object_name: str, bucket_name: str | None = None) -> None:
|
||||
bucket = bucket_name or settings.MINIO_BUCKET_NAME
|
||||
client = cls.get_client()
|
||||
_, S3Error = cls._load_minio_classes()
|
||||
try:
|
||||
client.remove_object(bucket_name=bucket, object_name=object_name)
|
||||
except S3Error as e:
|
||||
logger.error(f"MinIO文件删除失败: {e}")
|
||||
raise CustomException(msg="MinIO文件删除失败", data=str(e)) from e
|
||||
|
||||
@classmethod
|
||||
def presigned_get_url(
|
||||
cls,
|
||||
object_name: str,
|
||||
bucket_name: str | None = None,
|
||||
expires_seconds: int | None = None,
|
||||
) -> str:
|
||||
bucket = bucket_name or settings.MINIO_BUCKET_NAME
|
||||
client = cls.get_client()
|
||||
_, S3Error = cls._load_minio_classes()
|
||||
expires = timedelta(seconds=expires_seconds or settings.MINIO_PRESIGNED_EXPIRE_SECONDS)
|
||||
try:
|
||||
return client.presigned_get_object(bucket_name=bucket, object_name=object_name, expires=expires)
|
||||
except S3Error as e:
|
||||
logger.error(f"MinIO预签名地址生成失败: {e}")
|
||||
raise CustomException(msg="MinIO预签名地址生成失败", data=str(e)) from e
|
||||
|
||||
@staticmethod
|
||||
def get_public_url(object_name: str, bucket_name: str | None = None) -> str:
|
||||
bucket = bucket_name or settings.MINIO_BUCKET_NAME
|
||||
object_path = object_name.lstrip("/")
|
||||
if settings.MINIO_PUBLIC_URL:
|
||||
return f"{settings.MINIO_PUBLIC_URL.rstrip('/')}/{bucket}/{object_path}"
|
||||
scheme = "https" if settings.MINIO_SECURE else "http"
|
||||
return f"{scheme}://{settings.MINIO_ENDPOINT.rstrip('/')}/{bucket}/{object_path}"
|
||||
Loading…
Reference in New Issue