142 lines
4.6 KiB
Python
142 lines
4.6 KiB
Python
"""对象存储适配器(交付报告 P0-2)。
|
||||
|
|
|
|||
|
|
设计:统一接口 save/read/delete/exists,两个实现:
|
|||
|
|
- local(默认):磁盘目录,行为与现状一致;
|
|||
|
|
- s3(可选):通过环境变量启用(YAWEN_STORAGE_BACKEND=s3 + endpoint/key/secret/bucket),
|
|||
|
|
依赖 boto3(未安装时导入报错并回退 local,错误信息可读)。
|
|||
|
|
|
|||
|
|
产物路径统一用 "local://相对路径" / "s3://bucket/key" 形式存库,
|
|||
|
|
读取时由 resolve_path 还原为可读的绝对路径或 s3 引用。
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import os
|
|||
|
|
import shutil
|
|||
|
|
from pathlib import Path
|
|||
|
|
from urllib.parse import urlparse
|
|||
|
|
|
|||
|
|
from .config import DATA_DIR
|
|||
|
|
|
|||
|
|
|
|||
|
|
class StorageBackend:
|
|||
|
|
def save(self, key: str, data: bytes) -> str: ...
|
|||
|
|
def read(self, key: str) -> bytes: ...
|
|||
|
|
def delete(self, key: str) -> None: ...
|
|||
|
|
def exists(self, key: str) -> bool: ...
|
|||
|
|
|
|||
|
|
|
|||
|
|
class LocalStorage(StorageBackend):
|
|||
|
|
"""磁盘实现:key 是相对 DATA_DIR/storage 的路径。"""
|
|||
|
|
|
|||
|
|
def __init__(self, root: Path | None = None):
|
|||
|
|
self.root = Path(root) if root else DATA_DIR / "storage"
|
|||
|
|
self.root.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
def _path(self, key: str) -> Path:
|
|||
|
|
clean = key.lstrip("/").replace("..", "_")
|
|||
|
|
return self.root / clean
|
|||
|
|
|
|||
|
|
def save(self, key: str, data: bytes) -> str:
|
|||
|
|
path = self._path(key)
|
|||
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|||
|
|
tmp.write_bytes(data)
|
|||
|
|
tmp.replace(path)
|
|||
|
|
# 引用里不回传原始 key 的 ..,写库引用使用净化后的路径
|
|||
|
|
clean_key = str(path.relative_to(self.root))
|
|||
|
|
return f"local://{clean_key}"
|
|||
|
|
|
|||
|
|
def read(self, key: str) -> bytes:
|
|||
|
|
return self._path(key).read_bytes()
|
|||
|
|
|
|||
|
|
def delete(self, key: str) -> None:
|
|||
|
|
path = self._path(key)
|
|||
|
|
if path.exists():
|
|||
|
|
path.unlink()
|
|||
|
|
|
|||
|
|
def exists(self, key: str) -> bool:
|
|||
|
|
return self._path(key).exists()
|
|||
|
|
|
|||
|
|
def read_text(self, key: str) -> str:
|
|||
|
|
return self.read(key).decode("utf-8", errors="ignore")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class S3Storage(StorageBackend):
|
|||
|
|
"""S3/MinIO/COS 实现:懒加载 boto3,未配置时抛可读错误。"""
|
|||
|
|
|
|||
|
|
def __init__(self):
|
|||
|
|
try:
|
|||
|
|
import boto3 # noqa: F401
|
|||
|
|
except ImportError as exc:
|
|||
|
|
raise RuntimeError(
|
|||
|
|
"S3 存储后端需要 boto3:请在 server 环境执行 pip install boto3") from exc
|
|||
|
|
self.bucket = os.environ.get("YAWEN_S3_BUCKET", "")
|
|||
|
|
if not self.bucket:
|
|||
|
|
raise RuntimeError("S3 存储后端需要环境变量 YAWEN_S3_BUCKET")
|
|||
|
|
self.client = boto3.client(
|
|||
|
|
"s3",
|
|||
|
|
endpoint_url=os.environ.get("YAWEN_S3_ENDPOINT") or None,
|
|||
|
|
aws_access_key_id=os.environ.get("YAWEN_S3_ACCESS_KEY") or None,
|
|||
|
|
aws_secret_access_key=os.environ.get("YAWEN_S3_SECRET_KEY") or None,
|
|||
|
|
region_name=os.environ.get("YAWEN_S3_REGION") or None,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def save(self, key: str, data: bytes) -> str:
|
|||
|
|
self.client.put_object(Bucket=self.bucket, Key=key, Body=data)
|
|||
|
|
return f"s3://{self.bucket}/{key}"
|
|||
|
|
|
|||
|
|
def read(self, key: str) -> bytes:
|
|||
|
|
return self.client.get_object(Bucket=self.bucket, Key=key)["Body"].read()
|
|||
|
|
|
|||
|
|
def delete(self, key: str) -> None:
|
|||
|
|
self.client.delete_object(Bucket=self.bucket, Key=key)
|
|||
|
|
|
|||
|
|
def exists(self, key: str) -> bool:
|
|||
|
|
try:
|
|||
|
|
self.client.head_object(Bucket=self.bucket, Key=key)
|
|||
|
|
return True
|
|||
|
|
except Exception:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
_backend: StorageBackend | None = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_storage() -> StorageBackend:
|
|||
|
|
global _backend
|
|||
|
|
if _backend is None:
|
|||
|
|
backend_name = os.environ.get("YAWEN_STORAGE_BACKEND", "local").lower()
|
|||
|
|
if backend_name == "s3":
|
|||
|
|
_backend = S3Storage()
|
|||
|
|
else:
|
|||
|
|
_backend = LocalStorage()
|
|||
|
|
return _backend
|
|||
|
|
|
|||
|
|
|
|||
|
|
def reset_storage() -> None:
|
|||
|
|
"""测试用:清空缓存的 backend 单例。"""
|
|||
|
|
global _backend
|
|||
|
|
_backend = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse_ref(ref: str) -> tuple[str, str]:
|
|||
|
|
"""'s3://bucket/key' → ('s3', 'bucket/key');'local://a/b' → ('local', 'a/b');
|
|||
|
|
裸路径视为 local。"""
|
|||
|
|
ref = str(ref or "")
|
|||
|
|
if "://" in ref:
|
|||
|
|
scheme, rest = ref.split("://", 1)
|
|||
|
|
return scheme, rest
|
|||
|
|
return "local", ref
|
|||
|
|
|
|||
|
|
|
|||
|
|
def resolve_display_path(ref: str) -> str:
|
|||
|
|
"""把存库引用转成可展示/下载用的路径。"""
|
|||
|
|
scheme, key = parse_ref(ref)
|
|||
|
|
if scheme == "s3":
|
|||
|
|
return ref
|
|||
|
|
return str(DATA_DIR / "storage" / key.lstrip("/").replace("..", "_"))
|
|||
|
|
|
|||
|
|
|
|||
|
|
__all__ = ["StorageBackend", "LocalStorage", "S3Storage", "get_storage",
|
|||
|
|
"reset_storage", "parse_ref", "resolve_display_path"]
|