zhanghua hace 5 meses
commit
8b3be9be5e

+ 10 - 0
.env

@@ -0,0 +1,10 @@
+# .env
+PROJECT_NAME=MinIO FileManager
+API_V1_STR=/api
+
+# MinIO 配置
+MINIO_ENDPOINT=frp9.aaszxc.asia:10161
+MINIO_ACCESS_KEY=6596a2de4a92cfe9
+MINIO_SECRET_KEY=809d6749bb70e83bca08b8e5a614f9d8
+MINIO_BUCKET_NAME=user-dufs
+MINIO_SECURE=False  # 如果是HTTPS则为True

+ 6 - 0
.idea/inspectionProfiles/profiles_settings.xml

@@ -0,0 +1,6 @@
+<component name="InspectionProjectProfileManager">
+  <settings>
+    <option name="USE_PROJECT_PROFILE" value="false" />
+    <version value="1.0" />
+  </settings>
+</component>

+ 8 - 0
.idea/minio_file_manager.iml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="PYTHON_MODULE" version="4">
+  <component name="NewModuleRootManager">
+    <content url="file://$MODULE_DIR$" />
+    <orderEntry type="inheritedJdk" />
+    <orderEntry type="sourceFolder" forTests="false" />
+  </component>
+</module>

+ 7 - 0
.idea/misc.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="Black">
+    <option name="sdkName" value="minio" />
+  </component>
+  <component name="ProjectRootManager" version="2" project-jdk-name="minio" project-jdk-type="Python SDK" />
+</project>

+ 8 - 0
.idea/modules.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectModuleManager">
+    <modules>
+      <module fileurl="file://$PROJECT_DIR$/.idea/minio_file_manager.iml" filepath="$PROJECT_DIR$/.idea/minio_file_manager.iml" />
+    </modules>
+  </component>
+</project>

+ 6 - 0
.idea/vcs.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="VcsDirectoryMappings">
+    <mapping directory="$PROJECT_DIR$" vcs="Git" />
+  </component>
+</project>

+ 131 - 0
.idea/workspace.xml

