Files
system-backu/backend/app/core/storage/local.py
T
publ 4e3a4b4602 feat: 数据备份管理系统 v0.1.0
带 Web 操作界面的备份系统,支持:
- 数据源:MySQL 数据库(mysqldump)、服务器目录(tar.gz)
- 存储目标:本地目录、S3 兼容对象存储(MinIO/Ceph/AWS S3)
- 触发方式:手动 + Cron 定时调度(APScheduler)
- 备份还原:MySQL 库、目录
- JWT 登录认证 + Fernet 字段加密
- 保留策略:按数量 + 按天数双重清理

技术栈:FastAPI + SQLAlchemy 2 + APScheduler + aioboto3;
前端 Vite + React 18 + TypeScript + Ant Design 5 + Zustand。
Docker Compose 一键起。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 18:27:41 +08:00

99 lines
3.1 KiB
Python

"""本地文件系统存储。"""
import asyncio
import hashlib
import os
import shutil
from datetime import datetime
from pathlib import Path
from typing import AsyncIterator
import aiofiles
from app.core.storage.base import BaseStorage, StorageObject
from app.utils.logging import get_logger
log = get_logger(__name__)
class LocalStorage(BaseStorage):
type = "local"
def __init__(self, config: dict):
super().__init__(config)
self.root: str = os.path.abspath(config["path"])
Path(self.root).mkdir(parents=True, exist_ok=True)
def _resolve(self, key: str) -> Path:
# 防 path traversal:禁止 ..
if ".." in key.split("/"):
raise ValueError(f"Invalid key (path traversal): {key}")
return Path(self.root) / key
async def test_connection(self) -> tuple[bool, str]:
try:
Path(self.root).mkdir(parents=True, exist_ok=True)
test_file = Path(self.root) / ".backup_system_test"
async with aiofiles.open(test_file, "w") as f:
await f.write("ok")
# aiofiles 不暴露 os 命名空间,用线程池跑同步删除
await asyncio.to_thread(os.remove, test_file)
return True, f"OK: writable {self.root}"
except Exception as e:
return False, f"FAIL: {e}"
async def upload_stream(
self,
source: AsyncIterator[bytes],
key: str,
content_type: str = "application/octet-stream",
) -> tuple[int, str]:
path = self._resolve(key)
path.parent.mkdir(parents=True, exist_ok=True)
sha = hashlib.sha256()
size = 0
async with aiofiles.open(path, "wb") as f:
async for chunk in source:
await f.write(chunk)
sha.update(chunk)
size += len(chunk)
return size, sha.hexdigest()
async def download_stream(self, key: str) -> AsyncIterator[bytes]:
path = self._resolve(key)
if not path.exists():
raise FileNotFoundError(f"Not found: {key}")
async with aiofiles.open(path, "rb") as f:
while True:
chunk = await f.read(64 * 1024)
if not chunk:
break
yield chunk
async def delete(self, key: str) -> None:
path = self._resolve(key)
try:
path.unlink()
except FileNotFoundError:
pass
async def exists(self, key: str) -> bool:
return self._resolve(key).exists()
async def list(self, prefix: str = "") -> list[StorageObject]:
base = Path(self.root) / prefix if prefix else Path(self.root)
if not base.exists():
return []
out: list[StorageObject] = []
for p in base.rglob("*"):
if p.is_file():
rel = p.relative_to(Path(self.root)).as_posix()
st = p.stat()
out.append(
StorageObject(
key=rel,
size=st.st_size,
last_modified=datetime.fromtimestamp(st.st_mtime).isoformat(),
)
)
return out