refactor: 备份/恢复改为「SQLite 归档 + 单事务整库替换」——不再换文件,也不再依赖 SQLite 运行时
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_*、服务照常启动、数据未变。
This commit is contained in:
@@ -79,14 +79,19 @@ function renderRestorePreview(p){
|
||||
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>':'');
|
||||
@@ -104,10 +109,12 @@ async function applyRestore(){
|
||||
const applyBtn=document.getElementById('btn-apply-restore');
|
||||
const st=document.getElementById('restore-status');
|
||||
if(!_restoreToken)return;
|
||||
if(!confirm('确认应用导入?\n\n系统会先自动备份当前库(data/backups/pre_restore_*.db),再把待导入数据覆盖现有全部数据。\n导入将在重启 web_server 后生效。\n\n确定继续?'))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});
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user