diff --git a/api/app/services/atp_asset_service.py b/api/app/services/atp_asset_service.py index 2bcf476..7a763e5 100644 --- a/api/app/services/atp_asset_service.py +++ b/api/app/services/atp_asset_service.py @@ -286,6 +286,7 @@ def _write_archive_to_storage( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Release ZIP 包不能为空") driver.ensure_directory(storage_root_path) + ensured_directories = {normalize_virtual_path(storage_root_path)} extracted_count = 0 try: with zipfile.ZipFile(io.BytesIO(archive_content)) as archive: @@ -296,7 +297,10 @@ def _write_archive_to_storage( if relative_path is None: continue target_path = normalize_virtual_path(f"{storage_root_path.rstrip('/')}/{relative_path}") - driver.ensure_directory(_parent_virtual_path(target_path)) + parent_path = _parent_virtual_path(target_path) + if parent_path not in ensured_directories: + driver.ensure_directory(parent_path) + ensured_directories.add(parent_path) try: content = archive.read(member) except Exception as exc: diff --git a/api/app/services/storage_driver.py b/api/app/services/storage_driver.py index 3397b6f..13b97be 100644 --- a/api/app/services/storage_driver.py +++ b/api/app/services/storage_driver.py @@ -302,6 +302,7 @@ class S3StorageDriver: def __init__(self, *, config: dict[str, Any], mount_root_path: str) -> None: try: import boto3 + from botocore.config import Config except ImportError as exc: raise StorageNotConfiguredError("S3 driver requires boto3 dependency") from exc @@ -309,6 +310,15 @@ class S3StorageDriver: if not bucket: raise StorageNotConfiguredError("S3 backend requires config.bucket") + client_config = Config( + connect_timeout=_coerce_positive_number(config.get("connect_timeout_seconds"), default=3.0), + read_timeout=_coerce_positive_number(config.get("read_timeout_seconds"), default=10.0), + retries={"max_attempts": int(_coerce_positive_number(config.get("max_attempts"), default=2.0))}, + s3={"addressing_style": _coerce_non_empty_string(config.get("addressing_style")) or "path"}, + request_checksum_calculation="when_required", + response_checksum_validation="when_required", + ) + session = boto3.session.Session( aws_access_key_id=_coerce_non_empty_string(config.get("access_key_id")), aws_secret_access_key=_coerce_non_empty_string(config.get("secret_access_key")), @@ -319,9 +329,11 @@ class S3StorageDriver: "s3", endpoint_url=_coerce_non_empty_string(config.get("endpoint_url")), region_name=_coerce_non_empty_string(config.get("region_name")), + config=client_config, ) self._bucket = bucket self._root_prefix = _normalize_s3_prefix(mount_root_path) + self._should_write_directory_markers = bool(config.get("write_directory_markers", False)) def list_dir(self, path: str) -> list[StorageObject]: normalized = normalize_virtual_path(path) @@ -402,6 +414,12 @@ class S3StorageDriver: if normalized == "/": return + # S3-compatible object stores do not require directory marker objects + # before nested keys are written. The marker PUT is optional and is + # expensive on high-file-count uploads. + if not self._should_write_directory_markers: + return + key = self._key_for_path(normalized) if key and not key.endswith("/"): key = f"{key}/" @@ -711,6 +729,21 @@ def _coerce_non_empty_string(value: Any) -> str | None: return stripped if stripped else None +def _coerce_positive_number(value: Any, *, default: float) -> float: + if isinstance(value, bool): + return default + if isinstance(value, (int, float)): + number = float(value) + elif isinstance(value, str): + try: + number = float(value.strip()) + except ValueError: + return default + else: + return default + return number if number > 0 else default + + def _is_s3_not_found(exc: Exception) -> bool: response = getattr(exc, "response", None) if not isinstance(response, dict):