SQLite 从"运行时数据库"降级为"备份交换格式":导出把当前库(MySQL 或 SQLite)
整库写成一份 SQLite 归档,恢复则是启动时在单个事务里 DELETE + INSERT。
前端三个接口的契约一行未改。
- core/system_backup.py 重写:
* dump_to_sqlite()(旧名 snapshot_db 保留为别名)取代在线备份 API:MySQL 下把
这条连接提到 REPEATABLE READ,保证 12 张表读的是同一时刻
* zip 结构不变(users.db + manifest.json + apks/),既有老备份继续可导入;不依赖
任何外部二进制(sqlite3 是标准库,python:slim 容器里现成)
* manifest 增加 db_backend / deployment_env / source_db_id,用于识别备份来源环境
* apply_restore:安全网从裸 pre_restore_*.db 升级为 pre_restore_*.zip(可直接再导入
回滚);跨环境导入默认硬停(需 force_env_mismatch=true)
* consume_pending_restore:启动时单事务整库替换,任何一步失败 rollback,当前数据
完好(比换文件更安全);坏归档挪 restore_failed_* 且不阻塞启动
* 归档库收尾时 checkpoint 回单文件模式并清掉 -wal/-shm(zip 只打包主文件)
- web_server.py:consume 从 init_db 之前移到之后(现在是事务替换,需要表与 app context)
- config.py:BACKUP_DIR / RESTORE_STAGING_DIR / RESTORE_PENDING_DIR 支持环境变量覆盖
—— 自动化测试必须把恢复目录指到临时位置,否则测试造的"待生效恢复任务"会在服务
下次重启时被当成用户的操作消费掉
- core/db_config.py:SQLite 回退模式也写库环境标签(备份要能标出来源环境)
- web/system_api.py + static/admin/system.js + templates/admin/monitor.html:
预览显示来源/当前环境,跨环境时出现「允许跨环境导入」勾选项;文案同步
验证(隔离副本,绝不碰真实库):导出→zip 结构与 manifest 正确;预览带来源环境与
告警;应用→生成 pre_restore zip + 归档就位;跨环境 apply 被拒;模拟重启→数据回到
导出时刻、经验库/动作库完好;塞入坏归档→挪 restore_failed_*、服务照常启动、数据未变。
133 lines
6.2 KiB
JavaScript
133 lines
6.2 KiB
JavaScript
// 「系统」Tab:数据备份导出/导入(依赖 base.js:esc/_csrfHeaders/apiPost/showToast/handleResult)
|
||
let _restoreToken = ''; // 预览通过后的暂存标识(用于 apply)
|
||
|
||
// ================== 导出 ==================
|
||
async function doExportBackup(){
|
||
const st=document.getElementById('backup-status');
|
||
const btn=event&&event.target||null;
|
||
if(btn)btn.disabled=true;
|
||
const includeApk=document.getElementById('backup-include-apk').checked;
|
||
if(st)st.textContent='正在生成备份并打包…';
|
||
try{
|
||
const r=await fetch('/api/system/backup/export',{
|
||
method:'POST',
|
||
headers:{'Content-Type':'application/json',..._csrfHeaders()},
|
||
body:JSON.stringify({include_apk:includeApk})
|
||
});
|
||
if(r.status===401){window.location='/login';return;}
|
||
if(!r.ok){
|
||
let msg='导出失败';
|
||
try{const d=await r.json();if(d&&d.error)msg=d.error;}catch(e){}
|
||
showToast(msg,'error');
|
||
if(st)st.textContent='';
|
||
return;
|
||
}
|
||
const blob=await r.blob();
|
||
const cd=r.headers.get('Content-Disposition')||'';
|
||
const m=/filename="?([^";]+)"?/i.exec(cd);
|
||
const fname=m?m[1]:('auto_control_backup_'+Date.now()+'.zip');
|
||
const url=URL.createObjectURL(blob);
|
||
const a=document.createElement('a');
|
||
a.href=url;a.download=fname;document.body.appendChild(a);
|
||
a.click();a.remove();
|
||
setTimeout(()=>URL.revokeObjectURL(url),3000);
|
||
if(st)st.textContent='已生成 '+fname;
|
||
showToast('导出成功','success');
|
||
}catch(e){
|
||
showToast('导出失败: '+e,'error');
|
||
if(st)st.textContent='';
|
||
}finally{
|
||
if(btn)btn.disabled=false;
|
||
}
|
||
}
|
||
|
||
// ================== 导入:上传预览 ==================
|
||
async function previewRestore(){
|
||
const fileEl=document.getElementById('restore-file');
|
||
const f=fileEl&&fileEl.files&&fileEl.files[0];
|
||
const box=document.getElementById('restore-preview');
|
||
const st=document.getElementById('restore-status');
|
||
const applyBtn=document.getElementById('btn-apply-restore');
|
||
if(!f){showToast('请先选择备份文件(.zip 或 .db)','error');return;}
|
||
_restoreToken='';
|
||
if(applyBtn)applyBtn.style.display='none';
|
||
if(st)st.textContent='';
|
||
box.innerHTML='<span class="text-muted">解析校验中…</span>';
|
||
const fd=new FormData();
|
||
fd.append('file',f);
|
||
try{
|
||
const r=await fetch('/api/system/backup/preview',{
|
||
method:'POST',
|
||
headers:_csrfHeaders(), // FormData 不能手动设 Content-Type
|
||
body:fd
|
||
});
|
||
if(r.status===401){window.location='/login';return;}
|
||
const d=await r.json();
|
||
box.innerHTML='';
|
||
if(!d.ok){showToast(d.error||'解析失败','error');return;}
|
||
_restoreToken=d.token;
|
||
renderRestorePreview(d.preview);
|
||
if(applyBtn)applyBtn.style.display='';
|
||
}catch(e){
|
||
box.innerHTML='';
|
||
showToast('上传失败: '+e,'error');
|
||
}
|
||
}
|
||
|
||
function renderRestorePreview(p){
|
||
const box=document.getElementById('restore-preview');
|
||
const rows=(p.tables||[]).map(t=>
|
||
'<tr><td>'+esc(t.label||t.table)+'</td><td>'+esc(t.table)+'</td><td>'+esc(t.rows)+'</td></tr>').join('');
|
||
const warns=(p.warnings||[]).map(w=>'<li>'+esc(w)+'</li>').join('');
|
||
const crossEnv=!!(p.source_env&&p.current_env&&p.source_env!==p.current_env);
|
||
box.innerHTML=
|
||
'<table class="table table-hover table-sm" style="max-width:560px">'+
|
||
'<tbody>'+
|
||
'<tr><th style="width:120px">文件</th><td>'+esc(p.file_name||'')+'('+(p.file_size!=null?fmtSize(p.file_size):'')+')</td></tr>'+
|
||
'<tr><th>库结构版本</th><td>备份 v'+esc(p.schema_version)+' / 当前 v'+esc(p.current_schema_version)+'</td></tr>'+
|
||
'<tr><th>来源 / 当前环境</th><td>'+(p.source_env?esc(p.source_env):'(旧版备份,未记录)')+' → '+esc(p.current_env||'未登记')+'</td></tr>'+
|
||
'<tr><th>完整性</th><td>'+esc(p.integrity||'')+'</td></tr>'+
|
||
'</tbody>'+
|
||
'</table>'+
|
||
(crossEnv?'<label style="display:block;margin:8px 0;padding:8px 12px;background:#fdecea;border:1px solid #f5c6cb;border-radius:6px">'+
|
||
'<input type="checkbox" id="restore-force-env"> '+
|
||
'允许跨环境导入('+esc(p.source_env)+' → '+esc(p.current_env)+')—— 默认拒绝</label>':'')+
|
||
'<div class="section-title" style="margin-top:8px">表数据行数</div>'+
|
||
'<table class="table table-hover table-sm" style="max-width:560px"><thead><tr><th>业务</th><th>表</th><th>行数</th></tr></thead><tbody>'+(rows||'<tr><td colspan="3">—</td></tr>')+'</tbody></table>'+
|
||
(warns?'<div style="background:#fff3cd;border:1px solid #ffda6a;color:#7a5b00;padding:8px 12px;border-radius:6px;margin-top:8px"><ul style="margin:0;padding-left:18px">'+warns+'</ul></div>':'');
|
||
}
|
||
|
||
function fmtSize(n){
|
||
if(n==null)return '';
|
||
if(n<1024)return n+' B';
|
||
if(n<1024*1024)return (n/1024).toFixed(1)+' KB';
|
||
return (n/1024/1024).toFixed(1)+' MB';
|
||
}
|
||
|
||
// ================== 导入:确认应用 ==================
|
||
async function applyRestore(){
|
||
const applyBtn=document.getElementById('btn-apply-restore');
|
||
const st=document.getElementById('restore-status');
|
||
if(!_restoreToken)return;
|
||
const forceEl=document.getElementById('restore-force-env');
|
||
const force=!!(forceEl&&forceEl.checked);
|
||
if(!confirm('确认应用导入?\n\n系统会先自动备份当前库(data/backups/pre_restore_*.zip),再把待导入数据覆盖现有全部数据。\n导入将在重启 web_server 后生效。\n\n确定继续?'))return;
|
||
if(applyBtn)applyBtn.disabled=true;
|
||
if(st)st.textContent='正在生成恢复任务…';
|
||
const r=await apiPost('/api/system/backup/apply',{token:_restoreToken,force_env_mismatch:force});
|
||
if(!r)return;
|
||
if(r.ok){
|
||
const box=document.getElementById('restore-preview');
|
||
box.innerHTML='<div style="background:#e8f5e9;border:1px solid #a5d6a7;padding:10px 14px;border-radius:6px">'+
|
||
'✅ 恢复任务已生成。<br>当前库已自动备份到 <code>'+esc(r.backup_name||'')+'</code>。<br>'+
|
||
'请<b>重启 web_server</b>,启动时将自动应用导入数据(恢复完成前不要再次导入)。</div>';
|
||
if(st)st.textContent='';
|
||
if(applyBtn)applyBtn.style.display='none';
|
||
document.getElementById('restore-file').value='';
|
||
_restoreToken='';
|
||
}else{
|
||
showToast(r.error||'应用导入失败','error');
|
||
if(applyBtn)applyBtn.disabled=false;
|
||
}
|
||
}
|