Ver Fonte

多货主init

zh há 4 meses atrás
pai
commit
4ff6cab5c4

+ 6 - 4
.env

@@ -4,10 +4,12 @@ API_V1_STR=/api/minio-manager
 
 # MinIO 配置
 MINIO_ENDPOINT=minio:9000
-MINIO_ACCESS_KEY=6596a2de4a92cfe9
-MINIO_SECRET_KEY=809d6749bb70e83bca08b8e5a614f9d8
-MINIO_BUCKET_NAME=user-dufs
-MINIO_SECURE=False  # 如果是HTTPS则为True
+# MINIO_ENDPOINT=192.168.20.251:9000
+
+# JAVA后端接口
+JAVA_API_BASE_URL=https://api.baoshi56.com
+JWT_SECRET_KEY=SWMS-JWT
+JWT_ALGORITHM=HS256
 
 
 IMG_MINIO_ACCESS_KEY=b5c3c3e4f8101e01

+ 40 - 0
app/api/deps.py

@@ -0,0 +1,40 @@
+from fastapi import Depends, Header
+from pydantic import BaseModel
+from app.models.user import User
+from app.utils.auth_utils import auth_service
+from typing import Optional
+import contextvars
+from app.core.exceptions import ValidationException, ErrorMessage
+
+
+class RequestContext(BaseModel):
+    user: User
+    workspace: str
+    authorization: str
+    source: str
+    version: str
+
+request_context_var: contextvars.ContextVar[Optional[RequestContext]] = contextvars.ContextVar(
+    "request_context_var", default=None
+)
+
+async def get_request_context(
+    user: User = Depends(auth_service.verify_and_get_user),
+    workspace: Optional[str] = Header(None, alias="workspace"),
+    authorization: Optional[str] = Header(None),
+    source: Optional[str] = Header(None),
+    version: Optional[str] = Header(None),
+) -> RequestContext:
+    """统一封装请求上下文,避免各接口重复处理头信息"""
+
+    if not workspace:
+        raise ValidationException(detail=ErrorMessage.MISSING_PARAM)
+    ctx = RequestContext(
+        user=user,
+        workspace=workspace,
+        authorization=authorization or "",
+        source=source or "customer",
+        version=version or "V6",
+    )
+    request_context_var.set(ctx)
+    return ctx

+ 178 - 134
app/api/endpoints.py

@@ -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="文件重命名失败")

+ 11 - 9
app/core/config.py

@@ -5,25 +5,27 @@ from functools import lru_cache
 class Settings(BaseSettings):
     PROJECT_NAME: str
     API_V1_STR: str
-
     MINIO_ENDPOINT: str
-    MINIO_SECURE: bool = False
-
-    MINIO_ACCESS_KEY: str
-    MINIO_SECRET_KEY: str
-    MINIO_BUCKET_NAME: str
-
+    # Java API 配置
+    JAVA_API_BASE_URL: str
+    JWT_SECRET_KEY: str
+    JWT_ALGORITHM: str
+    # img 单独配置
     IMG_MINIO_ACCESS_KEY: str
     IMG_MINIO_SECRET_KEY: str
     IMG_MINIO_BUCKET_NAME: str
+    # MinIO 连接配置
+    MINIO_SECURE: bool = False
+    # CORS 配置(可选,用逗号分隔多个域名)
+    CORS_ORIGINS: str = "*"
+    # 文件上传限制(字节,默认 100MB)
+    MAX_UPLOAD_SIZE: int = 100 * 1024 * 1024
 
     class Config:
         env_file = ".env"
 
-
 @lru_cache()
 def get_settings():
     return Settings()
 
-
 settings = get_settings()

+ 184 - 0
app/core/exception_handler.py

