feat:新增minio相关接口

master
HuangHuiKang 4 days ago
parent 8c5b39d03b
commit a1eb2315fa

@ -7,6 +7,7 @@ from fastapi import (
Body,
Depends,
Form,
Path,
Query,
Request,
UploadFile,
@ -14,38 +15,92 @@ from fastapi import (
from fastapi.responses import FileResponse, JSONResponse
from app.common.response import ResponseSchema, SuccessResponse, UploadFileResponse
from app.core.base_schema import UploadResponseSchema
from app.core.base_params import PaginationQueryParam
from app.core.base_schema import AuthSchema, PageResultSchema
from app.core.dependencies import AuthPermission
from app.core.router_class import OperationLogRoute
from app.utils.upload_util import UploadUtil
from .schema import FileOutSchema, FileQueryParam
from .service import FileService
FileRouter = APIRouter(route_class=OperationLogRoute, prefix="/file", tags=["公共模块", "文件管理"])
FileRouter = APIRouter(route_class=OperationLogRoute, prefix="/file", tags=["文件管理"])
@FileRouter.get(
"/list",
summary="查询文件记录列表",
response_model=ResponseSchema[PageResultSchema[FileOutSchema]],
)
async def get_file_list_controller(
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[FileQueryParam, Depends()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_common:file:query"]))],
) -> JSONResponse:
result = await FileService.page(
auth=auth,
page_no=page.page_no,
page_size=page.page_size,
search=search,
order_by=page.order_by or [{"id": "desc"}],
)
return SuccessResponse(data=result, msg="查询文件记录列表成功")
@FileRouter.get(
"/detail/{id}",
summary="查询文件记录详情",
response_model=ResponseSchema[FileOutSchema],
)
async def get_file_detail_controller(
id: Annotated[int, Path(description="文件记录ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_common:file:detail"]))],
) -> JSONResponse:
result = await FileService.detail_service(auth=auth, id=id)
return SuccessResponse(data=result, msg="查询文件记录详情成功")
@FileRouter.post(
"/upload",
summary="上传文件",
response_model=ResponseSchema[UploadResponseSchema],
dependencies=[Depends(AuthPermission(["module_common:file:upload"]))],
response_model=ResponseSchema[FileOutSchema],
)
async def upload_controller(
file: UploadFile,
request: Request,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_common:file:upload"]))],
upload_type: Annotated[
Literal["file", "avatar", "param", "resource"] | None,
Query(description="上传类型: file=通用文件, avatar=头像, param=参数配置, resource=监控资源"),
] = "file",
business_type: Annotated[str | None, Query(description="业务类型,如 rule、contract、avatar")] = None,
business_id: Annotated[int | None, Query(ge=0, description="业务ID")] = None,
target_path: Annotated[str | None, Form(description="目标目录路径(仅 resource 类型支持)")] = None,
) -> JSONResponse:
result = await FileService.upload_service(
auth=auth,
base_url=str(request.base_url),
file=file,
upload_type=upload_type or "file",
target_path=target_path,
business_type=business_type,
business_id=business_id,
)
return SuccessResponse(data=result, msg="上传文件成功")
@FileRouter.get(
"/presigned/{id}",
summary="生成文件预签名访问地址",
response_model=ResponseSchema[str],
)
async def get_file_presigned_url_controller(
id: Annotated[int, Path(description="文件记录ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_common:file:detail"]))],
) -> JSONResponse:
result = await FileService.presigned_url_service(auth=auth, id=id)
return SuccessResponse(data=result, msg="生成文件预签名访问地址成功")
@FileRouter.post(
"/download",
summary="下载文件",
@ -60,3 +115,17 @@ async def download_controller(
if delete:
background_tasks.add_task(UploadUtil.delete_file, Path(result.file_path))
return UploadFileResponse(file_path=result.file_path, filename=result.file_name)
@FileRouter.delete(
"/delete",
summary="删除文件记录",
response_model=ResponseSchema[None],
)
async def delete_file_controller(
ids: Annotated[list[int], Body(description="ID列表")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_common:file:delete"]))],
delete_object: Annotated[bool, Query(description="是否同时删除MinIO/本地文件")] = False,
) -> JSONResponse:
await FileService.delete_service(auth=auth, ids=ids, delete_object=delete_object)
return SuccessResponse(msg="删除文件记录成功")

@ -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)

@ -1,13 +1,18 @@
import os
from pathlib import Path
from fastapi import UploadFile
from app.config.setting import settings
from app.core.base_schema import DownloadFileSchema, UploadResponseSchema
from app.core.base_schema import AuthSchema, DownloadFileSchema
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.utils.minio_util import MinioUtil
from app.utils.upload_util import UploadUtil
from .crud import FileCRUD
from .schema import FileCreateSchema, FileOutSchema, FileQueryParam
class FileService:
"""
@ -17,12 +22,18 @@ class FileService:
@classmethod
async def upload_service(
cls,
auth: AuthSchema,
base_url: str,
file: UploadFile,
upload_type: str = "file",
target_path: str | None = None,
) -> UploadResponseSchema:
business_type: str | None = None,
business_id: int | None = None,
) -> FileOutSchema:
"""上传文件"""
original_name = file.filename or ""
content_type = file.content_type
file_size = file.size or 0
filename, filepath, file_url = await UploadUtil.upload_file(
file=file,
@ -31,13 +42,147 @@ class FileService:
target_path=target_path,
)
storage_type = "local"
bucket_name = None
object_name = None
final_file_url = f"{file_url}"
final_file_path = f"{filepath}"
if settings.MINIO_ENABLE:
object_name = cls._build_object_name(upload_type=upload_type, filename=filename, filepath=filepath)
try:
final_file_url = MinioUtil.upload_file(
object_name=object_name,
file_path=filepath,
content_type=content_type,
)
except Exception:
UploadUtil.delete_file(filepath)
raise
bucket_name = settings.MINIO_BUCKET_NAME
storage_type = "minio"
final_file_path = object_name
UploadUtil.delete_file(filepath)
try:
file_record = await FileCRUD(auth).create(
data=FileCreateSchema(
storage_type=storage_type,
bucket_name=bucket_name,
object_name=object_name,
original_name=original_name,
file_name=filename,
file_ext=UploadUtil.get_extension_from_filename(filename),
content_type=content_type,
file_size=file_size,
file_path=final_file_path,
file_url=final_file_url,
upload_type=upload_type,
business_type=business_type,
business_id=business_id,
)
)
except Exception:
cls._delete_uploaded_object(storage_type=storage_type, bucket_name=bucket_name, object_name=object_name, file_path=final_file_path)
raise
return cls._to_out_schema(file_record)
@classmethod
async def page(
cls,
auth: AuthSchema,
page_no: int,
page_size: int,
search: FileQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> dict:
offset = (page_no - 1) * page_size
page_result = await FileCRUD(auth).page(
offset=offset,
limit=page_size,
order_by=order_by or [{"id": "desc"}],
search=vars(search) if search else None,
out_schema=FileOutSchema,
)
for item in page_result.items:
item["origin_name"] = item.get("original_name")
return page_result
@classmethod
async def detail_service(cls, auth: AuthSchema, id: int) -> FileOutSchema:
file_record = await FileCRUD(auth).get_or_404(id=id, msg="文件记录不存在")
return cls._to_out_schema(file_record)
@classmethod
async def delete_service(cls, auth: AuthSchema, ids: list[int], delete_object: bool = False) -> None:
if len(ids) < 1:
raise CustomException(msg="删除失败,删除对象不能为空")
records = await FileCRUD(auth).get_list(search={"id": ("in", ids)})
record_map = {record.id: record for record in records}
for file_id in ids:
if file_id not in record_map:
raise CustomException(msg="删除失败,文件记录不存在")
if delete_object:
for record in records:
cls._delete_storage_object(record)
await FileCRUD(auth).delete(ids=ids)
@classmethod
def _to_out_schema(cls, file_record) -> FileOutSchema:
result = FileOutSchema.model_validate(file_record)
result.origin_name = result.original_name
return result
@classmethod
def _build_object_name(cls, upload_type: str, filename: str, filepath: Path) -> str:
try:
relative_path = filepath.resolve().relative_to(settings.UPLOAD_FILE_PATH.resolve())
return relative_path.as_posix()
except ValueError:
return f"{upload_type}/{filename}"
@classmethod
def _delete_storage_object(cls, file_record) -> None:
if file_record.storage_type == "minio" and file_record.object_name:
MinioUtil.remove_object(object_name=file_record.object_name, bucket_name=file_record.bucket_name)
return
if file_record.file_path:
UploadUtil.delete_file(Path(file_record.file_path))
@classmethod
def _delete_uploaded_object(cls, storage_type: str, bucket_name: str | None, object_name: str | None, file_path: str | None) -> None:
try:
if storage_type == "minio" and object_name:
MinioUtil.remove_object(object_name=object_name, bucket_name=bucket_name)
return
if file_path:
UploadUtil.delete_file(Path(file_path))
except Exception as e:
logger.error(f"清理上传文件失败: {e}")
@classmethod
def to_upload_response(cls, file_record: FileOutSchema):
from app.core.base_schema import UploadResponseSchema
return UploadResponseSchema(
file_path=f"{filepath}",
file_name=filename,
origin_name=file.filename,
file_url=f"{file_url}",
file_path=file_record.file_path,
file_name=file_record.file_name,
origin_name=file_record.original_name,
file_url=file_record.file_url,
)
@classmethod
async def presigned_url_service(cls, auth: AuthSchema, id: int) -> str:
file_record = await FileCRUD(auth).get_or_404(id=id, msg="文件记录不存在")
if file_record.storage_type != "minio" or not file_record.object_name:
if file_record.file_url:
return file_record.file_url
raise CustomException(msg="该文件不是MinIO文件无法生成预签名地址")
return MinioUtil.presigned_get_url(object_name=file_record.object_name, bucket_name=file_record.bucket_name)
@classmethod
async def download_service(cls, file_path: str) -> DownloadFileSchema:
"""下载文件"""

@ -203,6 +203,19 @@ class Settings(BaseSettings):
]
MAX_FILE_SIZE: int = 10 * 1024 * 1024 # 最大文件大小(10MB)
# ================================================= #
# ***************** MinIO对象存储配置 **************** #
# ================================================= #
MINIO_ENABLE: bool = False # 是否启用MinIO对象存储
MINIO_ENDPOINT: str = "localhost:9000" # MinIO服务地址不带 http:// 或 https://
MINIO_ACCESS_KEY: str = "minioadmin" # Access Key
MINIO_SECRET_KEY: str = "minioadmin" # Secret Key
MINIO_BUCKET_NAME: str = "fastapiadmin" # 默认存储桶
MINIO_SECURE: bool = False # 是否使用HTTPS
MINIO_REGION: str | None = None # 区域,可为空
MINIO_PUBLIC_URL: str = "" # 外部访问地址,如 https://oss.example.com
MINIO_PRESIGNED_EXPIRE_SECONDS: int = 3600 # 预签名URL有效期单位秒
# ================================================= #
# ***************** Swagger配置 ***************** #
# ================================================= #

@ -14,6 +14,7 @@ from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_common.file.model import FileModel
from app.api.v1.module_platform.email.model import EmailConfigModel, EmailTemplateModel
from app.api.v1.module_platform.invoice.model import InvoiceModel
from app.api.v1.module_platform.menu.model import MenuModel
@ -76,6 +77,7 @@ class InitializeData:
NoticeModel,
NoticeReadModel,
TicketModel,
FileModel,
# ── 日志表(追加写入) ──
LoginLogModel,
OperationLogModel,

@ -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}"

11
env/.env.dev vendored

@ -54,5 +54,16 @@ OPENAI_BASE_URL = "https://api.minimax.chat"
OPENAI_API_KEY = "your_api_key"
OPENAI_MODEL = "deepseek-v4-flash"
# MinIO对象存储配置
MINIO_ENABLE = True
MINIO_ENDPOINT = "192.168.5.119:9000"
MINIO_ACCESS_KEY = "minioadmin"
MINIO_SECRET_KEY = "Admin@hd2019"
MINIO_BUCKET_NAME = "fastapiadmin"
MINIO_SECURE = False
MINIO_REGION = ""
MINIO_PUBLIC_URL = ""
MINIO_PRESIGNED_EXPIRE_SECONDS = 3600
# IP 归属地查询(登录时对外请求第三方 API生产建议关闭
IP_LOCATION_ENABLE=False

@ -57,5 +57,16 @@ OPENAI_BASE_URL = "https://api.minimax.chat"
OPENAI_API_KEY = "your_api_key"
OPENAI_MODEL = "deepseek-v4-flash"
# MinIO对象存储配置
MINIO_ENABLE = False
MINIO_ENDPOINT = "localhost:9000"
MINIO_ACCESS_KEY = "minioadmin"
MINIO_SECRET_KEY = "minioadmin"
MINIO_BUCKET_NAME = "fastapiadmin"
MINIO_SECURE = False
MINIO_REGION = ""
MINIO_PUBLIC_URL = ""
MINIO_PRESIGNED_EXPIRE_SECONDS = 3600
# IP 归属地查询(登录时对外请求第三方 API生产建议关闭
IP_LOCATION_ENABLE=False

@ -57,5 +57,16 @@ OPENAI_BASE_URL = "https://api.minimax.chat"
OPENAI_API_KEY = "your_api_key"
OPENAI_MODEL = "deepseek-v4-flash"
# MinIO对象存储配置
MINIO_ENABLE = False
MINIO_ENDPOINT = "your_minio_host:9000"
MINIO_ACCESS_KEY = "your_minio_access_key"
MINIO_SECRET_KEY = "your_minio_secret_key"
MINIO_BUCKET_NAME = "fastapiadmin"
MINIO_SECURE = True
MINIO_REGION = ""
MINIO_PUBLIC_URL = "https://oss.example.com"
MINIO_PRESIGNED_EXPIRE_SECONDS = 3600
# IP 归属地查询(登录时对外请求第三方 API生产建议关闭
IP_LOCATION_ENABLE=False

@ -23,6 +23,7 @@ dependencies = [
"httpx==0.28.1", # HTTP 客户端
"jinja2==3.1.6", # 模板引擎
"loguru==0.7.3", # 日志
"minio>=7.2.0,<8.0.0", # MinIO 对象存储客户端
"openai==2.28.0", # OpenAI API 客户端
"openpyxl==3.1.5", # Excel
"pandas==3.0.3", # 数据处理

@ -20,6 +20,7 @@ greenlet==3.5.2 # 协程核心依赖SQLAlchemy 异步
httpx==0.28.1 # HTTP 客户端
jinja2==3.1.6 # 模板引擎
loguru==0.7.3 # 日志
minio>=7.2.0,<8.0.0 # MinIO 对象存储客户端
openai==2.28.0 # OpenAI API 客户端
openpyxl==3.1.5 # Excel
pandas==3.0.3 # 数据处理

Loading…
Cancel
Save