""" 对象存储:S3 兼容客户端壳(boto3)。 本文件职责:组装 client、put_bytes / get_bytes;缺依赖或未配齐时明确失败。 禁止:在本包写工单状态;正式登记附件元数据走主账。 """ from __future__ import annotations import logging from dataclasses import dataclass from typing import Optional from agent.config import Settings, get_settings logger = logging.getLogger(__name__) @dataclass class S3ObjectStorage: """ 基于 boto3 的 S3/MinIO 壳。 allow_network=False(默认):不真正 PUT,只校验配置与 SDK 可导入。 """ settings: Settings allow_network: bool = False @classmethod def from_settings( cls, settings: Optional[Settings] = None, *, allow_network: bool = False, ) -> "S3ObjectStorage": return cls(settings=settings or get_settings(), allow_network=allow_network) def _cfg(self) -> tuple[str, str, str, str, bool]: s = self.settings return ( (s.object_storage_endpoint_url or "").rstrip("/"), s.object_storage_bucket or "", s.object_storage_access_key or "", s.object_storage_secret_key or "", bool(s.object_storage_path_style), ) def configured(self) -> bool: endpoint, bucket, *_rest = self._cfg() return bool(endpoint and bucket) def sdk_available(self) -> bool: try: import boto3 # noqa: F401 return True except ImportError: return False def _client(self): """构造 boto3 client;未配置抛 RuntimeError。""" import boto3 from botocore.client import Config endpoint, bucket, ak, sk, path_style = self._cfg() if not endpoint or not bucket or not ak or not sk: raise RuntimeError("object_storage_not_configured") return boto3.client( "s3", endpoint_url=endpoint, aws_access_key_id=ak, aws_secret_access_key=sk, config=Config(s3={"addressing_style": "path" if path_style else "auto"}), ) def put_bytes(self, *, key: str, data: bytes, content_type: str = "") -> dict: """ 上传字节。 allow_network=False:返回 dry_run,不访问网络。 """ endpoint, bucket, ak, sk, _ps = self._cfg() if not endpoint or not bucket: return {"ok": False, "error": "not_configured"} if not self.sdk_available(): return {"ok": False, "error": "boto3_not_installed"} if not self.allow_network: logger.info( "storage.s3.put dry_run bucket=%s key=%s bytes=%s", bucket, key, len(data), ) return {"ok": True, "dry_run": True, "bucket": bucket, "key": key} extra = {} if content_type: extra["ContentType"] = content_type self._client().put_object(Bucket=bucket, Key=key, Body=data, **extra) return {"ok": True, "dry_run": False, "bucket": bucket, "key": key} def get_bytes(self, *, key: str) -> dict: """下载壳;默认 dry_run 不拉网。""" endpoint, bucket, *_r = self._cfg() if not endpoint or not bucket: return {"ok": False, "error": "not_configured"} if not self.allow_network: return {"ok": True, "dry_run": True, "key": key, "data": b""} if not self.sdk_available(): return {"ok": False, "error": "boto3_not_installed"} obj = self._client().get_object(Bucket=bucket, Key=key) body = obj["Body"].read() return {"ok": True, "dry_run": False, "key": key, "data": body}