@@ -0,0 +1,184 @@
+"""
+全局异常处理器
+类似Java的@ControllerAdvice,统一处理所有异常
+"""
+from fastapi import Request, status
+from fastapi.responses import JSONResponse
+from fastapi.exceptions import RequestValidationError
+from starlette.exceptions import HTTPException as StarletteHTTPException
+from app.core.exceptions import (
+    BaseAPIException,
+    BusinessException,
+    UnauthorizedException,
+    ForbiddenException,
+    NotFoundException,
+    ValidationException,
+    InternalServerException,
+    StatusCode
+)
+from app.utils.logger_utils import logger
+
+
+async def base_api_exception_handler(request: Request, exc: BaseAPIException) -> JSONResponse:
+    """
+    处理自定义API异常
+    返回统一的错误响应格式
+    """
+    logger.warning(
+        f"API异常: {exc.detail} | "
+        f"Status: {exc.status_code} | "
+        f"Code: {exc.code} | "
+        f"Path: {request.url.path}"
+    )
+    
+    return JSONResponse(
+        status_code=exc.status_code,
+        content={
+            "code": exc.code,
+            "message": exc.detail,
+            "data": None
+        }
+    )
+
+
+async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
+    """
+    处理FastAPI的HTTPException
+    转换为统一的响应格式
+    """
+    logger.warning(
+        f"HTTP异常: {exc.detail} | "
+        f"Status: {exc.status_code} | "
+        f"Path: {request.url.path}"
+    )
+    
+    # 根据状态码确定错误码
+    error_code = exc.status_code
+    if exc.status_code == 401:
+        error_code = StatusCode.UNAUTHORIZED
+    elif exc.status_code == 403:
+        error_code = StatusCode.FORBIDDEN
+    elif exc.status_code == 404:
+        error_code = StatusCode.NOT_FOUND
+    elif exc.status_code >= 500:
+        error_code = StatusCode.INTERNAL_SERVER_ERROR
+    
+    return JSONResponse(
+        status_code=exc.status_code,
+        content={
+            "code": error_code,
+            "message": exc.detail,
+            "data": None
+        }
+    )
+
+
+async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
+    """
+    处理请求参数验证异常
+    """
+    errors = exc.errors()
+    error_messages = []
+    for error in errors:
+        field = ".".join(str(loc) for loc in error.get("loc", []))
+        msg = error.get("msg", "参数验证失败")
+        error_messages.append(f"{field}: {msg}")
+    
+    detail = "; ".join(error_messages) if error_messages else "参数验证失败"
+    
+    logger.warning(
+        f"参数验证失败: {detail} | "
+        f"Path: {request.url.path}"
+    )
+    
+    return JSONResponse(
+        status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+        content={
+            "code": StatusCode.BAD_REQUEST,
+            "message": detail,
+            "data": None
+        }
+    )
+
+
+async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
+    """
+    处理所有未捕获的异常
+    这是最后的异常处理兜底
+    """
+    logger.error(
+        f"未处理的异常: {str(exc)} | "
+        f"Type: {type(exc).__name__} | "
+        f"Path: {request.url.path}",
+        exc_info=True
+    )
+    
+    # 检查异常消息中是否包含特定业务错误信息
+    error_message = str(exc)
+    
+    # 权限相关错误
+    if "无该货主权限" in error_message or "无权限" in error_message:
+        return JSONResponse(
+            status_code=403,
+            content={
+                "code": StatusCode.FORBIDDEN,
+                "message": error_message if error_message else "无该货主权限!",
+                "data": None
+            }
+        )
+    
+    # Token相关错误
+    if "登录已过期" in error_message or "token" in error_message.lower() or "登录" in error_message:
+        return JSONResponse(
+            status_code=401,
+            content={
+                "code": StatusCode.INVALID_TOKEN,
+                "message": error_message if error_message else "无效的登录信息",
+                "data": None
+            }
+        )
+    
+    # 资源不存在
+    if "不存在" in error_message or "not found" in error_message.lower():
+        return JSONResponse(
+            status_code=404,
+            content={
+                "code": StatusCode.NOT_FOUND,
+                "message": error_message if error_message else "资源不存在",
+                "data": None
+            }
+        )
+    
+    # 默认返回500错误
+    return JSONResponse(
+        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+        content={
+            "code": StatusCode.INTERNAL_SERVER_ERROR,
+            "message": error_message if error_message else "服务器内部错误",
+            "data": None
+        }
+    )
+
+
+def register_exception_handlers(app):
+    """
+    注册所有异常处理器到FastAPI应用
+    """
+    # 自定义异常处理器(优先级最高)
+    app.add_exception_handler(BaseAPIException, base_api_exception_handler)
+    app.add_exception_handler(BusinessException, base_api_exception_handler)
+    app.add_exception_handler(UnauthorizedException, base_api_exception_handler)
+    app.add_exception_handler(ForbiddenException, base_api_exception_handler)
+    app.add_exception_handler(NotFoundException, base_api_exception_handler)
+    app.add_exception_handler(ValidationException, base_api_exception_handler)
+    app.add_exception_handler(InternalServerException, base_api_exception_handler)
+    
+    # FastAPI的HTTPException
+    app.add_exception_handler(StarletteHTTPException, http_exception_handler)
+    
+    # 请求验证异常
+    app.add_exception_handler(RequestValidationError, validation_exception_handler)
+    
+    # 通用异常处理器(兜底,必须最后注册)
+    app.add_exception_handler(Exception, general_exception_handler)
+

+ 113 - 0
app/core/exceptions.py

