diff --git a/app/api/v1/module_common/file/controller.py b/app/api/v1/module_common/file/controller.py index 262b74e..8dc57d6 100644 --- a/app/api/v1/module_common/file/controller.py +++ b/app/api/v1/module_common/file/controller.py @@ -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="删除文件记录成功") diff --git a/app/api/v1/module_common/file/crud.py b/app/api/v1/module_common/file/crud.py new file mode 100644 index 0000000..d33d688 --- /dev/null +++ b/app/api/v1/module_common/file/crud.py @@ -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) + diff --git a/app/api/v1/module_common/file/model.py b/app/api/v1/module_common/file/model.py new file mode 100644 index 0000000..97ec00a --- /dev/null +++ b/app/api/v1/module_common/file/model.py @@ -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) + diff --git a/app/api/v1/module_common/file/schema.py b/app/api/v1/module_common/file/schema.py new file mode 100644 index 0000000..e6a1838 --- /dev/null +++ b/app/api/v1/module_common/file/schema.py @@ -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) + diff --git a/app/api/v1/module_common/file/service.py b/app/api/v1/module_common/file/service.py index e4ac2db..674289f 100644 --- a/app/api/v1/module_common/file/service.py +++ b/app/api/v1/module_common/file/service.py @@ -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: """下载文件""" diff --git a/app/config/setting.py b/app/config/setting.py index f88c9d8..4331cc0 100644 --- a/app/config/setting.py +++ b/app/config/setting.py @@ -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配置 ***************** # # ================================================= # diff --git a/app/scripts/initialize.py b/app/scripts/initialize.py index 1be0545..053d857 100644 --- a/app/scripts/initialize.py +++ b/app/scripts/initialize.py @@ -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, diff --git a/app/utils/minio_util.py b/app/utils/minio_util.py new file mode 100644 index 0000000..6fe4891 --- /dev/null +++ b/app/utils/minio_util.py @@ -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}" diff --git a/env/.env.dev b/env/.env.dev index 43dc8f7..98a5eaa 100644 --- a/env/.env.dev +++ b/env/.env.dev @@ -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 diff --git a/env/.env.dev.example b/env/.env.dev.example index f867fda..b0e0f81 100644 --- a/env/.env.dev.example +++ b/env/.env.dev.example @@ -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 diff --git a/env/.env.prod.example b/env/.env.prod.example index 1402546..c2c279d 100644 --- a/env/.env.prod.example +++ b/env/.env.prod.example @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 4791fac..fbc2482 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", # 数据处理 diff --git a/requirements.txt b/requirements.txt index e005315..539008e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 # 数据处理