|
|
@@ -1,161 +1,208 @@
|
|
|
# app/api/endpoints.py
|
|
|
-from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Query
|
|
|
+from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends
|
|
|
from fastapi.responses import StreamingResponse
|
|
|
+from app.api.deps import get_request_context
|
|
|
from typing import List, Optional
|
|
|
import urllib.parse
|
|
|
|
|
|
from app.models.schemas import FileItem, CreateFolderRequest
|
|
|
from app.services.minio_service import minio_service
|
|
|
from app.services.img_minio_service import img_minio_service
|
|
|
+from app.utils.path_utils import validate_path, sanitize_filename
|
|
|
+from app.utils.logger_utils import logger
|
|
|
+from app.core.config import settings
|
|
|
+from app.core.exceptions import (
|
|
|
+ NotFoundException,
|
|
|
+ ValidationException,
|
|
|
+ InternalServerException,
|
|
|
+ ErrorMessage
|
|
|
+)
|
|
|
from minio.error import S3Error
|
|
|
|
|
|
-router = APIRouter()
|
|
|
+router = APIRouter(dependencies=[Depends(get_request_context)])
|
|
|
|
|
|
|
|
|
@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))
|
|
|
+async def list_files(
|
|
|
+ path: str = "/",
|
|
|
+):
|
|
|
+ # 验证路径
|
|
|
+ if path != "/":
|
|
|
+ path = validate_path(path, allow_root=True)
|
|
|
+ return minio_service.list_files(path)
|
|
|
|
|
|
|
|
|
-# 在 app/api/endpoints.py 中修改 upload_file 接口
|
|
|
@router.post("/upload")
|
|
|
async def upload_file(
|
|
|
file: UploadFile = File(...),
|
|
|
- path: str = Form(...)
|
|
|
+ path: str = Form(...),
|
|
|
):
|
|
|
"""
|
|
|
+ 上传文件到MinIO存储
|
|
|
+
|
|
|
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
|
|
|
+ # 验证和清理路径
|
|
|
+ path = validate_path(path)
|
|
|
+
|
|
|
+ # 验证文件名
|
|
|
+ if not file.filename:
|
|
|
+ raise ValidationException(detail="文件名不能为空")
|
|
|
+ filename = sanitize_filename(file.filename)
|
|
|
+
|
|
|
+ # 获取文件大小
|
|
|
+ file.file.seek(0, 2)
|
|
|
+ size = file.file.tell()
|
|
|
+ file.file.seek(0)
|
|
|
+
|
|
|
+ # 检查文件大小限制
|
|
|
+ if size > settings.MAX_UPLOAD_SIZE:
|
|
|
+ max_size_mb = settings.MAX_UPLOAD_SIZE / (1024 * 1024)
|
|
|
+ raise HTTPException(
|
|
|
+ status_code=413,
|
|
|
+ detail=f"文件大小超过限制 ({max_size_mb}MB)"
|
|
|
)
|
|
|
- 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))
|
|
|
+
|
|
|
+ # 构建完整路径
|
|
|
+ full_path = f"{path}/{filename}" if path else filename
|
|
|
+
|
|
|
+ logger.info(f"上传文件: {filename}, 目标路径: {full_path}, 大小: {size} bytes")
|
|
|
+
|
|
|
+ minio_service.upload_file(
|
|
|
+ file.file,
|
|
|
+ full_path,
|
|
|
+ file.content_type or "application/octet-stream",
|
|
|
+ size,
|
|
|
+ )
|
|
|
+ return {"message": "Upload successful", "filename": filename, "path": full_path}
|
|
|
|
|
|
@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))
|
|
|
+async def create_folder(
|
|
|
+ req: CreateFolderRequest,
|
|
|
+):
|
|
|
+ # 验证路径
|
|
|
+ path = validate_path(req.path)
|
|
|
+ minio_service.create_folder(path)
|
|
|
+ logger.info(f"创建文件夹: {path}")
|
|
|
+ return {"message": "Folder created"}
|
|
|
|
|
|
@router.get("/download")
|
|
|
-async def download_file(path: str):
|
|
|
+async def download_file(
|
|
|
+ path: str,
|
|
|
+):
|
|
|
+ # 验证路径
|
|
|
+ path = validate_path(path)
|
|
|
+
|
|
|
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")
|
|
|
+ except S3Error as e:
|
|
|
+ if e.code == "NoSuchKey":
|
|
|
+ logger.warning(f"文件不存在: {path}")
|
|
|
+ raise NotFoundException(detail=ErrorMessage.FILE_NOT_FOUND)
|
|
|
+ raise
|
|
|
+
|
|
|
+ filename = path.split("/")[-1]
|
|
|
+ quoted_filename = urllib.parse.quote(filename)
|
|
|
+
|
|
|
+ logger.info(f"下载文件: {path}")
|
|
|
+
|
|
|
+ return StreamingResponse(
|
|
|
+ data_stream,
|
|
|
+ media_type="application/octet-stream",
|
|
|
+ headers={
|
|
|
+ "Content-Disposition": f"attachment; filename*=UTF-8''{quoted_filename}"
|
|
|
+ }
|
|
|
+ )
|
|
|
|
|
|
|
|
|
@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))
|
|
|
+async def delete_item(
|
|
|
+ path: str,
|
|
|
+):
|
|
|
+ # 验证路径
|
|
|
+ path = validate_path(path)
|
|
|
+
|
|
|
+ is_dir = path.endswith("/")
|
|
|
+ minio_service.delete_file(path, is_dir)
|
|
|
+ logger.info(f"删除{'文件夹' if is_dir else '文件'}: {path}")
|
|
|
+ return {"message": "Deleted successfully"}
|
|
|
|
|
|
|
|
|
@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))
|
|
|
+async def preview_file(
|
|
|
+ path: str,
|
|
|
+):
|
|
|
+ # 验证路径
|
|
|
+ path = validate_path(path)
|
|
|
+
|
|
|
+ url = minio_service.get_presigned_url(path)
|
|
|
+ return {"url": url}
|
|
|
|
|
|
@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))
|
|
|
+ return minio_service.get_storage_info()
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/img")
|
|
|
-async def preview_file(path: str):
|
|
|
- try:
|
|
|
- url = img_minio_service.get_presigned_url(path)
|
|
|
- return {"url": url}
|
|
|
- except Exception as e:
|
|
|
- raise HTTPException(status_code=500, detail=str(e))
|
|
|
+async def preview_image(path: str):
|
|
|
+ # 验证路径
|
|
|
+ path = validate_path(path)
|
|
|
+
|
|
|
+ url = img_minio_service.get_presigned_url(path)
|
|
|
+ return {"url": url}
|
|
|
|
|
|
|
|
|
@router.post("/img/upload")
|
|
|
async def upload_image(
|
|
|
file: UploadFile = File(..., description="上传的图片文件"),
|
|
|
- path: Optional[str] = Form(..., description="指定存储路径,不指定则使用随机文件名")
|
|
|
+ path: Optional[str] = Form(None, description="指定存储路径,不指定则使用文件名")
|
|
|
):
|
|
|
"""
|
|
|
上传图片到MinIO存储
|
|
|
"""
|
|
|
- try:
|
|
|
- # 读取文件内容
|
|
|
- file_content = await file.read()
|
|
|
-
|
|
|
- # 生成对象名称:路径 + 文件名
|
|
|
- if path:
|
|
|
- # 清理路径格式,确保以/结尾
|
|
|
- if not path.endswith('/'):
|
|
|
- path = path + '/'
|
|
|
- object_name = f"{path}{file.filename}"
|
|
|
- else:
|
|
|
- # 直接使用文件名
|
|
|
- object_name = file.filename
|
|
|
-
|
|
|
- # 上传到MinIO
|
|
|
- stored_name = img_minio_service.upload_image_data(
|
|
|
- image_data=file_content,
|
|
|
- object_name=object_name,
|
|
|
- content_type=file.content_type
|
|
|
+ # 验证文件名
|
|
|
+ if not file.filename:
|
|
|
+ raise ValidationException(detail="文件名不能为空")
|
|
|
+ filename = sanitize_filename(file.filename)
|
|
|
+
|
|
|
+ # 读取文件内容
|
|
|
+ file_content = await file.read()
|
|
|
+
|
|
|
+ # 检查文件大小限制
|
|
|
+ if len(file_content) > settings.MAX_UPLOAD_SIZE:
|
|
|
+ max_size_mb = settings.MAX_UPLOAD_SIZE / (1024 * 1024)
|
|
|
+ raise HTTPException(
|
|
|
+ status_code=413,
|
|
|
+ detail=f"文件大小超过限制 ({max_size_mb}MB)"
|
|
|
)
|
|
|
|
|
|
- return {
|
|
|
- "message": "图片上传成功",
|
|
|
- "object_name": stored_name,
|
|
|
- "file_size": len(file_content),
|
|
|
- "content_type": file.content_type
|
|
|
- }
|
|
|
-
|
|
|
- except Exception as e:
|
|
|
- print(e)
|
|
|
- raise HTTPException(status_code=500, detail=f"上传失败: {str(e)}")
|
|
|
+ # 生成对象名称:路径 + 文件名
|
|
|
+ if path:
|
|
|
+ # 验证和清理路径
|
|
|
+ path = validate_path(path)
|
|
|
+ if not path.endswith('/'):
|
|
|
+ path = path + '/'
|
|
|
+ object_name = f"{path}{filename}"
|
|
|
+ else:
|
|
|
+ # 直接使用文件名
|
|
|
+ object_name = filename
|
|
|
+
|
|
|
+ logger.info(f"上传图片: {filename}, 目标路径: {object_name}, 大小: {len(file_content)} bytes")
|
|
|
+
|
|
|
+ # 上传到MinIO
|
|
|
+ stored_name = img_minio_service.upload_image_data(
|
|
|
+ image_data=file_content,
|
|
|
+ object_name=object_name,
|
|
|
+ content_type=file.content_type or "image/jpeg"
|
|
|
+ )
|
|
|
+
|
|
|
+ return {
|
|
|
+ "message": "图片上传成功",
|
|
|
+ "object_name": stored_name,
|
|
|
+ "file_size": len(file_content),
|
|
|
+ "content_type": file.content_type
|
|
|
+ }
|
|
|
|
|
|
|
|
|
@router.put("/img/rename")
|
|
|
@@ -167,32 +214,29 @@ async def rename_image(
|
|
|
"""
|
|
|
重命名或移动图片文件
|
|
|
"""
|
|
|
- try:
|
|
|
- # 检查原文件是否存在
|
|
|
- if not img_minio_service.image_exists(old_path):
|
|
|
- raise HTTPException(status_code=404, detail="原文件不存在")
|
|
|
-
|
|
|
- # 检查新文件是否已存在
|
|
|
- if not overwrite and img_minio_service.image_exists(new_path):
|
|
|
- raise HTTPException(
|
|
|
- status_code=400,
|
|
|
- detail="目标文件已存在,如需覆盖请设置 overwrite=true"
|
|
|
- )
|
|
|
-
|
|
|
- # 执行重命名操作
|
|
|
- success = img_minio_service.rename_image(old_path, new_path)
|
|
|
-
|
|
|
- if success:
|
|
|
- return {
|
|
|
- "message": "文件重命名成功",
|
|
|
- "old_path": old_path,
|
|
|
- "new_path": new_path
|
|
|
- }
|
|
|
- else:
|
|
|
- raise HTTPException(status_code=500, detail="文件重命名失败")
|
|
|
-
|
|
|
- except HTTPException:
|
|
|
- raise
|
|
|
- except Exception as e:
|
|
|
- print(e)
|
|
|
- raise HTTPException(status_code=500, detail=f"重命名失败: {str(e)}")
|
|
|
+ # 验证路径
|
|
|
+ old_path = validate_path(old_path)
|
|
|
+ new_path = validate_path(new_path)
|
|
|
+
|
|
|
+ # 检查原文件是否存在
|
|
|
+ if not img_minio_service.image_exists(old_path):
|
|
|
+ raise NotFoundException(detail="原文件不存在")
|
|
|
+
|
|
|
+ # 检查新文件是否已存在
|
|
|
+ if not overwrite and img_minio_service.image_exists(new_path):
|
|
|
+ raise ValidationException(
|
|
|
+ detail="目标文件已存在,如需覆盖请设置 overwrite=true"
|
|
|
+ )
|
|
|
+
|
|
|
+ # 执行重命名操作
|
|
|
+ success = img_minio_service.rename_image(old_path, new_path)
|
|
|
+
|
|
|
+ if success:
|
|
|
+ logger.info(f"重命名图片: {old_path} -> {new_path}")
|
|
|
+ return {
|
|
|
+ "message": "文件重命名成功",
|
|
|
+ "old_path": old_path,
|
|
|
+ "new_path": new_path
|
|
|
+ }
|
|
|
+ else:
|
|
|
+ raise InternalServerException(detail="文件重命名失败")
|