@@ -0,0 +1,113 @@
+"""
+自定义异常类和全局状态码定义
+"""
+from typing import Optional
+
+
+class BaseAPIException(Exception):
+    """基础API异常类"""
+    def __init__(self, status_code: int, detail: str, code: Optional[int] = None):
+        self.status_code = status_code
+        self.detail = detail
+        self.code = code or status_code
+        super().__init__(self.detail)
+
+
+class BusinessException(BaseAPIException):
+    """业务异常"""
+    def __init__(self, detail: str, status_code: int = 400, code: Optional[int] = None):
+        super().__init__(status_code, detail, code)
+
+
+class UnauthorizedException(BaseAPIException):
+    """未授权异常"""
+    def __init__(self, detail: str = "未经过系统授权", code: Optional[int] = None):
+        super().__init__(401, detail, code or 401)
+
+
+class ForbiddenException(BaseAPIException):
+    """权限不足异常"""
+    def __init__(self, detail: str = "无权限访问", code: Optional[int] = None):
+        super().__init__(403, detail, code or 403)
+
+
+class NotFoundException(BaseAPIException):
+    """资源不存在异常"""
+    def __init__(self, detail: str = "资源不存在", code: Optional[int] = None):
+        super().__init__(404, detail, code or 404)
+
+
+class ValidationException(BaseAPIException):
+    """参数验证异常"""
+    def __init__(self, detail: str = "参数验证失败", code: Optional[int] = None):
+        super().__init__(400, detail, code or 400)
+
+
+class InternalServerException(BaseAPIException):
+    """服务器内部错误异常"""
+    def __init__(self, detail: str = "服务器内部错误", code: Optional[int] = None):
+        super().__init__(500, detail, code or 500)
+
+
+# 全局状态码定义
+class StatusCode:
+    """全局状态码定义"""
+    # 成功
+    SUCCESS = 200
+    
+    # 客户端错误
+    BAD_REQUEST = 400  # 请求参数错误
+    UNAUTHORIZED = 401  # 未授权
+    FORBIDDEN = 403  # 权限不足
+    NOT_FOUND = 404  # 资源不存在
+    METHOD_NOT_ALLOWED = 405  # 方法不允许
+    NOT_ACCEPTABLE = 406  # 请求头不支持
+    REQUEST_TIMEOUT = 408  # 请求超时
+    CONFLICT = 409  # 资源冲突
+    PAYLOAD_TOO_LARGE = 413  # 文件过大
+    
+    # 服务器错误
+    INTERNAL_SERVER_ERROR = 500  # 服务器内部错误
+    BAD_GATEWAY = 502  # 网关错误
+    SERVICE_UNAVAILABLE = 503  # 服务不可用
+    GATEWAY_TIMEOUT = 504  # 网关超时
+    
+    # 业务错误码(自定义)
+    INVALID_TOKEN = 600  # 无效的登录信息
+    TOKEN_EXPIRED = 601  # Token过期
+
+
+# 全局错误消息定义
+class ErrorMessage:
+    """全局错误消息定义"""
+    # 认证相关
+    UNAUTHORIZED = "未经过系统授权"
+    INVALID_TOKEN = "无效的登录信息"
+    TOKEN_EXPIRED = "当前登录已过期,请重新登录"
+    
+    # 权限相关
+    FORBIDDEN = "无权限访问"
+    NO_OWNER_PERMISSION = "无该货主权限!"
+    
+    # 资源相关
+    NOT_FOUND = "资源不存在"
+    FILE_NOT_FOUND = "文件不存在"
+    
+    # 参数相关
+    MISSING_PARAM = "缺少必要参数"
+    INVALID_PARAM = "参数验证失败"
+    INVALID_HEADER = "请求的头部不支持"
+    
+    # 业务相关
+    BUCKET_CONFIG_FAILED = "获取桶配置失败"
+    UPLOAD_FAILED = "上传文件失败"
+    DOWNLOAD_FAILED = "下载文件失败"
+    DELETE_FAILED = "删除失败"
+    CREATE_FOLDER_FAILED = "创建文件夹失败"
+    LIST_FILES_FAILED = "列出文件失败"
+    
+    # 系统相关
+    INTERNAL_ERROR = "服务器内部错误"
+    REQUEST_FAILED = "请求失败"
+    INVALID_RESPONSE = "无效的JSON响应"
+

+ 11 - 0
app/core/security.py

@@ -0,0 +1,11 @@
+from enum import Enum
+
+
+class ServerEnum(str, Enum):
+    WEB = "web"
+    CUSTOMER = "customer"
+    APP = "app"
+
+    @classmethod
+    def exist(cls, server_name: str) -> bool:
+        return server_name in cls._value2member_map_

+ 11 - 1
app/main.py

@@ -3,16 +3,21 @@ from fastapi import FastAPI
 from fastapi.middleware.cors import CORSMiddleware
 from app.core.config import settings
 from app.api.endpoints import router
+from app.core.exception_handler import register_exception_handlers
 
 app = FastAPI(
     title=settings.PROJECT_NAME,
     openapi_url=f"{settings.API_V1_STR}/openapi.json"
 )
 
+# 注册全局异常处理器(必须在路由注册之前)
+register_exception_handlers(app)
+
 # CORS 配置