@@ -0,0 +1,131 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="AutoImportSettings">
+    <option name="autoReloadType" value="SELECTIVE" />
+  </component>
+  <component name="ChangeListManager">
+    <list default="true" id="faca4961-58ca-45f4-8a0e-73812dfa125a" name="更改" comment="">
+      <change afterPath="$PROJECT_DIR$/.env" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/.idea/inspectionProfiles/profiles_settings.xml" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/.idea/minio_file_manager.iml" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/.idea/misc.xml" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/.idea/modules.xml" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/.idea/vcs.xml" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/Dockerfile" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/app/api/__init__.py" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/app/api/endpoints.py" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/app/core/__init__.py" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/app/core/config.py" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/app/main.py" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/app/models/__init__.py" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/app/models/schemas.py" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/app/services/__init__.py" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/app/services/minio_service.py" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/docker-compose.yml" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/requirements.txt" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/test_main.http" afterDir="false" />
+    </list>
+    <option name="SHOW_DIALOG" value="false" />
+    <option name="HIGHLIGHT_CONFLICTS" value="true" />
+    <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
+    <option name="LAST_RESOLUTION" value="IGNORE" />
+  </component>
+  <component name="FileTemplateManagerImpl">
+    <option name="RECENT_TEMPLATES">
+      <list>
+        <option value="FastAPI main" />
+        <option value="FastAPI test_main" />
+        <option value="Python Script" />
+      </list>
+    </option>
+  </component>
+  <component name="Git.Settings">
+    <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
+  </component>
+  <component name="ProjectColorInfo"><![CDATA[{
+  "associatedIndex": 5
+}]]></component>
+  <component name="ProjectId" id="35sKloTsijaSj4MdoM972beWAFB" />
+  <component name="ProjectLevelVcsManager" settingsEditedManually="true" />
+  <component name="ProjectViewState">
+    <option name="hideEmptyMiddlePackages" value="true" />
+    <option name="showLibraryContents" value="true" />
+  </component>
+  <component name="PropertiesComponent"><![CDATA[{
+  "keyToString": {
+    "FastAPI.minio_file_manager.executor": "Run",
+    "RunOnceActivity.OpenProjectViewOnStart": "true",
+    "RunOnceActivity.ShowReadmeOnStart": "true",
+    "git-widget-placeholder": "main",
+    "node.js.detected.package.eslint": "true",
+    "node.js.detected.package.tslint": "true",
+    "node.js.selected.package.eslint": "(autodetect)",
+    "node.js.selected.package.tslint": "(autodetect)",
+    "nodejs_package_manager_path": "npm",
+    "settings.editor.selected.configurable": "preferences.lookFeel",
+    "vue.rearranger.settings.migration": "true"
+  }
+}]]></component>
+  <component name="RecentsManager">
+    <key name="MoveFile.RECENT_KEYS">
+      <recent name="$PROJECT_DIR$" />
+      <recent name="$PROJECT_DIR$/app" />
+    </key>
+  </component>
+  <component name="RunManager">
+    <configuration name="minio_file_manager" type="Python.FastAPI">
+      <option name="additionalOptions" value="--host 0.0.0.0 --port 8000 --reload" />
+      <option name="file" value="$PROJECT_DIR$/app/main.py" />
+      <module name="minio_file_manager" />
+      <option name="ENV_FILES" value="" />
+      <option name="INTERPRETER_OPTIONS" value="" />
+      <option name="PARENT_ENVS" value="true" />
+      <option name="SDK_HOME" value="$PROJECT_DIR$/../anaconda/miniconda3/envs/minio/bin/python" />
+      <option name="SDK_NAME" value="minio" />
+      <option name="WORKING_DIRECTORY" value="" />
+      <option name="IS_MODULE_SDK" value="false" />
+      <option name="ADD_CONTENT_ROOTS" value="true" />
+      <option name="ADD_SOURCE_ROOTS" value="true" />
+      <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
+      <option name="launchJavascriptDebuger" value="false" />
+      <method v="2" />
+    </configuration>
+  </component>
+  <component name="SharedIndexes">
+    <attachedChunks>
+      <set>
+        <option value="bundled-python-sdk-09665e90c3a7-b11f5e8da5ad-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-233.15026.15" />
+      </set>
+    </attachedChunks>
+  </component>
+  <component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="应用程序级" UseSingleDictionary="true" transferred="true" />
+  <component name="TaskManager">
+    <task active="true" id="Default" summary="默认任务">
+      <changelist id="faca4961-58ca-45f4-8a0e-73812dfa125a" name="更改" comment="" />
+      <created>1763893493215</created>
+      <option name="number" value="Default" />
+      <option name="presentableId" value="Default" />
+      <updated>1763893493215</updated>
+      <workItem from="1763893536383" duration="6906000" />
+    </task>
+    <servers />
+  </component>
+  <component name="TypeScriptGeneratedFilesManager">
+    <option name="version" value="3" />
+  </component>
+  <component name="Vcs.Log.Tabs.Properties">
+    <option name="TAB_STATES">
+      <map>
+        <entry key="MAIN">
+          <value>
+            <State />
+          </value>
+        </entry>
+      </map>
+    </option>
+  </component>
+  <component name="com.intellij.coverage.CoverageDataManagerImpl">
+    <SUITE FILE_PATH="coverage/minio_file_manager$minio_file_manager.coverage" NAME="minio_file_manager 覆盖结果" MODIFIED="1763911913066" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="true" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="" />
+  </component>
+</project>

+ 27 - 0
Dockerfile

@@ -0,0 +1,27 @@
+# 使用官方轻量级 Python 镜像
+FROM python:3.9-slim
+
+# 设置工作目录
+WORKDIR /app
+
+# 设置环境变量,防止 Python 生成 .pyc 文件,并实时打印日志
+ENV PYTHONDONTWRITEBYTECODE 1
+ENV PYTHONUNBUFFERED 1
+
+# 安装系统依赖 (如果需要)
+# RUN apt-get update && apt-get install -y gcc
+
+# 复制依赖文件
+COPY requirements.txt .
+
+# 安装 Python 依赖
+RUN pip install --no-cache-dir -r requirements.txt
+
+# 复制项目代码
+COPY app .
+
+# 暴露端口
+EXPOSE 8000
+
+# 启动命令
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

