feat: 数据导出/导入(资产迁移/备份,支撑部署到其他服务器)

This commit is contained in:
gouki
2026-08-03 00:12:38 +00:00
parent b049eaf03c
commit f1cdcf716c
3 changed files with 99 additions and 3 deletions
+17 -1
View File
@@ -5,7 +5,7 @@
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Body, Depends, Query
from sqlmodel import Session from sqlmodel import Session
from app.core.security import require_api_key from app.core.security import require_api_key
@@ -32,6 +32,22 @@ def list_assets(
) )
@router.get("/export", summary="导出所有资产(数据迁移/备份)")
def export_assets(session: Session = Depends(get_session)) -> dict:
return asset_service.export_assets(session)
@router.post(
"/import",
summary="导入资产(从导出数据,按 name+类型+服务商去重)",
dependencies=[Depends(require_api_key)],
)
def import_assets_endpoint(
data: dict = Body(...), session: Session = Depends(get_session)
) -> dict:
return asset_service.import_assets(session, data.get("assets", []))
@router.get("/{asset_id}", response_model=AssetRead, summary="资产详情") @router.get("/{asset_id}", response_model=AssetRead, summary="资产详情")
def get_asset(asset_id: int, session: Session = Depends(get_session)): def get_asset(asset_id: int, session: Session = Depends(get_session)):
return asset_service.get_asset(session, asset_id) return asset_service.get_asset(session, asset_id)
+42 -1
View File
@@ -3,7 +3,7 @@
统一处理 Asset 主表与其一对一详情表(VPSDetail/DomainDetail/AIAccount)的联动。 统一处理 Asset 主表与其一对一详情表(VPSDetail/DomainDetail/AIAccount)的联动。
""" """
from datetime import date from datetime import date, datetime
from typing import List, Optional from typing import List, Optional
from fastapi import HTTPException, status from fastapi import HTTPException, status
@@ -235,6 +235,47 @@ def delete_asset(session: Session, asset_id: int) -> None:
session.commit() session.commit()
def export_assets(session: Session) -> dict:
"""导出所有资产(含 detail)为 JSON,用于数据迁移/备份"""
assets = session.exec(select(Asset)).all()
result = []
for asset in assets:
read = _to_read(session, asset, _get_detail(session, asset))
result.append(read.model_dump(mode="json"))
return {
"count": len(result),
"exported_at": datetime.utcnow().isoformat(),
"assets": result,
}
def import_assets(session: Session, assets_data: list) -> dict:
"""从导出数据导入资产(按 name+asset_type+provider 去重:存在则更新,不存在则创建)"""
created = 0
updated = 0
errors = []
skip_fields = {"id", "created_at", "updated_at", "days_to_expiry", "provider_name"}
for item in assets_data:
try:
payload = {k: v for k, v in item.items() if k not in skip_fields}
existing = session.exec(
select(Asset).where(
Asset.name == payload.get("name"),
Asset.asset_type == payload.get("asset_type"),
Asset.provider == payload.get("provider"),
)
).first()
if existing:
update_asset(session, existing.id, AssetUpdate(**payload))
updated += 1
else:
create_asset(session, AssetCreate(**payload))
created += 1
except Exception as e: # noqa: BLE001
errors.append(f"{item.get('name', '?')}: {e}")
return {"created": created, "updated": updated, "errors": errors}
def list_assets( def list_assets(
session: Session, session: Session,
asset_type: Optional[AssetType] = None, asset_type: Optional[AssetType] = None,
+40 -1
View File
@@ -761,10 +761,20 @@ const SettingsView = {
<input type="checkbox" v-model="store.dark" @change="applyDark"> 深色模式 <input type="checkbox" v-model="store.dark" @change="applyDark"> 深色模式
</label> </label>
</div> </div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<h3 class="font-semibold text-sm mb-2">数据管理(迁移/备份)</h3>
<p class="text-xs text-slate-500 mb-2">导出所有资产为 JSON,可导入到新部署的实例,实现数据迁移。</p>
<div class="flex gap-2 flex-wrap items-center">
<button @click="exportData" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800">导出数据</button>
<label class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800 cursor-pointer">导入数据<input type="file" accept=".json" @change="onImportFile" class="hidden"></label>
</div>
<div v-if="dataResult" class="mt-2 text-xs px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50">{{ dataResult }}</div>
</div>
</div>`, </div>`,
setup() { setup() {
const notifyChannels = ref([]); const notifyChannels = ref([]);
const notifyResult = ref(''); const notifyResult = ref('');
const dataResult = ref('');
onMounted(async () => { onMounted(async () => {
try { const r = await Api.get('/notify/channels'); notifyChannels.value = r.channels || []; } catch (e) { /* ignore */ } try { const r = await Api.get('/notify/channels'); notifyChannels.value = r.channels || []; } catch (e) { /* ignore */ }
}); });
@@ -785,7 +795,36 @@ const SettingsView = {
notifyResult.value = text; notifyResult.value = text;
} catch (e) { notifyResult.value = '失败:' + e.message; } } catch (e) { notifyResult.value = '失败:' + e.message; }
} }
return { store, settings, save: saveSettings, applyDark, notifyChannels, notifyResult, testNotify, checkRenewals }; async function exportData() {
dataResult.value = '导出中…';
try {
const r = await Api.get('/assets/export');
const blob = new Blob([JSON.stringify(r, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'vps-manager-export-' + new Date().toISOString().slice(0, 10) + '.json';
a.click();
URL.revokeObjectURL(url);
dataResult.value = '导出成功:' + r.count + ' 个资产';
} catch (e) { dataResult.value = '导出失败:' + e.message; }
}
function onImportFile(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (ev) => {
dataResult.value = '导入中…';
try {
const data = JSON.parse(ev.target.result);
const r = await Api.post('/assets/import', data);
dataResult.value = '导入完成:新增 ' + r.created + ',更新 ' + r.updated + (r.errors && r.errors.length ? ',错误 ' + r.errors.length : '');
await loadAssets();
} catch (err) { dataResult.value = '导入失败:' + err.message; }
};
reader.readAsText(file);
}
return { store, settings, save: saveSettings, applyDark, notifyChannels, notifyResult, testNotify, checkRenewals, dataResult, exportData, onImportFile };
}, },
}; };