+cors_origins = settings.CORS_ORIGINS.split(",") if settings.CORS_ORIGINS != "*" else ["*"]
 app.add_middleware(
     CORSMiddleware,
-    allow_origins=["*"],
+    allow_origins=[origin.strip() for origin in cors_origins],
     allow_credentials=True,
     allow_methods=["*"],
     allow_headers=["*"],
@@ -25,6 +30,11 @@ app.include_router(router, prefix=settings.API_V1_STR) # 前缀 /api
 async def root():
     return {"message": "MinIO FileManager Backend is Running"}
 
+@app.get("/health")
+async def health_check():
+    """健康检查端点"""
+    return {"status": "healthy"}
+
 if __name__ == "__main__":
     import uvicorn
     uvicorn.run("app.main:app", host="0.0.0.0", port=9002, reload=True)

+ 9 - 0
app/models/user.py

@@ -0,0 +1,9 @@
+from pydantic import BaseModel
+
+
+class User(BaseModel):
+    id: str
+    token: str
+
+    class Config:
+        from_attributes = True

+ 1 - 0
app/providers/__init__.py

@@ -0,0 +1 @@
+

+ 106 - 0
app/providers/minio_client_provider.py

@@ -0,0 +1,106 @@
+from typing import Dict, Tuple
+import io
+from minio import Minio
+from cachetools import TTLCache
+from app.services.bucket_config_service import bucket_config_service
+from app.core.config import settings
+
+# 缓存已初始化的 MinIO 客户端,key 为 f"{user_id}/{owner_code}"
+minio_client_cache: TTLCache = TTLCache(maxsize=100, ttl=3600)
+
+
+class MinioClientProvider:
+    """
+    提供基于上下文的 MinIO 客户端获取能力,统一封装缓存与配置解析。
+    依赖 bucket_config_service 从 ContextVar 中读取 RequestContext。
+    """
+
+    def get_bucket_config_and_client(self) -> Tuple[Dict, Minio, str]:
+        """
+        获取桶配置和 MinIO 客户端(带缓存),无需传参,直接使用上下文。
+        返回 (bucket_config, client, bucket_name)
+        """
+        # 解析上下文与配置
+        current_ctx, user, owner_code = bucket_config_service.resolve_context()
+        bucket_config = bucket_config_service.get_bucket_config(
+            user=user,
+            owner_code=owner_code,
+            ctx=current_ctx,
+        )
+
+        cache_key = f"{user.id}/{owner_code}"
+
+        # 缓存命中直接返回
+        if cache_key in minio_client_cache:
+            return bucket_config, minio_client_cache[cache_key], bucket_config["minioBucketName"]
+
+        # 创建新的客户端
+        secure = getattr(settings, "MINIO_SECURE", False)
+        client = Minio(
+            settings.MINIO_ENDPOINT,
+            access_key=bucket_config["minioAccessKey"],
+            secret_key=bucket_config["minioSecretKey"],
+            secure=secure,
+        )
+
+        # 确保桶存在
+        bucket_name = bucket_config["minioBucketName"]
+        if not client.bucket_exists(bucket_name):
+            client.make_bucket(bucket_name)
+
+        # 写入缓存
+        minio_client_cache[cache_key] = client
+
+        return bucket_config, client, bucket_name
+
+    # 基础 MinIO 操作封装,自动从上下文解析桶配置与客户端
+
+    def list_objects(self, prefix: str = "", recursive: bool = False):
+        _, client, bucket_name = self.get_bucket_config_and_client()
+        return client.list_objects(bucket_name, prefix=prefix, recursive=recursive)
+
+    def put_object(self, path: str, data, length: int, content_type: str):
+        _, client, bucket_name = self.get_bucket_config_and_client()
+        client.put_object(bucket_name, path, data, length=length, content_type=content_type)
+
+    def put_empty_object(self, path: str):
+        _, client, bucket_name = self.get_bucket_config_and_client()
+        client.put_object(bucket_name, path, io.BytesIO(b""), 0)
+
+    def get_object(self, path: str):
+        _, client, bucket_name = self.get_bucket_config_and_client()
+        return client.get_object(bucket_name, path)
+
+    def remove_object(self, path: str):
+        _, client, bucket_name = self.get_bucket_config_and_client()
+        client.remove_object(bucket_name, path)
+
+    def remove_objects_with_prefix(self, prefix: str):
+        _, client, bucket_name = self.get_bucket_config_and_client()
+        objects = client.list_objects(bucket_name, prefix=prefix, recursive=True)
+        for obj in objects:
+            client.remove_object(bucket_name, obj.object_name)
+
+    def get_presigned_url(self, path: str, expires):
+        _, client, bucket_name = self.get_bucket_config_and_client()
+        return client.get_presigned_url("GET", bucket_name, path, expires=expires)
+
+    def get_storage_info(self) -> Dict:
+        bucket_config, client, bucket_name = self.get_bucket_config_and_client()
+        total_size = 0
+        for obj in client.list_objects(bucket_name, recursive=True):
+            total_size += obj.size
+        quota_gb = bucket_config.get("quotaGb", 10)
+        total_capacity = quota_gb * 1024 * 1024 * 1024
+        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,
+        }
+
+
+# 单例
+minio_client_provider = MinioClientProvider()
+

+ 134 - 0
app/services/bucket_config_service.py

@@ -0,0 +1,134 @@
+from typing import Optional, Dict
+from cachetools import TTLCache
+from app.utils.request_utils import request_service
+from app.models.user import User
+from app.api.deps import RequestContext, request_context_var
+from app.core.exceptions import (
+    BaseAPIException,
+    InternalServerException,
+    ValidationException,
+    ForbiddenException,
+    UnauthorizedException,
+    ErrorMessage
+)
+
+# 创建 TTL 缓存,1小时过期 (3600秒)
+# key 格式: f"{user_id}/{owner_code}"
+bucket_config_cache: TTLCache = TTLCache(maxsize=1000, ttl=3600)
+
+
+class BucketConfigService:
+    """桶配置服务,负责从Java微服务获取桶配置并缓存"""
+
+    def __init__(self):
+        self.java_service = request_service()
+
+    def resolve_context(self) -> tuple[RequestContext, User, str]:
+        """
+        统一解析上下文信息,确保 user 与 owner_code 均可用。
+        返回 (ctx, user, owner_code)。
+        """
+        current_ctx = request_context_var.get()
+        if not current_ctx:
+            raise InternalServerException(detail="未获取到请求上下文 RequestContext")
+        resolved_user = current_ctx.user
+        resolved_owner = current_ctx.workspace
+        if not resolved_user or not resolved_owner:
+            raise ValidationException(detail="获取桶配置失败: 缺少用户或货主代码")
+        return current_ctx, resolved_user, resolved_owner
+
+    def get_bucket_config(
+        self,
+        user: Optional[User] = None,
+        owner_code: Optional[str] = None,
+        ctx: Optional[RequestContext] = None
+    ) -> Dict:
+        """
+        获取桶配置信息,优先从缓存读取,不存在则调用Java接口
+
+        参数:
+            user: 用户对象
+            owner_code: 货主代码
+
+        返回:
+            桶配置字典,包含:
+            - ownerCode: 货主代码
+            - minioAccessKey: MinIO访问密钥
+            - minioSecretKey: MinIO密钥
+            - minioBucketName: 桶名称
+            - quotaGb: 配额(GB)
+        """
+        # 生成缓存key
+        current_ctx, user, owner_code = self.resolve_context()
+
+        cache_key = f"{user.id}/{owner_code}"
+
+        # 尝试从缓存获取
+        if cache_key in bucket_config_cache:
+            return bucket_config_cache[cache_key]
+
+        # 缓存不存在,调用Java接口
+        try:
+            # 构造 Authorization 头;前端传入时带 Bearer,这里容错补全
+            auth_header = user.token or ""
+            if auth_header and not auth_header.lower().startswith("bearer "):
+                auth_header = f"Bearer {auth_header}"
+
+            url = f"/api/basic/owner/minio-config/{owner_code}"
+            # 直接使用上下文中的头信息,避免重复传参
+            config_data = self.java_service.request(
+                method='GET',
+                url=url,
+                auth=True,
+                authorization=current_ctx.authorization or auth_header,
+                source=current_ctx.source or "customer",
+                version=current_ctx.version or "V6"
+            )
+
+            if not config_data:
+                raise InternalServerException(
+                    detail=f"获取货主 {owner_code} 的桶配置失败: 返回数据为空"
+                )
+
+            # 验证必要字段
+            required_fields = ['ownerCode', 'minioAccessKey', 'minioSecretKey', 
+                             'minioBucketName', 'quotaGb']
+            for field in required_fields:
+                if field not in config_data:
+                    raise InternalServerException(
+                        detail=f"桶配置缺少必要字段: {field}"
+                    )
+
+            # 存入缓存
+            bucket_config_cache[cache_key] = config_data
+
+            return config_data
+
+        except BaseAPIException:
+            # 如果是自定义API异常(包括ForbiddenException、UnauthorizedException等),直接抛出
+            raise
+        except Exception as e:
+            # 其他异常包装为内部服务器异常
+            raise InternalServerException(
+                detail=f"获取存储空间失败: {str(e)}"
+            )
+
+    def clear_cache(self, user_id: Optional[str] = None, owner_code: Optional[str] = None):
+        """
+        清除缓存
+
+        参数:
+            user_id: 用户ID,如果提供则只清除该用户的缓存
+            owner_code: 货主代码,如果提供则只清除该货主的缓存
+        """
+        if user_id and owner_code:
+            cache_key = f"{user_id}/{owner_code}"
+            bucket_config_cache.pop(cache_key, None)
+        else:
+            # 清除所有缓存
+            bucket_config_cache.clear()
+
+
+# 创建全局实例
+bucket_config_service = BucketConfigService()
+