+ 0 - 0
app/api/__init__.py


+ 105 - 0
app/api/endpoints.py

@@ -0,0 +1,105 @@
+# app/api/endpoints.py
+from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Query
+from fastapi.responses import StreamingResponse
+from typing import List
+import urllib.parse
+
+from app.models.schemas import FileItem, CreateFolderRequest
+from app.services.minio_service import minio_service
+from minio.error import S3Error
+
+router = APIRouter()
+
+
+@router.get("/files", response_model=List[FileItem])
+async def list_files(path: str = "/"):
+    try:
+        return minio_service.list_files(path)
+    except S3Error as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+# 在 app/api/endpoints.py 中修改 upload_file 接口
+@router.post("/upload")
+async def upload_file(
+        file: UploadFile = File(...),
+        path: str = Form(...)
+):
+    """
+    path: 完整的文件路径 (例如: folder/subfolder/image.png)
+    """
+    try:
+        # 调试信息
+        print(f"上传文件: {file.filename}, 目标路径: {path}")
+
+        # 确保路径不以 / 开头(MinIO 规范)
+        if path.startswith('/'):
+            path = path[1:]
+
+        # 获取文件大小
+        # 获取文件大小 (UploadFile 的 spool_max_size 后会存入磁盘或内存)
+        # 为了稳妥起见,移动指针到最后获取大小,再移回
+        file.file.seek(0, 2)
+        size = file.file.tell()
+        file.file.seek(0)
+
+        minio_service.upload_file(
+            file.file,
+            path,
+            file.content_type or "application/octet-stream",
+            size
+        )
+        return {"message": "Upload successful", "filename": file.filename, "path": path}
+    except Exception as e:
+        print(f"上传错误: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@router.post("/folder")
+async def create_folder(req: CreateFolderRequest):
+    try:
+        minio_service.create_folder(req.path)
+        return {"message": "Folder created"}
+    except S3Error as e:
+        raise HTTPException(status_code=500, detail=str(e))
+@router.get("/download")
+async def download_file(path: str):
+    try:
+        data_stream = minio_service.get_file_stream(path)
+        filename = path.split("/")[-1]
+        quoted_filename = urllib.parse.quote(filename)
+
+        return StreamingResponse(
+            data_stream,
+            media_type="application/octet-stream",
+            headers={
+                "Content-Disposition": f"attachment; filename*=UTF-8''{quoted_filename}"
+            }
+        )
+    except Exception as e:
+        raise HTTPException(status_code=404, detail="File not found or error")
+
+
+@router.delete("/delete")
+async def delete_item(path: str):
+    try:
+        is_dir = path.endswith("/")
+        minio_service.delete_file(path, is_dir)
+        return {"message": "Deleted successfully"}
+    except S3Error as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/preview")
+async def preview_file(path: str):
+    try:
+        url = minio_service.get_presigned_url(path)
+        return {"url": url}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+@router.get("/storage-info")
+async def get_storage_info():
+    try:
+        return minio_service.get_storage_info()
+    except S3Error as e:
+        raise HTTPException(status_code=500, detail=str(e))

+ 0 - 0
app/core/__init__.py


+ 24 - 0
app/core/config.py

@@ -0,0 +1,24 @@
+from pydantic_settings import BaseSettings
+from functools import lru_cache
+
+
+class Settings(BaseSettings):
+    PROJECT_NAME: str
+    API_V1_STR: str
+
+    MINIO_ENDPOINT: str
+    MINIO_ACCESS_KEY: str
+    MINIO_SECRET_KEY: str
+    MINIO_BUCKET_NAME: str
+    MINIO_SECURE: bool = False
+
+    class Config:
+        env_file = ".env"
+
+
+@lru_cache()
+def get_settings():
+    return Settings()
+
+
+settings = get_settings()

+ 30 - 0
app/main.py

@@ -0,0 +1,30 @@
+# app/main.py
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from app.core.config import settings
+from app.api.endpoints import router
+
+app = FastAPI(
+    title=settings.PROJECT_NAME,
+    openapi_url=f"{settings.API_V1_STR}/openapi.json"
+)
+
+# CORS 配置
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["*"], # 生产环境建议改为具体的 ["http://localhost:8080"]
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+# 注册路由
+app.include_router(router, prefix=settings.API_V1_STR) # 前缀 /api
+
+@app.get("/")
+async def root():
+    return {"message": "MinIO FileManager Backend is Running"}
+
+if __name__ == "__main__":
+    import uvicorn
+    uvicorn.run("app.main:app", host="0.0.0.0", port=8080, reload=True)

+ 0 - 0
app/models/__init__.py


+ 14 - 0
app/models/schemas.py

@@ -0,0 +1,14 @@
+# app/models/schemas.py
+from pydantic import BaseModel
+from typing import Optional, List
+
+class FileItem(BaseModel):
+    name: str
+    path: str       # MinIO 中的完整 Key
+    is_dir: bool
+    size: int
+    last_modified: Optional[str] = None
+    content_type: Optional[str] = None
+
+class CreateFolderRequest(BaseModel):
+    path: str       # 文件夹的完整路径

+ 0 - 0
app/services/__init__.py


+ 178 - 0
app/services/minio_service.py

@@ -0,0 +1,178 @@
+# app/services/minio_service.py
+from minio import Minio
+from minio.error import S3Error
+from app.core.config import settings
+import io
+from datetime import timedelta
+
+
+class MinioService:
+    def __init__(self):
+        self.client = Minio(
+            settings.MINIO_ENDPOINT,
+            access_key=settings.MINIO_ACCESS_KEY,
+            secret_key=settings.MINIO_SECRET_KEY,
+            secure=settings.MINIO_SECURE
+        )
+        self.bucket = settings.MINIO_BUCKET_NAME
+        self._ensure_bucket_exists()
+
+    def _ensure_bucket_exists(self):
+        if not self.client.bucket_exists(self.bucket):
+            self.client.make_bucket(self.bucket)
+
+    # 在 app/services/minio_service.py 中修复 list_files 方法
+    def list_files(self, prefix: str = ""):
+        # 清理前缀
+        if prefix == "/":
+            prefix = ""
+        elif prefix.startswith("/"):
+            prefix = prefix[1:]
+
+        if prefix and not prefix.endswith("/"):
+            prefix += "/"
+
+        print(f"列出文件,前缀: '{prefix}'")
+
+        try:
+            objects = self.client.list_objects(self.bucket, prefix=prefix, recursive=False)
+
+            results = []
+            for obj in objects:
+                # 跳过与前缀完全相同的对象(通常是文件夹标记)
+                if obj.object_name == prefix:
+                    continue
+
+                is_dir = obj.is_dir
+
+                # 获取显示名称
+                if is_dir:
+                    # 对于目录,去掉末尾的斜杠
+                    name = obj.object_name[len(prefix):].rstrip('/')
+                else:
+                    name = obj.object_name[len(prefix):]
+
+                # 跳过空名称
+                if not name:
+                    continue
+
+                results.append({
+                    "name": name,
+                    "path": obj.object_name,
+                    "is_dir": is_dir,
+                    "size": obj.size if not is_dir else 0,
+                    "last_modified": str(obj.last_modified) if not is_dir else None,
+                    "content_type": obj.content_type
+                })
+
+            # 排序
+            results.sort(key=lambda x: (not x['is_dir'], x['name'].lower()))
+            print(f"找到 {len(results)} 个文件/文件夹")
+            return results
+
+        except S3Error as e:
+            print(f"列出文件错误: {e}")
+            raise e
+
+    # def list_files(self, prefix: str = ""):
+    #     # 处理前缀,确保以 / 结尾(除非是根目录)
+    #     if prefix == "/":
+    #         prefix = ""
+    #     elif prefix and not prefix.endswith("/"):
+    #         prefix += "/"
+    #
+    #     objects = self.client.list_objects(self.bucket, prefix=prefix, recursive=False)
+    #
+    #     results = []
+    #     for obj in objects:
+    #         # 跳过占位符对象
+    #         if obj.object_name == prefix:
+    #             continue
+    #
+    #         is_dir = obj.is_dir
+    #         # 去掉前缀获取显示名称
+    #         name = obj.object_name[len(prefix):]
+    #         if is_dir:
+    #             name = name.rstrip("/")
+    #
+    #         results.append({
+    #             "name": name,
+    #             "path": obj.object_name,
+    #             "is_dir": is_dir,
+    #             "size": obj.size if not is_dir else 0,
+    #             "last_modified": str(obj.last_modified) if not is_dir else None,
+    #             "content_type": obj.content_type
+    #         })
+    #
+    #     # 排序:文件夹在前,然后按名称
+    #     results.sort(key=lambda x: (not x['is_dir'], x['name']))
+    #     return results
+
+    def upload_file(self, file_obj, file_path: str, content_type: str, file_size: int):
+        # file_obj 是 spooled temp file
+        self.client.put_object(
+            self.bucket,
+            file_path,
+            file_obj,
+            length=file_size,  # 如果无法获取确切大小,可以用 -1 并设置 part_size
+            content_type=content_type
+        )
+
+    def create_folder(self, folder_path: str):
+        if not folder_path.endswith("/"):
+            folder_path += "/"
+        # 创建一个空的 Object 以模拟文件夹
+        self.client.put_object(
+            self.bucket,
+            folder_path,
+            io.BytesIO(b""),
+            0
+        )
+
+    def get_file_stream(self, file_path: str):
+        return self.client.get_object(self.bucket, file_path)
+
+    def delete_file(self, path: str, is_dir: bool = False):
+        if is_dir:
+            # 递归删除
+            objects_to_delete = self.client.list_objects(self.bucket, prefix=path, recursive=True)
+            for obj in objects_to_delete:
+                self.client.remove_object(self.bucket, obj.object_name)
+        else:
+            self.client.remove_object(self.bucket, path)
+
+    def get_presigned_url(self, path: str):
+        return self.client.get_presigned_url(
+            "GET",
+            self.bucket,
+            path,
+            expires=timedelta(hours=1)
+        )
+
+    def get_storage_info(self):
+        """获取存储桶使用情况"""
+        try:
+            # 获取存储桶的总大小
+            total_size = 0
+            objects = self.client.list_objects(self.bucket, recursive=True)
+
+            for obj in objects:
+                total_size += obj.size
+
+            # 这里可以设置存储桶的总容量(根据实际情况调整)
+            total_capacity = 10 * 1024 * 1024 * 1024  # 10GB 示例
+
+            used_percentage = (total_size / total_capacity) * 100 if total_capacity > 0 else 0
+
+            return {
+                "used_size": total_size,
+                "total_capacity": total_capacity,
+                "used_percentage": round(used_percentage, 1),
+                "available_size": total_capacity - total_size
+            }
+        except S3Error as e:
+            raise e
+
+
+# 实例化单例
+minio_service = MinioService()

+ 13 - 0
docker-compose.yml

@@ -0,0 +1,13 @@
+version: '3.8'
+
+services:
+  backend:
+    build: .
+    container_name: filemanager_backend
+    ports:
+      - "8000:8000"
+    env_file:
+      - .env
+    restart: always
+    volumes:
+      - ./app:/app/app  # 挂载代码目录,方便热更新(生产环境可去掉)

+ 7 - 0
requirements.txt

@@ -0,0 +1,7 @@
+fastapi==0.109.0
+uvicorn==0.27.0
+minio==7.2.3
+python-multipart==0.0.6
+python-dotenv==1.0.1
+pydantic==2.6.1
+pydantic-settings==2.1.0

+ 11 - 0
test_main.http

@@ -0,0 +1,11 @@
+# Test your FastAPI endpoints
+
+GET http://127.0.0.1:8000/
+Accept: application/json
+
+###
+
+GET http://127.0.0.1:8000/hello/User
+Accept: application/json
+
+###