diff --git a/doc/API.md b/doc/API.md index e786c85..d274531 100644 --- a/doc/API.md +++ b/doc/API.md @@ -610,37 +610,49 @@ ### POST /api/system/backup/export -**JSON body**(不是表单):`{"include_apks": false}`。响应为 **zip 附件**: +**JSON body**(不是表单):`{"include_apk": true}`(默认 true)。响应为 **zip 附件**: ``` -users.db # sqlite 在线备份 API 做的一致快照 -apks/*.apk # include_apks=true 时 -manifest.json # {format:"auto_control_backup", version, created_at, schema_version, - # include_apk, tables:[{table,label,rows}], apks:[…], coverage_missing?} +users.db # 当前库(MySQL 或回退 SQLite)整库一致快照成的 SQLite 归档 +apks/*.apk # include_apk=true 时 +manifest.json # {format:"auto_control_backup", version:2, created_at, schema_version, + # db_backend, deployment_env, source_db_id, include_apk, + # tables:[{table,label,rows}], apks:[…], coverage_missing?} ``` +> SQLite 在这里是**备份交换格式**而非运行时库:MySQL 下导出时把连接提到 REPEATABLE READ, +> 保证 12 张表读的是同一时刻。 + ### POST /api/system/backup/preview multipart 上传 `.zip` 或 `.db`(字段 `file`)→ 校验并暂存: ```json {"ok": true, "token": "e172dc75e2f7", - "preview": {"integrity": "ok", "schema_version": 4, "current_schema_version": 4, - "tables": [{"table":"agent_action","label":"动作库","rows":4}], + "preview": {"integrity": "ok", "schema_version": 6, "current_schema_version": 6, + "source_env": "dev", "current_env": "dev", "source_backend": "mysql", + "source_db_id": "704fbbb0…", + "tables": [{"table":"agent_action","label":"动作库","rows":5}], "missing_optional": [], "extra_tables": [], - "warnings": ["备份为全量快照,含用户口令哈希、AI 控制台 API Key 等敏感信息…"]}} + "warnings": ["备份为全量数据,含用户口令哈希、AI 配置里的 API Key 等敏感信息…"]}} ``` - 完整性 `PRAGMA integrity_check` 必须 `ok`;缺必需表(`app_meta`/`user`/`task_job`/`device_group`)直接拒绝 -- `extra_tables` 非空 = 备份含**未登记进覆盖清单**的表(提示去登记) +- `extra_tables` 非空 = 备份含**未登记的表**(提示去登记) +- `source_env` ≠ `current_env` 时 `warnings` 会多一条跨环境告警 - 暂存 **TTL 30 分钟**,过期自动清理 - 文件非法 / 未选文件 → 400 ### POST /api/system/backup/apply -`{"token":"…"}` → 先自动把当前库快照到 `data/backups/pre_restore_.db`(安全网),再把暂存库落到 `data/restore_pending/`。 +`{"token":"…", "force_env_mismatch": false}` → 先自动把当前库导出成 +`data/backups/pre_restore_.zip`(安全网,可直接再导入回来),再把暂存归档落到 +`data/restore_pending/`。 -**必须重启服务才生效**(`web_server.py` 在 `init_db` 之前消费该目录)。token 无效/暂存缺失 → 400。 +- **必须重启服务才生效**:`web_server.py` 在 `init_db` 之后、`TaskManager` 之前消费该目录 +- 重启时**单事务整库替换**(DELETE 全表 + 分块 INSERT),失败自动回滚,当前数据不受影响 +- 备份来源环境与当前库不符时**默认拒绝**(400),要跨环境须显式传 `force_env_mismatch: true` +- token 无效/暂存缺失 → 400 --- diff --git a/doc/DATA_MODEL.md b/doc/DATA_MODEL.md index ca3824a..a7513dc 100644 --- a/doc/DATA_MODEL.md +++ b/doc/DATA_MODEL.md @@ -271,9 +271,13 @@ SUMMARY_TABLES = tuple(sorted(t.name for t in db.metadata.tables.values())) |------|------|--------| | `data/users.db`(+`-wal`/`-shm`) | SQLite 主库(**仅回退模式用**;连 MySQL 时这些文件不被读写,可留作历史归档) | 否 | | `data/apks/*.apk` | 上传的 APK | 否 | -| `data/backups/` | 导出临时 zip、`pre_restore_*.db`(导入前安全网)、`restore_failed_*` | 否 | +| `data/backups/` | 导出临时 zip、`pre_restore_*.zip`(导入前安全网)、`restore_failed_*` | 否 | | `data/restore_staging//` | 导入暂存(TTL 1800s 自动清理) | 否 | -| `data/restore_pending/` | 待生效恢复任务(重启时消费) | 否 | +| `data/restore_pending/` | 待生效恢复任务(重启时单事务消费) | 否 | + +> 后三个目录可用环境变量改到别处(`DATA_BACKUP_DIR` / `DATA_RESTORE_STAGING_DIR` / +> `DATA_RESTORE_PENDING_DIR`)——自动化测试必须这么做,否则测试造的待生效恢复任务 +> 会被服务当成用户的操作在下次重启时消费掉。 | `data/mcp_audit.log` | MCP 调用审计(路径由 `MCP_AUDIT_FILE` 指定) | 否 | | `data/uiauto.pid` | uiautodev 子进程 PID | 否 | | `data/*.json.migrated` | 旧 JSON 迁移归档 | 否 | diff --git a/doc/DEPLOY.md b/doc/DEPLOY.md index 6249762..f25a6fb 100644 --- a/doc/DEPLOY.md +++ b/doc/DEPLOY.md @@ -183,25 +183,56 @@ tail -20 logs/web.log # 无 ERROR/Traceback **「系统 → 数据备份 / 导入恢复」**(仅管理员): -- **导出**:`POST /api/system/backup/export` → sqlite 在线备份 API 做一致快照 → 打包 zip(`users.db` + `manifest.json` + 可选 `apks/*.apk`) -- **导入**:上传 zip/`.db` → 校验预览(完整性 / 必需表 / schema 版本 / 未登记表告警)→ 确认后自动把当前库快照到 `data/backups/pre_restore_*.db`(安全网)→ 落 `data/restore_pending/` → **重启服务生效** +- **导出**:`POST /api/system/backup/export` → 把当前库(MySQL 或回退模式的 SQLite) + 整库一致快照成一份 SQLite 归档 → 打包 zip(`users.db` + `manifest.json` + 可选 `apks/*.apk`) +- **导入**:上传 zip/`.db` → 校验预览(完整性 / 必需表 / schema 版本 / 未登记表 / **来源环境**) + → 确认后自动把当前库导出一份 `data/backups/pre_restore_*.zip`(安全网,可再导入回来) + → 落 `data/restore_pending/` → **重启服务生效** + +> **SQLite 在这里是"备份交换格式",不是运行时数据库。** 所以归档里永远是一个 `users.db`, +> 无论平台连的是 MySQL 还是 SQLite —— 前端接口、校验逻辑、老备份的兼容性都不用变, +> 也不依赖 `mysqldump` 这类外部二进制(容器是 `python:slim`,没有 MySQL 客户端)。 + +**恢复是怎么生效的**:重启时 `consume_pending_restore()` 在**单个事务内** `DELETE` 全表 + +分块 `INSERT`,任何一步失败就回滚——当前数据保持原样,不会留下半新半旧的库。 +坏归档会被挪到 `data/backups/restore_failed_*/` 且不阻塞启动。 + +**跨环境导入默认拒绝**:备份的 manifest 里记了来源环境(`deployment_env`), +若与当前库环境不一致(例如拿生产备份灌 dev 库),预览会弹黄框告警、直接应用会被拒绝; +确需跨环境时在预览页勾选「允许跨环境导入」。 ### 5.2 备份覆盖清单(红线) -覆盖清单 = `core/system_backup.py` 的 `SUMMARY_TABLES`,当前 12 张表:`app_meta` / `user` / `device_group` / `task_job` / `custom_action` / `apk_file` / `device` / `pending_device` / `agent_conversation` / `agent_experience` / `experience_audit` / `agent_action`。完整说明见 [DATA_MODEL.md](DATA_MODEL.md) §6。 +覆盖清单 = `core/system_backup.py` 的 `SUMMARY_TABLES`,**由模型元数据自动派生**: -> **新增任何持久化表,必须同步登记进该清单**——否则导出预览里看不到它,会被误判为"没有备份"(2026-09-10 动作库 `agent_action` 就踩过:数据其实在快照里,只是清单漏列)。 -> 导出侧有**覆盖自检**(登记表缺失 → `manifest.coverage_missing` + 日志告警);导入侧有**反向自检**(备份含未登记表 → 预览告警)。 +```python +SUMMARY_TABLES = tuple(sorted(t.name for t in db.metadata.tables.values())) +``` + +当前 12 张表:`app_meta` / `user` / `device_group` / `task_job` / `custom_action` / +`apk_file` / `device` / `pending_device` / `agent_conversation` / `agent_experience` / +`experience_audit` / `agent_action`。完整说明见 [DATA_MODEL.md](DATA_MODEL.md) §6。 + +> **新增一张 ORM 表,就自动进了覆盖清单**,不可能再漏(2026-09-10 动作库 `agent_action` +> 曾因手工维护漏登记,数据其实在快照里,只是清单没列 → 被误判为"没有备份")。 +> 还需要手工做的只有:给新表补 `TABLE_LABELS` 的中文标签。 +> 导出侧有**覆盖自检**(登记表缺失 → `manifest.coverage_missing` + 日志告警); +> 导入侧有**反向自检**(备份含未登记表 → 预览告警)。 ### 5.3 目录与手工备份 -| 目录 | 用途 | -|------|------| -| `data/backups/` | 导出临时 zip、`pre_restore_*.db`、`restore_failed_*` | -| `data/restore_staging/` | 导入暂存(TTL 30 分钟自动清理) | -| `data/restore_pending/` | 待生效恢复任务(重启时消费) | +| 目录 | 用途 | 可否用环境变量改 | +|------|------|------------------| +| `data/backups/` | 导出临时 zip、`pre_restore_*.zip`(导入前安全网)、`restore_failed_*` | `DATA_BACKUP_DIR` | +| `data/restore_staging/` | 导入暂存(TTL 30 分钟自动清理) | `DATA_RESTORE_STAGING_DIR` | +| `data/restore_pending/` | 待生效恢复任务(重启时消费) | `DATA_RESTORE_PENDING_DIR` | -手工整目录备份 `data/` 仍可作兜底,但**整库恢复建议走内置功能**(在线一致快照 + 预恢复备份 + 重启原子生效,避免手工替换被 WAL/占用文件破坏)。 +> 这三个环境变量主要给**自动化测试**用:测试必须把恢复目录指到临时位置, +> 否则测试造出来的"待生效恢复任务"会在服务下次重启时被当成用户的操作消费掉。 + +连 MySQL 时 `data/users.db` 及其 `-wal`/`-shm` **不再被读写**(只有回退模式才用), +可以留作历史归档。手工整目录备份 `data/` 仍可作兜底,但**整库恢复建议走内置功能** +(一致快照 + 预恢复备份 + 重启时事务替换)。 > ⚠️ 备份 zip 含**用户口令哈希与 AI 控制台 API Key(明文)**,注意保管与传输。 diff --git a/doc/DEVELOPMENT.md b/doc/DEVELOPMENT.md index 8ad63d7..fd4f3bc 100644 --- a/doc/DEVELOPMENT.md +++ b/doc/DEVELOPMENT.md @@ -145,6 +145,7 @@ MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<密码> \ |------|------|------|------| | `WEB_SECRET_KEY` | 未配置则随机 | `web_server.py` | 会话密钥,**生产必须固定** | | `DISABLE_SCHEDULER` | 未设 | `core/task_manager.py` | 设任意值则**不启动 cron 调度器**(测试用) | +| `DATA_BACKUP_DIR` / `DATA_RESTORE_STAGING_DIR` / `DATA_RESTORE_PENDING_DIR` | `data/backups` 等 | `config.py` | 备份/恢复目录改到别处;**自动化测试必须指到临时目录**(否则测试造的待生效恢复任务会在下次重启被当成用户操作消费) | | `MCP_ENABLED` | `1` | `scripts/start.sh` | =0 则不后台拉起 MCP | | `MCP_ALLOW_WRITE` | `0`(脚本内强制 1) | `mcp_server/config.py` | 写操作总开关 | | `MCP_PLATFORM_URL` / `_USER` / `_PASS` | `http://127.0.0.1:18050` / `admin` / 空 | `mcp_server/config.py` | 登录平台的凭据(改过 admin 密码要同步) |