+ 11 - 10
app/services/img_minio_service.py

@@ -7,7 +7,8 @@ from minio import Minio
 from minio.error import S3Error
 from datetime import timedelta
 import os
-from typing import Optional, Tuple
+from typing import Optional
+from app.utils.logger_utils import logger
 
 
 class ImgMinioService:
@@ -58,10 +59,10 @@ class ImgMinioService:
             return object_name
 
         except S3Error as e:
-            print(f"上传图片错误: {e}")
+            logger.error(f"上传图片错误: {e}", exc_info=True)
             raise e
         except Exception as e:
-            print(f"上传文件时发生错误: {e}")
+            logger.error(f"上传文件时发生错误: {e}", exc_info=True)
             raise e
 
     def upload_image_data(self, image_data: bytes, object_name: str,
@@ -93,7 +94,7 @@ class ImgMinioService:
             return object_name
 
         except S3Error as e:
-            print(f"上传图片数据错误: {e}")
+            logger.error(f"上传图片数据错误: {e}", exc_info=True)
             raise e
 
 
@@ -113,7 +114,7 @@ class ImgMinioService:
             try:
                 self.client.stat_object(self.bucket, old_path)
             except S3Error as e:
-                print(f"原文件不存在: {e}")
+                logger.warning(f"原文件不存在: {e}")
                 return False
 
             # 创建 CopySource 对象
@@ -132,12 +133,12 @@ class ImgMinioService:
             return True
 
         except S3Error as e:
-            print(f"重命名图片错误: {e}")
+            logger.error(f"重命名图片错误: {e}", exc_info=True)
             # 如果复制成功但删除失败,尝试回滚
             try:
                 self.client.remove_object(self.bucket, new_path)
-            except:
-                pass
+            except Exception as rollback_error:
+                logger.error(f"回滚失败: {rollback_error}", exc_info=True)
             return False
 
     def delete_file(self, path: str, is_dir: bool = False):
@@ -160,7 +161,7 @@ class ImgMinioService:
                 expires=timedelta(hours=expires_hours)
             )
         except S3Error as e:
-            print(f"生成预签名URL错误: {e}")
+            logger.error(f"生成预签名URL错误: {e}", exc_info=True)
             raise e
 
     def get_storage_info(self):
@@ -201,7 +202,7 @@ class ImgMinioService:
             objects = self.client.list_objects(self.bucket, prefix=prefix, recursive=True)
             return [obj.object_name for obj in objects]
         except S3Error as e:
-            print(f"列出图片错误: {e}")
+            logger.error(f"列出图片错误: {e}", exc_info=True)
             raise e
 
     def image_exists(self, path: str) -> bool:

+ 27 - 62
app/services/minio_service.py

@@ -1,26 +1,21 @@
 from minio import Minio
 from minio.error import S3Error
-from app.core.config import settings
-import io
 from datetime import timedelta
-
+from app.providers.minio_client_provider import minio_client_provider
+from app.utils.logger_utils import logger
 
 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()
+        """初始化服务,不再创建固定的客户端"""
+        pass
 
-    def _ensure_bucket_exists(self):
-        if not self.client.bucket_exists(self.bucket):
-            self.client.make_bucket(self.bucket)
+    def _ensure_bucket_exists(self, client: Minio, bucket_name: str):
+        """确保桶存在"""
+        if not client.bucket_exists(bucket_name):
+            client.make_bucket(bucket_name)
 
