Files
yawan-workbench/server/app/storage.py
T

142 lines
4.6 KiB
Python
Raw Normal View History

"""对象存储适配器(交付报告 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"]