feat: enhance backup functionality with section selection and import mode options

This commit is contained in:
shiyu
2026-01-18 21:01:59 +08:00
parent 45e0194465
commit c441d8776f
7 changed files with 333 additions and 107 deletions
+11 -5
View File
@@ -1,6 +1,6 @@
import datetime
from fastapi import APIRouter, Depends, File, Request, UploadFile
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile
from fastapi.responses import JSONResponse
from domain.audit import AuditAction, audit
@@ -16,8 +16,10 @@ router = APIRouter(
@router.get("/export", summary="导出全站数据")
@audit(action=AuditAction.DOWNLOAD, description="导出备份")
async def export_backup(request: Request):
data = await BackupService.export_data()
async def export_backup(
request: Request, sections: list[str] | None = Query(default=None)
):
data = await BackupService.export_data(sections=sections)
timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
headers = {"Content-Disposition": f"attachment; filename=foxel_backup_{timestamp}.json"}
return JSONResponse(content=data.model_dump(), headers=headers)
@@ -25,6 +27,10 @@ async def export_backup(request: Request):
@router.post("/import", summary="导入数据")
@audit(action=AuditAction.UPLOAD, description="导入备份")
async def import_backup(request: Request, file: UploadFile = File(...)):
await BackupService.import_from_bytes(file.filename, await file.read())
async def import_backup(
request: Request,
file: UploadFile = File(...),
mode: str = Form("replace"),
):
await BackupService.import_from_bytes(file.filename, await file.read(), mode=mode)
return {"message": "数据导入成功。"}
+228 -92
View File
@@ -20,18 +20,64 @@ from models.database import (
class BackupService:
ALL_SECTIONS = (
"storage_adapters",
"user_accounts",
"automation_tasks",
"share_links",
"configurations",
"ai_providers",
"ai_models",
"ai_default_models",
"plugins",
)
@classmethod
async def export_data(cls) -> BackupData:
async def export_data(cls, sections: list[str] | None = None) -> BackupData:
sections = cls._normalize_sections(sections)
section_set = set(sections)
async with in_transaction():
adapters = await StorageAdapter.all().values()
users = await UserAccount.all().values()
tasks = await AutomationTask.all().values()
shares = await ShareLink.all().values()
configs = await Configuration.all().values()
providers = await AIProvider.all().values()
models = await AIModel.all().values()
default_models = await AIDefaultModel.all().values()
plugins = await Plugin.all().values()
adapters = (
await StorageAdapter.all().values()
if "storage_adapters" in section_set
else []
)
users = (
await UserAccount.all().values()
if "user_accounts" in section_set
else []
)
tasks = (
await AutomationTask.all().values()
if "automation_tasks" in section_set
else []
)
shares = (
await ShareLink.all().values()
if "share_links" in section_set
else []
)
configs = (
await Configuration.all().values()
if "configurations" in section_set
else []
)
providers = (
await AIProvider.all().values()
if "ai_providers" in section_set
else []
)
models = (
await AIModel.all().values() if "ai_models" in section_set else []
)
default_models = (
await AIDefaultModel.all().values()
if "ai_default_models" in section_set
else []
)
plugins = (
await Plugin.all().values() if "plugins" in section_set else []
)
share_links = cls._serialize_datetime_fields(
shares, ["created_at", "expires_at"]
@@ -51,6 +97,7 @@ class BackupService:
return BackupData(
version=VERSION,
sections=sections,
storage_adapters=list(adapters),
user_accounts=list(users),
automation_tasks=list(tasks),
@@ -63,106 +110,195 @@ class BackupService:
)
@classmethod
async def import_from_bytes(cls, filename: str, content: bytes) -> None:
async def import_from_bytes(
cls, filename: str, content: bytes, mode: str = "replace"
) -> None:
if not filename.endswith(".json"):
raise HTTPException(status_code=400, detail="无效的文件类型, 请上传 .json 文件")
try:
raw_data = json.loads(content)
except Exception:
raise HTTPException(status_code=400, detail="无法解析JSON文件")
await cls.import_data(BackupData(**raw_data))
await cls.import_data(BackupData(**raw_data), mode=mode)
@classmethod
async def import_data(cls, payload: BackupData) -> None:
async def import_data(cls, payload: BackupData, mode: str = "replace") -> None:
sections = cls._normalize_sections(payload.sections)
if mode not in {"replace", "merge"}:
raise HTTPException(status_code=400, detail="无效的导入模式")
share_links = (
cls._parse_datetime_fields(payload.share_links, ["created_at", "expires_at"])
if payload.share_links
else []
)
ai_providers = (
cls._parse_datetime_fields(payload.ai_providers, ["created_at", "updated_at"])
if payload.ai_providers
else []
)
ai_models = (
cls._parse_datetime_fields(payload.ai_models, ["created_at", "updated_at"])
if payload.ai_models
else []
)
ai_default_models = (
cls._parse_datetime_fields(
payload.ai_default_models, ["created_at", "updated_at"]
)
if payload.ai_default_models
else []
)
plugins = (
cls._parse_datetime_fields(payload.plugins, ["created_at", "updated_at"])
if payload.plugins
else []
)
async with in_transaction() as conn:
await ShareLink.all().using_db(conn).delete()
await AutomationTask.all().using_db(conn).delete()
await StorageAdapter.all().using_db(conn).delete()
await UserAccount.all().using_db(conn).delete()
await Configuration.all().using_db(conn).delete()
await AIDefaultModel.all().using_db(conn).delete()
await AIModel.all().using_db(conn).delete()
await AIProvider.all().using_db(conn).delete()
await Plugin.all().using_db(conn).delete()
if mode == "replace":
if "share_links" in sections:
await ShareLink.all().using_db(conn).delete()
if "automation_tasks" in sections:
await AutomationTask.all().using_db(conn).delete()
if "storage_adapters" in sections:
await StorageAdapter.all().using_db(conn).delete()
if "user_accounts" in sections:
await UserAccount.all().using_db(conn).delete()
if "configurations" in sections:
await Configuration.all().using_db(conn).delete()
if "ai_default_models" in sections:
await AIDefaultModel.all().using_db(conn).delete()
if "ai_models" in sections:
await AIModel.all().using_db(conn).delete()
if "ai_providers" in sections:
await AIProvider.all().using_db(conn).delete()
if "plugins" in sections:
await Plugin.all().using_db(conn).delete()
if payload.configurations:
await Configuration.bulk_create(
[Configuration(**config) for config in payload.configurations],
using_db=conn,
)
if "configurations" in sections and payload.configurations:
if mode == "merge":
await cls._merge_records(
Configuration, payload.configurations, conn
)
else:
await Configuration.bulk_create(
[Configuration(**config) for config in payload.configurations],
using_db=conn,
)
if payload.user_accounts:
await UserAccount.bulk_create(
[UserAccount(**user) for user in payload.user_accounts],
using_db=conn,
)
if "user_accounts" in sections and payload.user_accounts:
if mode == "merge":
await cls._merge_records(UserAccount, payload.user_accounts, conn)
else:
await UserAccount.bulk_create(
[UserAccount(**user) for user in payload.user_accounts],
using_db=conn,
)
if payload.storage_adapters:
await StorageAdapter.bulk_create(
[StorageAdapter(**adapter) for adapter in payload.storage_adapters],
using_db=conn,
)
if "storage_adapters" in sections and payload.storage_adapters:
if mode == "merge":
await cls._merge_records(
StorageAdapter, payload.storage_adapters, conn
)
else:
await StorageAdapter.bulk_create(
[StorageAdapter(**adapter) for adapter in payload.storage_adapters],
using_db=conn,
)
if payload.automation_tasks:
await AutomationTask.bulk_create(
[AutomationTask(**task) for task in payload.automation_tasks],
using_db=conn,
)
if "automation_tasks" in sections and payload.automation_tasks:
if mode == "merge":
await cls._merge_records(
AutomationTask, payload.automation_tasks, conn
)
else:
await AutomationTask.bulk_create(
[AutomationTask(**task) for task in payload.automation_tasks],
using_db=conn,
)
if payload.share_links:
await ShareLink.bulk_create(
[
ShareLink(**share)
for share in cls._parse_datetime_fields(
payload.share_links, ["created_at", "expires_at"]
)
],
using_db=conn,
)
if "share_links" in sections and share_links:
if mode == "merge":
await cls._merge_records(ShareLink, share_links, conn)
else:
await ShareLink.bulk_create(
[ShareLink(**share) for share in share_links],
using_db=conn,
)
if payload.ai_providers:
await AIProvider.bulk_create(
[
AIProvider(**item)
for item in cls._parse_datetime_fields(
payload.ai_providers, ["created_at", "updated_at"]
)
],
using_db=conn,
)
if "ai_providers" in sections and ai_providers:
if mode == "merge":
await cls._merge_records(AIProvider, ai_providers, conn)
else:
await AIProvider.bulk_create(
[AIProvider(**item) for item in ai_providers],
using_db=conn,
)
if payload.ai_models:
await AIModel.bulk_create(
[
AIModel(**item)
for item in cls._parse_datetime_fields(
payload.ai_models, ["created_at", "updated_at"]
)
],
using_db=conn,
)
if "ai_models" in sections and ai_models:
if mode == "merge":
await cls._merge_records(AIModel, ai_models, conn)
else:
await AIModel.bulk_create(
[AIModel(**item) for item in ai_models],
using_db=conn,
)
if payload.ai_default_models:
await AIDefaultModel.bulk_create(
[
AIDefaultModel(**item)
for item in cls._parse_datetime_fields(
payload.ai_default_models, ["created_at", "updated_at"]
)
],
using_db=conn,
)
if "ai_default_models" in sections and ai_default_models:
if mode == "merge":
await cls._merge_records(
AIDefaultModel, ai_default_models, conn
)
else:
await AIDefaultModel.bulk_create(
[AIDefaultModel(**item) for item in ai_default_models],
using_db=conn,
)
if payload.plugins:
await Plugin.bulk_create(
[
Plugin(**item)
for item in cls._parse_datetime_fields(
payload.plugins, ["created_at", "updated_at"]
)
],
using_db=conn,
)
if "plugins" in sections and plugins:
if mode == "merge":
await cls._merge_records(Plugin, plugins, conn)
else:
await Plugin.bulk_create(
[Plugin(**item) for item in plugins],
using_db=conn,
)
@classmethod
def _normalize_sections(cls, sections: list[str] | None) -> list[str]:
if not sections:
return list(cls.ALL_SECTIONS)
normalized = [item for item in sections if item]
invalid = [item for item in normalized if item not in cls.ALL_SECTIONS]
if invalid:
raise HTTPException(
status_code=400, detail=f"无效的备份分区: {', '.join(invalid)}"
)
result: list[str] = []
seen = set()
for item in normalized:
if item in seen:
continue
seen.add(item)
result.append(item)
return result
@staticmethod
async def _merge_records(model, records: list[dict], using_db) -> None:
for record in records:
data = dict(record)
record_id = data.pop("id", None)
if record_id is None:
await model.create(using_db=using_db, **data)
continue
updated = (
await model.filter(id=record_id)
.using_db(using_db)
.update(**data)
)
if updated == 0:
await model.create(using_db=using_db, id=record_id, **data)
@staticmethod
def _serialize_datetime_fields(
+1
View File
@@ -5,6 +5,7 @@ from pydantic import BaseModel, Field
class BackupData(BaseModel):
version: str | None = None
sections: list[str] = Field(default_factory=list)
storage_adapters: list[dict[str, Any]] = Field(default_factory=list)
user_accounts: list[dict[str, Any]] = Field(default_factory=list)
automation_tasks: list[dict[str, Any]] = Field(default_factory=list)