-    def list_files(self, prefix: str = ""):
+    def list_files(self, prefix: str):
+        """列出文件"""
         # 清理前缀
         if prefix == "/":
             prefix = ""
@@ -31,7 +26,7 @@ class MinioService:
             prefix += "/"
 
         try:
-            objects = self.client.list_objects(self.bucket, prefix=prefix, recursive=False)
+            objects = minio_client_provider.list_objects(prefix=prefix, recursive=False)
 
             results = []
             for obj in objects:
@@ -66,73 +61,43 @@ class MinioService:
             return results
 
         except S3Error as e:
-            print(f"列出文件错误: {e}")
+            logger.error(f"列出文件错误: {e}", exc_info=True)
             raise e
 
     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,
+        """上传文件"""
+        minio_client_provider.put_object(
+            path=file_path,
+            data=file_obj,
             length=file_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
-        )
+        minio_client_provider.put_empty_object(folder_path)
 
     def get_file_stream(self, file_path: str):
-        return self.client.get_object(self.bucket, file_path)
+        """获取文件流"""
+        return minio_client_provider.get_object(file_path)
 
-    def delete_file(self, path: str, is_dir: bool = False):
+    def delete_file(self, path: str, is_dir: bool):
+        """删除文件或文件夹"""
         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)
+            minio_client_provider.remove_objects_with_prefix(prefix=path)
         else:
-            self.client.remove_object(self.bucket, path)
+            minio_client_provider.remove_object(path)
 
     def get_presigned_url(self, path: str):
