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 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 fastapi.responses import JSONResponse
from domain.audit import AuditAction, audit from domain.audit import AuditAction, audit
@@ -16,8 +16,10 @@ router = APIRouter(
@router.get("/export", summary="导出全站数据") @router.get("/export", summary="导出全站数据")
@audit(action=AuditAction.DOWNLOAD, description="导出备份") @audit(action=AuditAction.DOWNLOAD, description="导出备份")
async def export_backup(request: Request): async def export_backup(
data = await BackupService.export_data() 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") timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
headers = {"Content-Disposition": f"attachment; filename=foxel_backup_{timestamp}.json"} headers = {"Content-Disposition": f"attachment; filename=foxel_backup_{timestamp}.json"}
return JSONResponse(content=data.model_dump(), headers=headers) return JSONResponse(content=data.model_dump(), headers=headers)
@@ -25,6 +27,10 @@ async def export_backup(request: Request):
@router.post("/import", summary="导入数据") @router.post("/import", summary="导入数据")
@audit(action=AuditAction.UPLOAD, description="导入备份") @audit(action=AuditAction.UPLOAD, description="导入备份")
async def import_backup(request: Request, file: UploadFile = File(...)): async def import_backup(
await BackupService.import_from_bytes(file.filename, await file.read()) request: Request,
file: UploadFile = File(...),
mode: str = Form("replace"),
):
await BackupService.import_from_bytes(file.filename, await file.read(), mode=mode)
return {"message": "数据导入成功。"} return {"message": "数据导入成功。"}
+228 -92
View File
@@ -20,18 +20,64 @@ from models.database import (
class BackupService: class BackupService:
ALL_SECTIONS = (
"storage_adapters",
"user_accounts",
"automation_tasks",
"share_links",
"configurations",
"ai_providers",
"ai_models",
"ai_default_models",
"plugins",
)
@classmethod @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(): async with in_transaction():
adapters = await StorageAdapter.all().values() adapters = (
users = await UserAccount.all().values() await StorageAdapter.all().values()
tasks = await AutomationTask.all().values() if "storage_adapters" in section_set
shares = await ShareLink.all().values() else []
configs = await Configuration.all().values() )
providers = await AIProvider.all().values() users = (
models = await AIModel.all().values() await UserAccount.all().values()
default_models = await AIDefaultModel.all().values() if "user_accounts" in section_set
plugins = await Plugin.all().values() 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( share_links = cls._serialize_datetime_fields(
shares, ["created_at", "expires_at"] shares, ["created_at", "expires_at"]
@@ -51,6 +97,7 @@ class BackupService:
return BackupData( return BackupData(
version=VERSION, version=VERSION,
sections=sections,
storage_adapters=list(adapters), storage_adapters=list(adapters),
user_accounts=list(users), user_accounts=list(users),
automation_tasks=list(tasks), automation_tasks=list(tasks),
@@ -63,106 +110,195 @@ class BackupService:
) )
@classmethod @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"): if not filename.endswith(".json"):
raise HTTPException(status_code=400, detail="无效的文件类型, 请上传 .json 文件") raise HTTPException(status_code=400, detail="无效的文件类型, 请上传 .json 文件")
try: try:
raw_data = json.loads(content) raw_data = json.loads(content)
except Exception: except Exception:
raise HTTPException(status_code=400, detail="无法解析JSON文件") raise HTTPException(status_code=400, detail="无法解析JSON文件")
await cls.import_data(BackupData(**raw_data)) await cls.import_data(BackupData(**raw_data), mode=mode)
@classmethod @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: async with in_transaction() as conn:
await ShareLink.all().using_db(conn).delete() if mode == "replace":
await AutomationTask.all().using_db(conn).delete() if "share_links" in sections:
await StorageAdapter.all().using_db(conn).delete() await ShareLink.all().using_db(conn).delete()
await UserAccount.all().using_db(conn).delete() if "automation_tasks" in sections:
await Configuration.all().using_db(conn).delete() await AutomationTask.all().using_db(conn).delete()
await AIDefaultModel.all().using_db(conn).delete() if "storage_adapters" in sections:
await AIModel.all().using_db(conn).delete() await StorageAdapter.all().using_db(conn).delete()
await AIProvider.all().using_db(conn).delete() if "user_accounts" in sections:
await Plugin.all().using_db(conn).delete() 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: if "configurations" in sections and payload.configurations:
await Configuration.bulk_create( if mode == "merge":
[Configuration(**config) for config in payload.configurations], await cls._merge_records(
using_db=conn, Configuration, payload.configurations, conn
) )
else:
await Configuration.bulk_create(
[Configuration(**config) for config in payload.configurations],
using_db=conn,
)
if payload.user_accounts: if "user_accounts" in sections and payload.user_accounts:
await UserAccount.bulk_create( if mode == "merge":
[UserAccount(**user) for user in payload.user_accounts], await cls._merge_records(UserAccount, payload.user_accounts, conn)
using_db=conn, else:
) await UserAccount.bulk_create(
[UserAccount(**user) for user in payload.user_accounts],
using_db=conn,
)
if payload.storage_adapters: if "storage_adapters" in sections and payload.storage_adapters:
await StorageAdapter.bulk_create( if mode == "merge":
[StorageAdapter(**adapter) for adapter in payload.storage_adapters], await cls._merge_records(
using_db=conn, 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: if "automation_tasks" in sections and payload.automation_tasks:
await AutomationTask.bulk_create( if mode == "merge":
[AutomationTask(**task) for task in payload.automation_tasks], await cls._merge_records(
using_db=conn, 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: if "share_links" in sections and share_links:
await ShareLink.bulk_create( if mode == "merge":
[ await cls._merge_records(ShareLink, share_links, conn)
ShareLink(**share) else:
for share in cls._parse_datetime_fields( await ShareLink.bulk_create(
payload.share_links, ["created_at", "expires_at"] [ShareLink(**share) for share in share_links],
) using_db=conn,
], )
using_db=conn,
)
if payload.ai_providers: if "ai_providers" in sections and ai_providers:
await AIProvider.bulk_create( if mode == "merge":
[ await cls._merge_records(AIProvider, ai_providers, conn)
AIProvider(**item) else:
for item in cls._parse_datetime_fields( await AIProvider.bulk_create(
payload.ai_providers, ["created_at", "updated_at"] [AIProvider(**item) for item in ai_providers],
) using_db=conn,
], )
using_db=conn,
)
if payload.ai_models: if "ai_models" in sections and ai_models:
await AIModel.bulk_create( if mode == "merge":
[ await cls._merge_records(AIModel, ai_models, conn)
AIModel(**item) else:
for item in cls._parse_datetime_fields( await AIModel.bulk_create(
payload.ai_models, ["created_at", "updated_at"] [AIModel(**item) for item in ai_models],
) using_db=conn,
], )
using_db=conn,
)
if payload.ai_default_models: if "ai_default_models" in sections and ai_default_models:
await AIDefaultModel.bulk_create( if mode == "merge":
[ await cls._merge_records(
AIDefaultModel(**item) AIDefaultModel, ai_default_models, conn
for item in cls._parse_datetime_fields( )
payload.ai_default_models, ["created_at", "updated_at"] else:
) await AIDefaultModel.bulk_create(
], [AIDefaultModel(**item) for item in ai_default_models],
using_db=conn, using_db=conn,
) )
if payload.plugins: if "plugins" in sections and plugins:
await Plugin.bulk_create( if mode == "merge":
[ await cls._merge_records(Plugin, plugins, conn)
Plugin(**item) else:
for item in cls._parse_datetime_fields( await Plugin.bulk_create(
payload.plugins, ["created_at", "updated_at"] [Plugin(**item) for item in plugins],
) using_db=conn,
], )
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 @staticmethod
def _serialize_datetime_fields( def _serialize_datetime_fields(
+1
View File
@@ -5,6 +5,7 @@ from pydantic import BaseModel, Field
class BackupData(BaseModel): class BackupData(BaseModel):
version: str | None = None version: str | None = None
sections: list[str] = Field(default_factory=list)
storage_adapters: list[dict[str, Any]] = Field(default_factory=list) storage_adapters: list[dict[str, Any]] = Field(default_factory=list)
user_accounts: 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) automation_tasks: list[dict[str, Any]] = Field(default_factory=list)
+8 -4
View File
@@ -1,8 +1,11 @@
import request from './client'; import request from './client';
export const backupApi = { export const backupApi = {
export: async () => { export: async (sections?: string[]) => {
const response = await request('/backup/export', { const params = new URLSearchParams();
(sections || []).forEach((section) => params.append('sections', section));
const query = params.toString();
const response = await request(`/backup/export${query ? `?${query}` : ''}`, {
method: 'GET', method: 'GET',
rawResponse: true, rawResponse: true,
}) as Response; }) as Response;
@@ -27,12 +30,13 @@ export const backupApi = {
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
}, },
import: async (file: File) => { import: async (file: File, mode: 'replace' | 'merge' = 'replace') => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
formData.append('mode', mode);
return request('/backup/import', { return request('/backup/import', {
method: 'POST', method: 'POST',
body: formData, body: formData,
}); });
}, },
}; };
+14
View File
@@ -556,10 +556,24 @@
"Export": "Export", "Export": "Export",
"Import": "Import", "Import": "Import",
"Export all data (adapters, users, tasks, shares) into a JSON file.": "Export all data (adapters, users, tasks, shares) into a JSON file.", "Export all data (adapters, users, tasks, shares) into a JSON file.": "Export all data (adapters, users, tasks, shares) into a JSON file.",
"Export selected data into a JSON file.": "Export selected data into a JSON file.",
"Keep your backup file safe.": "Keep your backup file safe.", "Keep your backup file safe.": "Keep your backup file safe.",
"Select backup sections": "Select backup sections",
"User Accounts": "User Accounts",
"Share Links": "Share Links",
"Configurations": "Configurations",
"AI Providers": "AI Providers",
"AI Models": "AI Models",
"AI Default Models": "AI Default Models",
"Plugin Data": "Plugins",
"Export Backup": "Export Backup", "Export Backup": "Export Backup",
"Restore data from a previously exported JSON file.": "Restore data from a previously exported JSON file.", "Restore data from a previously exported JSON file.": "Restore data from a previously exported JSON file.",
"Warning: This will clear and overwrite existing data.": "Warning: This will clear and overwrite existing data.", "Warning: This will clear and overwrite existing data.": "Warning: This will clear and overwrite existing data.",
"Import mode": "Import mode",
"Merge (upsert by ID)": "Merge (upsert by ID)",
"Replace (clear before import)": "Replace (clear before import)",
"Warning: This will clear data in the backup sections before importing.": "Warning: This will clear data in the backup sections before importing.",
"Warning: This will merge data in the backup sections and overwrite existing records with the same ID.": "Warning: This will merge data in the backup sections and overwrite existing records with the same ID.",
"Choose File and Restore": "Choose File and Restore", "Choose File and Restore": "Choose File and Restore",
"No files yet here": "No files yet here", "No files yet here": "No files yet here",
"This folder is empty": "This folder is empty", "This folder is empty": "This folder is empty",
+14
View File
@@ -547,10 +547,24 @@
"Export": "导出", "Export": "导出",
"Import": "恢复", "Import": "恢复",
"Export all data (adapters, users, tasks, shares) into a JSON file.": "点击按钮将所有数据(包括存储、用户、自动化任务和分享)导出为一个 JSON 文件。", "Export all data (adapters, users, tasks, shares) into a JSON file.": "点击按钮将所有数据(包括存储、用户、自动化任务和分享)导出为一个 JSON 文件。",
"Export selected data into a JSON file.": "导出选中的数据为一个 JSON 文件。",
"Keep your backup file safe.": "请妥善保管您的备份文件。", "Keep your backup file safe.": "请妥善保管您的备份文件。",
"Select backup sections": "选择备份内容",
"User Accounts": "账号",
"Share Links": "分享列表",
"Configurations": "配置",
"AI Providers": "AI 服务商",
"AI Models": "AI 模型",
"AI Default Models": "AI 默认模型",
"Plugin Data": "插件",
"Export Backup": "导出备份", "Export Backup": "导出备份",
"Restore data from a previously exported JSON file.": "从之前导出的JSON文件恢复数据。", "Restore data from a previously exported JSON file.": "从之前导出的JSON文件恢复数据。",
"Warning: This will clear and overwrite existing data.": "警告:此操作将清除并覆盖现有数据。", "Warning: This will clear and overwrite existing data.": "警告:此操作将清除并覆盖现有数据。",
"Import mode": "导入方式",
"Merge (upsert by ID)": "增量+覆盖(按 ID",
"Replace (clear before import)": "清空后导入",
"Warning: This will clear data in the backup sections before importing.": "警告:此操作会先清空备份中包含的分区数据,再导入。",
"Warning: This will merge data in the backup sections and overwrite existing records with the same ID.": "警告:此操作会合并备份中包含的分区数据,并按 ID 覆盖已存在记录。",
"Choose File and Restore": "选择文件并恢复", "Choose File and Restore": "选择文件并恢复",
"No files yet here": "这里还没有任何文件", "No files yet here": "这里还没有任何文件",
"This folder is empty": "此目录为空", "This folder is empty": "此目录为空",
@@ -1,5 +1,5 @@
import { memo, useState } from 'react'; import { memo, useState } from 'react';
import { Button, Typography, Upload, message, Modal, Card } from 'antd'; import { Button, Typography, Upload, message, Modal, Card, Checkbox, Space, Radio } from 'antd';
import PageCard from '../../components/PageCard'; import PageCard from '../../components/PageCard';
import { UploadOutlined, DownloadOutlined } from '@ant-design/icons'; import { UploadOutlined, DownloadOutlined } from '@ant-design/icons';
import { backupApi } from '../../api/backup'; import { backupApi } from '../../api/backup';
@@ -7,14 +7,40 @@ import { useI18n } from '../../i18n';
const { Paragraph, Text } = Typography; const { Paragraph, Text } = Typography;
const BACKUP_SECTIONS = [
{ key: 'user_accounts', labelKey: 'User Accounts' },
{ key: 'storage_adapters', labelKey: 'Storage Adapters' },
{ key: 'automation_tasks', labelKey: 'Automation Tasks' },
{ key: 'share_links', labelKey: 'Share Links' },
{ key: 'configurations', labelKey: 'Configurations' },
{ key: 'ai_providers', labelKey: 'AI Providers' },
{ key: 'ai_models', labelKey: 'AI Models' },
{ key: 'ai_default_models', labelKey: 'AI Default Models' },
{ key: 'plugins', labelKey: 'Plugin Data' },
] as const;
type BackupSection = typeof BACKUP_SECTIONS[number]['key'];
const ALL_SECTION_KEYS = BACKUP_SECTIONS.map((section) => section.key) as BackupSection[];
const BackupPage = memo(function BackupPage() { const BackupPage = memo(function BackupPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selectedSections, setSelectedSections] = useState<BackupSection[]>(ALL_SECTION_KEYS);
const [importMode, setImportMode] = useState<'replace' | 'merge'>('replace');
const { t } = useI18n(); const { t } = useI18n();
const importWarning = importMode === 'replace'
? t('Warning: This will clear data in the backup sections before importing.')
: t('Warning: This will merge data in the backup sections and overwrite existing records with the same ID.');
const importWarningType = importMode === 'replace' ? 'danger' : 'warning';
const exportOptions = BACKUP_SECTIONS.map((section) => ({
label: t(section.labelKey),
value: section.key,
}));
const canExport = selectedSections.length > 0;
const handleExport = async () => { const handleExport = async () => {
setLoading(true); setLoading(true);
try { try {
await backupApi.export(); await backupApi.export(selectedSections);
message.success(t('Export started, check your downloads.')); message.success(t('Export started, check your downloads.'));
} catch (e: any) { } catch (e: any) {
message.error(e.message || t('Export failed')); message.error(e.message || t('Export failed'));
@@ -29,7 +55,9 @@ const BackupPage = memo(function BackupPage() {
content: ( content: (
<Typography> <Typography>
<Paragraph>{t('Are you sure to import from this file?')}</Paragraph> <Paragraph>{t('Are you sure to import from this file?')}</Paragraph>
<Paragraph strong>{t('Warning: This will overwrite all data including users (with passwords), settings, storages and tasks. Irreversible!')}</Paragraph> <Paragraph>
<Text strong type={importWarningType}>{importWarning}</Text>
</Paragraph>
</Typography> </Typography>
), ),
okText: t('Confirm Import'), okText: t('Confirm Import'),
@@ -38,7 +66,7 @@ const BackupPage = memo(function BackupPage() {
onOk: async () => { onOk: async () => {
setLoading(true); setLoading(true);
try { try {
const response = await backupApi.import(file); const response = await backupApi.import(file, importMode);
message.success(response.message || t('Import succeeded! The page will refresh.')); message.success(response.message || t('Import succeeded! The page will refresh.'));
setTimeout(() => window.location.reload(), 2000); setTimeout(() => window.location.reload(), 2000);
} catch (e: any) { } catch (e: any) {
@@ -57,13 +85,22 @@ const BackupPage = memo(function BackupPage() {
<div style={{ display: 'flex', gap: '16px' }}> <div style={{ display: 'flex', gap: '16px' }}>
<Card title={t('Export')} style={{ flex: 1 }}> <Card title={t('Export')} style={{ flex: 1 }}>
<Paragraph> <Paragraph>
{t('Export all data (adapters, users, tasks, shares) into a JSON file.')} {t('Export selected data into a JSON file.')}
<Text strong>{t('Keep your backup file safe.')}</Text> <Text strong>{t('Keep your backup file safe.')}</Text>
</Paragraph> </Paragraph>
<Space direction="vertical" size={8} style={{ width: '100%', marginBottom: 12 }}>
<Text>{t('Select backup sections')}</Text>
<Checkbox.Group
options={exportOptions}
value={selectedSections}
onChange={(values) => setSelectedSections(values as BackupSection[])}
/>
</Space>
<Button <Button
icon={<DownloadOutlined />} icon={<DownloadOutlined />}
onClick={handleExport} onClick={handleExport}
loading={loading} loading={loading}
disabled={!canExport}
> >
{t('Export Backup')} {t('Export Backup')}
</Button> </Button>
@@ -71,8 +108,22 @@ const BackupPage = memo(function BackupPage() {
<Card title={t('Import')} style={{ flex: 1 }}> <Card title={t('Import')} style={{ flex: 1 }}>
<Paragraph> <Paragraph>
{t('Restore data from a previously exported JSON file.')} {t('Restore data from a previously exported JSON file.')}
<Text strong type="danger">{t('Warning: This will clear and overwrite existing data.')}</Text>
</Paragraph> </Paragraph>
<Space direction="vertical" size={8} style={{ width: '100%', marginBottom: 12 }}>
<Text>{t('Import mode')}</Text>
<Radio.Group
optionType="button"
buttonStyle="solid"
value={importMode}
onChange={(event) => setImportMode(event.target.value)}
>
<Radio.Button value="merge">{t('Merge (upsert by ID)')}</Radio.Button>
<Radio.Button value="replace">{t('Replace (clear before import)')}</Radio.Button>
</Radio.Group>
<Text type={importWarningType}>
{importWarning}
</Text>
</Space>
<Upload <Upload
beforeUpload={handleImport} beforeUpload={handleImport}
showUploadList={false} showUploadList={false}