-        return self.client.get_presigned_url(
-            "GET",
-            self.bucket,
-            path,
-            expires=timedelta(hours=1)
-        )
+        """获取预签名URL"""
+        return minio_client_provider.get_presigned_url(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
+        return minio_client_provider.get_storage_info()
 
 
 # 实例化单例

+ 57 - 0
app/utils/auth_utils.py

@@ -0,0 +1,57 @@
+import json
+from fastapi import Header
+from typing import Optional
+
+from app.core.security import ServerEnum
+from app.utils.jwt_utils import verify_jwt_token
+from app.models.user import User
+from app.core.exceptions import (
+    UnauthorizedException,
+    ValidationException,
+    StatusCode,
+    ErrorMessage
+)
+
+
+def USER_KEY(user_id: str, server: str) -> str:
+    """生成用户缓存key"""
+    return f"user:{user_id}:{server}"
+
+
+class AuthService:
+    """认证服务"""
+
+    @staticmethod
+    async def verify_and_get_user(
+            authorization: Optional[str] = Header(None),
+            source: Optional[str] = Header(None)
+    ) -> User:
+        """验证用户token并返回用户信息"""
+
+        # 检查请求来源
+        if not source or not ServerEnum.exist(source):
+            raise ValidationException(detail=ErrorMessage.INVALID_HEADER)
+
+        # 检查token是否存在
+        if not authorization:
+            raise UnauthorizedException(detail=ErrorMessage.UNAUTHORIZED)
+        
+        token = authorization.replace("Bearer ", "")
+        # 验证JWT token
+        claims = verify_jwt_token(token)
+        if not claims:
+            raise UnauthorizedException(
+                detail=ErrorMessage.INVALID_TOKEN,
+                code=StatusCode.INVALID_TOKEN
+            )
+        user_id = claims.get("sub")
+        if not user_id:
+            raise UnauthorizedException(
+                detail=ErrorMessage.INVALID_TOKEN,
+                code=StatusCode.INVALID_TOKEN
+            )
+        return User(id=user_id, token=token)
+
+
+# 创建全局实例
+auth_service = AuthService()

+ 132 - 0
app/utils/jwt_utils.py

@@ -0,0 +1,132 @@
+from __future__ import annotations
+import jwt
+from jwt import PyJWTError
+from app.core.config import settings
+
+def _parse_base64_binary(text: str) -> bytes:
+    """
+    Base64 解码实现
+    """
+    decode_map = _init_decode_map()
+    PADDING = 127
+
+    # 长度估算逻辑
+    buflen = _guess_length(text, decode_map, PADDING)
+    out = bytearray(buflen)
+    o = 0
+    length = len(text)
+    quadruplet = [0] * 4
+    q = 0
+
+    for i in range(length):
+        ch = text[i]
+        char_code = ord(ch)
+
+        # 只检查 ASCII 范围内的字符
+        if char_code < 128:
+            v = decode_map[char_code]
+            # 只有当 v != -1 时才加入 quadruplet
+            if v != -1:
+                quadruplet[q] = v
+                q += 1
+
+        # 当 q == 4 时进行解码
+        if q == 4:
+            # 第一个字节:quadruplet[0] << 2 | quadruplet[1] >> 4
+            byte_val = (quadruplet[0] << 2) | (quadruplet[1] >> 4)
+            out[o] = byte_val & 0xFF  # 确保在 0-255 范围内
+            o += 1
+
+            # 第二个字节:只有 quadruplet[2] 不是填充时才计算
+            if quadruplet[2] != PADDING:
+                byte_val = (quadruplet[1] << 4) | (quadruplet[2] >> 2)
+                out[o] = byte_val & 0xFF  # 确保在 0-255 范围内
+                o += 1
+
+            # 第三个字节:只有 quadruplet[3] 不是填充时才计算
+            if quadruplet[3] != PADDING:
+                byte_val = (quadruplet[2] << 6) | quadruplet[3]
+                out[o] = byte_val & 0xFF  # 确保在 0-255 范围内
+                o += 1
+
+            q = 0
+
+    # 返回正确长度的字节数组
+    if buflen == o:
+        return bytes(out)
+    else:
+        # 如果长度不匹配,创建新数组并复制
+        nb = bytearray(o)
+        nb[:] = out[:o]
+        return bytes(nb)
+
+
+def _guess_length(text: str, decode_map: list, padding: int) -> int:
+    """
+    长度估算逻辑
+    """
+    length = len(text)
+
+    # 从末尾开始找到第一个非填充字符
+    j = length - 1
+    while j >= 0:
+        char_code = ord(text[j])
+        # 只检查 ASCII 字符
+        if char_code < 128:
+            code = decode_map[char_code]
+            if code != padding:
+                if code == -1:
+                    # 包含无效字符,使用标准估算
+                    return length // 4 * 3
+                break
+        j -= 1
+
+    j += 1
+    padding_count = length - j
+
+    # 计算输出长度
+    if padding_count > 2:
+        return length // 4 * 3
+    else:
+        return length // 4 * 3 - padding_count
+
+
+def _init_decode_map() -> list:
+    """
+    解码映射表
+    """
+    decode_map = [-1] * 128
+
+    # A-Z: 0-25
+    for i in range(65, 91):  # 'A' to 'Z'
+        decode_map[i] = i - 65
+
+    # a-z: 26-51
+    for i in range(97, 123):  # 'a' to 'z'
+        decode_map[i] = i - 97 + 26
+
+    # 0-9: 52-61
+    for i in range(48, 58):  # '0' to '9'
+        decode_map[i] = i - 48 + 52
+
+    # '+' -> 62, '/' -> 63, '=' -> 127
+    decode_map[43] = 62  # '+'
+    decode_map[47] = 63  # '/'
+    decode_map[61] = 127  # '=' (PADDING)
+
+    return decode_map
+
+
+def verify_jwt_token(token: str) -> dict | None:
+    """
+        验证JWT token并返回payload
+    """
+    try:
+        payload = jwt.decode(
+            token,
+            _parse_base64_binary(settings.JWT_SECRET_KEY),
+            algorithms=[settings.JWT_ALGORITHM]
+        )
+        return payload
+    except PyJWTError:
+        return None

+ 47 - 0
app/utils/logger_utils.py

@@ -0,0 +1,47 @@
+"""
+日志工具模块
+"""
+import logging
+import sys
+from typing import Optional
+
+# 配置日志格式
+LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
+
+
+def setup_logger(name: str = "minio_manager", level: int = logging.INFO) -> logging.Logger:
+    """
+    设置并返回日志记录器
+    
+    Args:
+        name: 日志记录器名称
+        level: 日志级别
+    
+    Returns:
+        配置好的日志记录器
+    """
+    logger = logging.getLogger(name)
+    logger.setLevel(level)
+    
+    # 避免重复添加处理器
+    if logger.handlers:
+        return logger
+    
+    # 创建控制台处理器
+    console_handler = logging.StreamHandler(sys.stdout)
+    console_handler.setLevel(level)
+    
+    # 创建格式器
+    formatter = logging.Formatter(LOG_FORMAT, DATE_FORMAT)
+    console_handler.setFormatter(formatter)
+    
+    # 添加处理器
+    logger.addHandler(console_handler)
+    
+    return logger
+
+
+# 创建默认日志记录器
+logger = setup_logger()
+

+ 73 - 0
app/utils/path_utils.py

@@ -0,0 +1,73 @@
+"""
+路径验证工具
+"""
+import re
+from fastapi import HTTPException
+
+
+def validate_path(path: str, allow_root: bool = False) -> str:
+    """
+    验证和清理文件路径,防止路径遍历攻击
+    
+    Args:
+        path: 要验证的路径
+        allow_root: 是否允许根路径 "/"
+    
+    Returns:
+        清理后的路径
+    
+    Raises:
+        HTTPException: 如果路径无效
+    """
+    if not path:
+        raise HTTPException(status_code=400, detail="路径不能为空")
+    
+    # 移除开头的斜杠(MinIO 规范)
+    if path.startswith('/'):
+        path = path[1:]
+    
+    # 检查路径遍历攻击
+    if '..' in path or path.startswith('/') or '//' in path:
+        raise HTTPException(status_code=400, detail="无效的路径格式,不允许路径遍历")
+    
+    # 检查危险字符
+    dangerous_chars = ['<', '>', '|', '\0', '\r', '\n']
+    for char in dangerous_chars:
+        if char in path:
+            raise HTTPException(status_code=400, detail=f"路径包含非法字符: {char}")
+    
+    # 检查路径长度
+    if len(path) > 1024:
+        raise HTTPException(status_code=400, detail="路径长度超过限制")
+    
+    return path
+
+
+def sanitize_filename(filename: str) -> str:
+    """
+    清理文件名,移除危险字符
+    
+    Args:
+        filename: 原始文件名
+    
+    Returns:
+        清理后的文件名
+    """
+    if not filename:
+        raise HTTPException(status_code=400, detail="文件名不能为空")
+    
+    # 移除路径分隔符
+    filename = filename.replace('/', '').replace('\\', '')
+    
+    # 移除危险字符
+    dangerous_chars = ['<', '>', '|', ':', '"', '?', '*', '\0']
+    for char in dangerous_chars:
+        filename = filename.replace(char, '_')
+    
+    # 限制长度
+    if len(filename) > 255:
+        name, ext = filename.rsplit('.', 1) if '.' in filename else (filename, '')
+        filename = name[:250] + ('.' + ext if ext else '')
+    
+    return filename
+

+ 155 - 0
app/utils/request_utils.py

@@ -0,0 +1,155 @@
+import requests
+import time
+from typing import Optional
+from app.core.config import settings
+from app.core.exceptions import (
+    UnauthorizedException,
+    ForbiddenException,
+    BusinessException,
+    InternalServerException,
+    ErrorMessage,
+    StatusCode
+)
+
+class request_service:
+    def __init__(self):
+        """初始化请求服务"""
+        self.base_url = settings.JAVA_API_BASE_URL
+        self.session = requests.Session()
+        self.session.timeout = 80  # 80秒超时
+        self.max_retries = 3  # 最大重试次数
+        self.retry_delay = 1  # 重试延迟(秒)
+
+    def request(self, method: str, url: str, auth: bool, authorization: Optional[str] = None, 
+                source: Optional[str] = None, version: Optional[str] = None, **kwargs) -> any:
+        """
+        发送请求的核心方法
+
+        参数:
+            method: HTTP方法 (GET/POST等)
+            url: 请求路径
+            auth: 是否需要认证
+            authorization: Authorization token
+            source: Source 头部,默认为 'customer'
+            version: Version 头部,默认为 'V6'
+            **kwargs: 其他请求参数
+
+        返回:
+            响应数据或文件对象
+
+        异常:
+            抛出请求或业务逻辑相关的异常
+        """
+        # 1. 准备请求头
+        headers = self._prepare_headers(auth, authorization, source, version)
+
+        # 2. 处理GET请求的数组参数
+        if method.upper() == 'GET' and 'params' in kwargs:
+            kwargs['params'] = self._process_array_params(kwargs['params'])
+
+        # 3. 设置请求头
+        kwargs['headers'] = headers
+
+        # 4. 发送请求(带重试机制)
+        response = self._send_request_with_retry(method, url, **kwargs)
+
+        # 5. 处理响应
+        return self._handle_response(response)
+
+    def _prepare_headers(self, auth: bool, authorization: Optional[str] = None,
+                         source: Optional[str] = None, version: Optional[str] = None) -> 'dict[str, str]':
+        """准备请求头"""
+        headers = {
+            'Source': source or 'customer',
+            'Content-Type': 'application/json',
+            'Version': version or 'V6'
+        }
+        if auth and authorization:
+            headers['Authorization'] = authorization
+        return headers
+
+    def _process_array_params(self, params: 'dict[str, any]') -> 'dict[str, any]':
+        """处理GET请求中的数组参数"""
+        processed = {}
+        for key, value in params.items():
+            if isinstance(value, list):
+                processed[key] = ','.join(map(str, value))
+            else:
+                processed[key] = value
+        return processed
+
+    def _handle_response(self, response: requests.Response) -> any:
+        """处理响应"""
+        # 处理文件类型的响应
+        if response.headers.get('requesttype') == 'file':
+            return response
+        try:
+            res = response.json()
+        except ValueError:
+            raise InternalServerException(detail=ErrorMessage.INVALID_RESPONSE)
+        if not res:
+            return None
+        # 处理业务逻辑错误
+        if res.get('code') != 200:
+            self._handle_business_error(res)
+        return res.get('data')
+
+    def _handle_business_error(self, response_data: 'dict[str, any]'):
+        """处理业务逻辑错误,直接使用Java服务返回的code和message"""
+        error_code = response_data.get('code')
+        error_msg = response_data.get('message', '未知系统错误')
+        
+        # 可接受的错误码,直接返回(不抛出异常)
+        if error_code in (-1, 0):
+            return
+        
+        # 根据错误码映射HTTP状态码,但保留原始的code和message
+        # 600, 601 -> 401 (未授权)
+        if error_code in (600, 601):
+            raise UnauthorizedException(
+                detail=error_msg,
+                code=error_code
+            )
+        # 403或权限相关 -> 403 (禁止访问)
+        elif error_code == 403 or "无该货主权限" in error_msg or "无权限" in error_msg or "没有权限" in error_msg:
+            raise ForbiddenException(
+                detail=error_msg,
+                code=error_code if error_code else StatusCode.FORBIDDEN
+            )
+        else:
+            # 其他所有错误(包括1001等),直接使用原始code和message
+            # HTTP状态码使用400,但响应体中的code保留原始值
+            raise BusinessException(
+                detail=error_msg,
+                status_code=400,
+                code=error_code
+            )
+
+    def _send_request_with_retry(self, method: str, url: str, **kwargs) -> requests.Response:
+        """带重试机制的请求发送,打印完整请求数据"""
+        last_exception = None
+        full_url = f"{self.base_url}{url}"
+
+        for attempt in range(self.max_retries):
+            try:
+                # 获取headers并确保它是字典
+                headers = kwargs.get('headers', {})
+                if headers is None:
+                    headers = {}
+                safe_headers = dict(headers)  # 更安全的方式创建副本
+                if 'Authorization' in safe_headers:
+                    safe_headers['Authorization'] = 'Bearer ******'
+
+                # 发送请求
+                response = self.session.request(method, full_url, **kwargs)
+                return response
+
+            except (requests.exceptions.RequestException,
+                    requests.exceptions.Timeout) as e:
+                last_exception = e
+                if attempt < self.max_retries - 1:
+                    time.sleep(self.retry_delay)
+
+        raise InternalServerException(
+            detail=str(last_exception) if last_exception else ErrorMessage.REQUEST_FAILED
+        )

+ 2 - 0
requirements.txt

@@ -5,3 +5,5 @@ python-multipart==0.0.6
 python-dotenv==1.0.1
 pydantic==2.6.1
 pydantic-settings==2.1.0
+cachetools==5.3.2
+requests==2.31.0