10 Commits
Author SHA1 Message Date
butubb 5f02edb5c3 feat: 浏览器标签名带环境前缀(dev-设备自动化后台 / 设备自动化后台)
同时开 dev 和生产的标签页时分不清谁是谁。标题取 web_server 注入的
db_env(非 prod 才加前缀),login / monitor / wall 三个页面一致。
2026-09-13 13:03:37 +08:00
butubb c6fda262db fix: 回归脚本两处"自己坏掉"的问题——GBK 控制台崩在汇总、写死过期设备串
在 MySQL dev 库上跑全量回归时暴露:
1. 汇总里的 ⚠/❌/✅ 在 Windows GBK 控制台抛 UnicodeEncodeError,而且崩在打印
   汇总那一步 —— 探测其实全跑完了,看起来却像脚本挂了(stdout 重设为 UTF-8)
2. `100.100.10.11:5555` 是 Tailscale 时代的地址,设备早换了:路径参数与两个关键
   POST 都拿它当目标 → 每次回归要等好几轮 30s adb connect 超时,还误报
   "关键 POST 测试步骤(wait) 失败"。改为**运行期从设备池挑一台启用设备**

结果(对 MySQL dev 库):99 项检查全通过(38 GET + 60 写探测 + 7 关键业务),
仅 3 条 Tailscale 未配置的业务提示。
2026-09-13 11:05:44 +08:00
butubb da77d8eb6b fix: 回归脚本 Windows 可用 + 加"禁止对生产库跑"安全闸;迁移脚本去掉 GBK 控制台会崩的符号
- scripts/regression_test.py:
  * signal.alarm 加 hasattr 守卫(Windows 上直接 AttributeError 退出,
    backlog A4)→ 本机终于能跑"一条命令扫全接口 500"
  * 新增安全闸:.env 声明 prod、或目标库 app_meta.deployment_env=prod 时
    **拒绝运行**(exit 3)。本脚本会发真实写请求(设备池增删/剪贴板注入/
    亮灭屏),跑在生产配置上就是拿正式数据做实验
- scripts/migrate_sqlite_to_mysql.py: stdout 重设为 UTF-8 并去掉 ✔/✘ 符号。
  实测在 GBK 控制台里打印 ✔ 会抛 UnicodeEncodeError,而且崩在"写库标签"之前,
  看起来像迁移失败(数据其实已搬完)
- doc/backlog/TODO.md: A4 移入已完成;gitignore 条目更新(mcp_audit.log 已补,
  uiauto.pid 仍缺);登记 MySQL 迁移与回归安全闸

实测(对 192.168.2.27 的 auto_control_dev):数据迁移 12 张表逐表 SHA-256 一致;
应用直连 MySQL 全部接口 200,设备名/指纹唯一索引语义与 SQLite 一致(空值可重复、
非空重复被拦、大小写敏感 A08≠a08);备份导出→预览→应用往返正常。
2026-09-13 10:48:36 +08:00
butubb f5fc96c300 feat: 页面顶部环境徽标(防混库第 4 层)
启动日志和横幅只在日志里;用户在界面上操作时也该一眼看出连的是哪个库。
生产显示红底 'PROD · <库名@主机/库>',开发显示灰底 'DEV',鼠标悬停给出完整目标。
环境信息由 core/db_config.env_badge() 提供(不含密码),web_server 注入 Jinja 全局。
2026-09-13 10:38:13 +08:00
butubb 429aaf6283 feat: SQLite → MySQL 数据迁移脚本 + 建库建账号 SQL(P3)
- scripts/migrate_sqlite_to_mysql.py(新增):源库完整性校验 → 列宽审计(SQLite 不强制
  长度,MySQL 严格模式下超长直接报错,先拦下)→ 建目标 schema(复用平台的 create_all
  + _sync_columns + 唯一索引)→ 逐表单事务搬行 → 逐表行数 + 全行 SHA-256 比对 →
  写库环境标签与 schema_version。支持 --dry-run / --mode verify / --schema-only;
  --env prod 必须 --allow-prod(+ 交互确认或 --yes);目标库已登记别的环境时拒绝
- scripts/sql/init_mysql_5.7.sql(新增):建 auto_control / auto_control_dev 两个库
  (utf8mb4 + utf8mb4_bin)+ 专用账号与授权
- doc/DEPLOY.md §7.2:补 SQLite→MySQL 迁移流程与注意事项

比对口径踩坑记录:验证哈希必须两边都从**驱动层原生读**。走 ORM/Core 的 typed
select 会把 Boolean 读成 True/False,而源库 SQLite 原始读出来是 1/0 —— 三张带
布尔列的表(user/device/task_job)会全部误报"不一致"。现在统一用原生 SQL 回读 +
值规范化(bool→int、整数值浮点→int、bytes→str)。

自测(目标用 SQLite 代跑,MySQL 专属部分待 P4 真库验证):12 张表逐表 SHA-256 一致、
重复迁移幂等、verify 模式通过。
2026-09-13 10:37:15 +08:00
butubb 2a3775ffd5 docs: 同步 P5 备份机制重写——DEPLOY §5 / API §13 / DATA_MODEL §7 / DEVELOPMENT §4.3
- DEPLOY §5.1: 说明 SQLite 现在是「备份交换格式」,恢复走单事务整库替换,
  跨环境导入默认拒绝;§5.2 覆盖清单改为「由 metadata 派生」;§5.3 三个目录
  支持环境变量覆盖及原因
- API §13: 导出/预览/应用三个接口的字段补齐(db_backend/deployment_env/
  source_db_id/source_env/current_env、force_env_mismatch、pre_restore zip)
- DATA_MODEL §7: pre_restore 改 .zip、补目录可覆盖说明
- DEVELOPMENT §4.3: 补三个备份目录环境变量
2026-09-13 10:35:26 +08:00
butubb 4899e66c68 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_*、服务照常启动、数据未变。
2026-09-13 10:34:58 +08:00
butubb b145a007eb docs: 同步 P1/P2——DATA_MODEL 重写引擎/建表/迁移/唯一索引/覆盖清单章节,ARCHITECTURE 更新装配顺序与数据库决策
- DATA_MODEL §1: 引擎改为「MySQL(正式)/SQLite(回退)」+ 连接参数 + 环境与库名绑定表;
  表清单注明 12 张全部是 ORM 模型(原 5 张裸表已并入)
- §4 重写: 建表/补列以模型为准(create_all + _sync_columns),补 §4.2.1 说明
  排序规则为何必须 utf8mb4_bin;唯一索引补 MySQL 的「生成列 + 唯一索引」方案
- §5 补 deployment_env/deployment_id/deployment_claimed_at 三个键
- §6 覆盖清单改为「由 metadata 派生」,红线只剩补 TABLE_LABELS
- ARCHITECTURE: 装配阶段 B/C/D 更新(db_config 装配 + 环境校验横幅)、
  §3.2 并发描述按方言改写、§7 数据库决策表改写
2026-09-13 10:31:09 +08:00
butubb c834349fac refactor: schema 收敛到 ORM metadata——5 张裸表升模型,方言无关的建表/补列/唯一索引
原来建表有三套来源(ORM create_all + 自建 SCHEMA_MIGRATIONS + agent_api 里的裸
CREATE TABLE)。这在 SQLite 下能活是因为 SQLite 的 DDL 极宽容;换 MySQL 后
agent_* 四张表的 `INTEGER PRIMARY KEY AUTOINCREMENT`、`TEXT DEFAULT ''`、
`TEXT PRIMARY KEY` 会全部建不出来,而失败被 `except: pass` 吞掉 —— 表现为
「经验库/动作库/会话功能静默失灵、日志里什么都看不到」。本期把它收敛成一套。

- core/models.py:
  * 新增 5 个 ORM 模型:AppMeta / AgentExperience / ExperienceAudit /
    AgentAction / AgentConversation(列名沿用历史,`app_meta.key` 在 MySQL 里是
    保留字,ORM 属性名用 k,读写统一走 db_config.meta_get/meta_set)
  * 新增 _long_text()(MySQL 用 MEDIUMTEXT,裸 TEXT 只有 64KB)与 _DOUBLE()
    (评分别用单精度 FLOAT);_migrate_schema 不再执行 DDL,只维护版本账本,
    SCHEMA_MIGRATIONS 的历史建表/加列条目 SQL 置 None
  * 新增 _sync_columns():用 sqlalchemy.inspect 比对模型与实表补缺列,取代原来
    靠 "duplicate column name" 报错文本判断的写法(换方言就失效)
  * _ensure_unique_indexes() 按方言分叉:MySQL 5.7 没有过滤索引,改用
    「虚拟生成列 + 唯一索引」复刻"空值不参与唯一约束"的语义
- web/agent_api.py: 删 4 段裸 CREATE TABLE;_ensure_* 收敛为 _ensure_tables()
  (db.create_all 薄封装,失败必记日志);app_meta 读写与 INSERT OR IGNORE
  改为方言中立
- core/device_discovery.py: app_meta 读写改用同一助手
- core/system_backup.py: SUMMARY_TABLES 由 db.metadata 派生 —— 备份覆盖红线
  从"靠人记"变成结构上不可能漏;CURRENT_SCHEMA_VERSION 改从 models 导入

验证(均在 users.db 的一致快照副本上,不碰真实库):
  空库 → 自建 12 张表 + 默认管理员 + 唯一索引 + schema_version=6;
  老库 → 逐表行数与真实库完全一致,app_meta 键未变;
  接口 → health/devices/jobs/groups/agent(经验库/动作库/会话) 全 200,
        会话增删与备份导出(12 张表、无覆盖缺失)正常。
2026-09-13 10:30:15 +08:00
butubb f22263ab45 feat: 数据库连接层改造——库目标由 .env 装配 + 环境防呆(迁 MySQL 第一步)
为把数据库从单文件 SQLite 迁到 MySQL 5.7 铺路。本期不改后端:
DB_HOST 为空时仍走 SQLite,本地开发无感。

- config.py: 新增 DEPLOY_ENV(默认 dev)与 DB_HOST/PORT/USER/PASSWORD/NAME/
  CHARSET/COLLATION、DATABASE_URL、两个逃生阀(DB_ALLOW_ENV_MISMATCH /
  DB_ALLOW_SQLITE_FALLBACK)
- core/db_config.py(新增): URI 组装;按方言分叉的引擎参数(utf8mb4、
  pool_pre_ping、pool_recycle=1800、READ COMMITTED、STRICT_TRANS_TABLES);
  连接探活;app_meta 方言中立读写(MySQL 里 key 是保留字,需反引号)
- 防混库三层: ①库名与环境绑定(dev→auto_control_dev / prod→auto_control)
  ②库标签 app_meta.deployment_env 与 .env 声明比对 ③启动横幅打印当前库
  (生产用 WARNING 级)。不符直接拒绝启动并说明两边分别是什么
- web_server.py: 硬编码 sqlite URI → db_config;配置错在装配期就 exit 2;
  init_db 之后跑库标签校验 + 横幅
- core/models.py: PRAGMA 监听器加 sqlite 类型守卫——它挂在 Engine 基类上,
  MySQL 连接执行 PRAGMA 会直接导致建连失败
- requirements.txt 加 PyMySQL;scripts/start.sh 依赖守卫加 pymysql,
  并在 exec 前打印 DEPLOY_ENV/DB_NAME/DB_HOST
- .env.example 新增「数据库」段;DEVELOPMENT.md §4.1/4.2、DEPLOY.md §2.2 同步

验证: 用 DATABASE_URL 指向 users.db 的一致快照副本跑通主要只读接口
(health/devices/jobs/pool/groups/discovery/summary 全 200,app_meta 读写正常);
DEPLOY_ENV=prod 且无 DB_HOST 时退出码 2;开 DB_ALLOW_SQLITE_FALLBACK 后可回退。
2026-09-13 10:27:14 +08:00
24 changed files with 1694 additions and 371 deletions
+27
View File
@@ -7,6 +7,33 @@
# - 完整说明见 doc/DEVELOPMENT.md §4 配置速查、doc/DEPLOY.md §2.2
# ==============================================================================
# ==================== 数据库 ====================
# 平台用 MySQL(正式用法)。DEPLOY_ENV 声明"这份配置连的是哪个环境的库",
# 并与库名一一绑定,启动时会互相校验,不符直接拒绝启动:
# DEPLOY_ENV=dev → 期望库名 auto_control_dev(本地开发,可随意折腾)
# DEPLOY_ENV=prod → 期望库名 auto_control (正式数据)
# 首次连接的库会被打上环境标签(app_meta.deployment_env);标签与 DEPLOY_ENV
# 不符时拒绝启动——这是防止「开发配置连到生产库」的最后一道闸。
DEPLOY_ENV=dev
DB_HOST=
DB_PORT=3306
DB_USER=
DB_PASSWORD=
DB_NAME=auto_control_dev
# 排序规则用 _bin(逐码点比较,等价 SQLite 的大小写敏感语义),别改成 _general_ci
DB_CHARSET=utf8mb4
DB_COLLATION=utf8mb4_bin
# 完整连接串:优先级最高,用于脚本临时指向别的库(一般不用配)
# DATABASE_URL=mysql+pymysql://user:pass@host:3306/auto_control_dev?charset=utf8mb4
# 逃生阀(默认关,仅在明确知道后果时打开)
# DB_ALLOW_ENV_MISMATCH=1 环境与库名/库标签不符时仍启动(危险)
# DB_ALLOW_SQLITE_FALLBACK=1 生产环境 DB_HOST 为空时允许回退 SQLite(回滚用)
# DB_ALLOW_ENV_MISMATCH=0
# DB_ALLOW_SQLITE_FALLBACK=0
# ==================== Web 服务 ====================
# 会话密钥【必须,生产务必固定】:不配则每次启动随机生成,重启后登录态失效。
# 生成:python -c "import secrets; print(secrets.token_hex(32))"
+33 -3
View File
@@ -47,6 +47,33 @@ else:
# 部署后需执行 chmod +x bin/adb/adb
ADB_PATH = os.path.join(_ADB_DIR, "adb")
# ================== 数据库 ==================
# 平台支持 MySQL(正式用法)与 SQLite(仅作回退,见 core/db_config.py)。
#
# DEPLOY_ENV 声明"这套配置连的是哪个环境的库",与库名一一绑定:
# dev → auto_control_dev(本地开发,可随意折腾)
# prod → auto_control (正式数据)
# 启动时会校验「.env 声明的环境」与「目标库登记的环境」是否一致,
# 不一致直接拒绝启动——防止把开发配置连到生产库上。
DEPLOY_ENV = _env("DEPLOY_ENV", "dev").strip().lower()
DB_HOST = _env("DB_HOST", "") # 为空则回退 SQLite(生产禁止静默回退)
DB_PORT = int(_env("DB_PORT", "3306"))
DB_USER = _env("DB_USER", "")
DB_PASSWORD = _env("DB_PASSWORD", "") # 写入 .env,不要提交到 git
DB_NAME = _env("DB_NAME", "")
DB_CHARSET = _env("DB_CHARSET", "utf8mb4")
# 排序规则用 _bin(逐码点比较,等价 SQLite 的大小写敏感语义)。
# 用默认的 utf8mb4_general_ci 会让 Admin/admin、Phone1/phone1 被判重复。
DB_COLLATION = _env("DB_COLLATION", "utf8mb4_bin")
# 完整连接串(优先级最高,迁移/回归脚本用它临时指向别的库)
DATABASE_URL = _env("DATABASE_URL", "")
# 逃生阀(默认关;仅在你明确知道自己在干什么时打开)
DB_ALLOW_ENV_MISMATCH = _env("DB_ALLOW_ENV_MISMATCH", "0") == "1"
DB_ALLOW_SQLITE_FALLBACK = _env("DB_ALLOW_SQLITE_FALLBACK", "0") == "1"
# ================== Web 后台 ==================
WEB_HOST = "0.0.0.0"
# 端口必须避开 Windows 动态端口范围(本机被改成 1024-15000,默认 49152-65535)
@@ -63,9 +90,12 @@ APK_DIR = os.path.join(DATA_DIR, "apks")
# ================== 系统数据备份(导出/导入) ==================
# 导出 zip 与恢复前快照存放;导入暂存目录;待下次启动生效的恢复目录
# (运行时产物,不入 git,见 .gitignore data/backups 等)
BACKUP_DIR = os.path.join(DATA_DIR, "backups")
RESTORE_STAGING_DIR = os.path.join(DATA_DIR, "restore_staging")
RESTORE_PENDING_DIR = os.path.join(DATA_DIR, "restore_pending")
#
# 三个目录都支持用环境变量改到别处——自动化测试必须把恢复目录指到临时位置,
# 否则测试造出来的"待生效恢复任务"会在服务下次重启时被真当成用户的操作消费掉。
BACKUP_DIR = _env("DATA_BACKUP_DIR", "") or os.path.join(DATA_DIR, "backups")
RESTORE_STAGING_DIR = _env("DATA_RESTORE_STAGING_DIR", "") or os.path.join(DATA_DIR, "restore_staging")
RESTORE_PENDING_DIR = _env("DATA_RESTORE_PENDING_DIR", "") or os.path.join(DATA_DIR, "restore_pending")
# ================== SSH 到部署机(已废弃,STF 相关操作退役) ==================
# 阶段 3 后代码不再使用 SSH;保留配置仅供手动运维 220(如 docker stop stf)。
+287
View File
@@ -0,0 +1,287 @@
"""数据库连接装配:URI 组装、引擎参数、环境防呆校验、启动横幅。
为什么单独一个模块:
web_server 要装配 app、迁移脚本/回归脚本要复用同一套 URI 与校验逻辑;
塞进 config.py 会把它撑肥,塞进 models.py 会引入循环依赖。
支持两种后端:
- **MySQL**(DB_HOST 非空):正式用法,dev 库 / 正式库由 .env 的 DEPLOY_ENV 决定
- **SQLite**(DB_HOST 为空):仅作迁移期回退;生产禁止静默回退,需 DB_ALLOW_SQLITE_FALLBACK=1
防呆设计(曾经把生产数据搞混过,这层是刚需):
1. 库名与环境绑定:dev → auto_control_dev,prod → auto_control,对不上拒绝启动
2. 库标签:app_meta.deployment_env 记录"这个库属于哪个环境",不匹配硬停
3. 启动横幅:每次启动都打印目标库,prod 用 WARNING 级(tail 日志必然可见)
"""
import os
import uuid
from sqlalchemy import create_engine, text
from sqlalchemy.engine import make_url
from config import (DATABASE_URL, DB_ALLOW_ENV_MISMATCH, DB_ALLOW_SQLITE_FALLBACK,
DB_CHARSET, DB_COLLATION, DB_HOST, DB_NAME, DB_PASSWORD,
DB_PORT, DB_USER, DATA_DIR, DEPLOY_ENV)
from core.logger import get_logger
_log = get_logger("core.db")
# 环境 ↔ 库名绑定:防止「dev 的 .env 抄错成正式库名」这类事故
_EXPECTED_DB_NAME = {"dev": "auto_control_dev", "prod": "auto_control"}
_REQUIRED_MYSQL_VERSION = "5.7"
# app_meta 里记录库身份的键
_K_ENV, _K_ID, _K_CLAIMED = "deployment_env", "deployment_id", "deployment_claimed_at"
class DBConfigError(Exception):
"""数据库配置错误(消息直接面向运维,要说清怎么改)。"""
# ================== URI 与引擎参数 ==================
def is_sqlite(uri):
return str(uri).startswith("sqlite")
def build_db_uri():
"""按 .env 组装连接串。DATABASE_URL 优先级最高(脚本临时指向别的库用)。"""
if DATABASE_URL:
return DATABASE_URL
if DB_HOST:
from urllib.parse import quote_plus
return "mysql+pymysql://{}:{}@{}:{}/{}?charset={}".format(
quote_plus(DB_USER), quote_plus(DB_PASSWORD),
DB_HOST, DB_PORT, DB_NAME, DB_CHARSET)
# 回退 SQLite:生产环境必须显式开逃生阀,否则拒绝启动(静默降级本身就是混库温床)
if DEPLOY_ENV == "prod" and not DB_ALLOW_SQLITE_FALLBACK:
raise DBConfigError(
"生产环境(DEPLOY_ENV=prod)未配置 DB_HOST,且未显式允许回退 SQLite。\n"
" 如确需临时回退到 sqlite,请在 .env 里加 DB_ALLOW_SQLITE_FALLBACK=1\n"
" 否则请补全 DB_HOST/DB_USER/DB_PASSWORD/DB_NAME。")
return "sqlite:///" + os.path.join(DATA_DIR, "users.db")
def engine_options(uri):
"""create_engine 参数。SQLite 与 MySQL 需求不同,必须分叉。"""
if is_sqlite(uri):
return {"pool_pre_ping": True}
return {
# 长驻后台线程(TaskManager/device_discovery)长时间空闲后,
# 连接可能已被 MySQL 的 wait_timeout 掐断 / 中间链路掉线
"pool_pre_ping": True,
"pool_recycle": 1800,
"pool_size": 10,
"max_overflow": 20,
"pool_timeout": 30,
# 接近 SQLite「逐语句读最新提交」的语义,并显著降低间隙锁/死锁概率。
# 备份导出需要一致性快照时会单独把那个连接提到 REPEATABLE READ。
"isolation_level": "READ COMMITTED",
"connect_args": {
"charset": DB_CHARSET,
# 保留 STRICT_TRANS_TABLES(超长写入报错而不是静默截断);
# 去掉 5.7 默认带的 ONLY_FULL_GROUP_BY,避免给未来埋雷
"init_command": "SET sql_mode='STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION'",
},
}
def create_engine_from_uri(uri=None):
"""独立建一个 engine(不经过 Flask-SQLAlchemy)。脚本与启动前探活用。"""
uri = uri or build_db_uri()
return create_engine(uri, **engine_options(uri))
def describe_target(uri=None):
"""人类可读的目标描述(**不含密码**),用于日志与横幅。"""
uri = uri or build_db_uri()
try:
u = make_url(uri)
except Exception:
return str(uri)
if u.get_backend_name() == "sqlite":
return u.database
return "{}@{}:{}/{}".format(u.username or "", u.host or "", u.port or 3306,
u.database or "")
# ================== app_meta 读写(方言中立)==================
def _q(name):
"""按当前方言给标识符加引号:MySQL 里 key 是保留字,必须反引号。"""
from core.models import db
return db.engine.dialect.identifier_preparer.quote(name)
def meta_get(key, default=None):
from core.models import db
row = db.session.execute(
text("SELECT value FROM app_meta WHERE {} = :k".format(_q("key"))),
{"k": key}).fetchone()
return row[0] if row else default
def meta_set(key, value):
"""方言中立 upsert:SQLite 用 INSERT OR REPLACE,MySQL 用 ON DUPLICATE KEY。"""
from core.models import db
k = _q("key")
if db.engine.dialect.name == "mysql":
sql = ("INSERT INTO app_meta ({k}, value) VALUES (:k, :v) "
"ON DUPLICATE KEY UPDATE value = VALUES(value)").format(k=k)
else:
sql = "INSERT OR REPLACE INTO app_meta ({k}, value) VALUES (:k, :v)".format(k=k)
db.session.execute(text(sql), {"k": key, "v": str(value)})
db.session.commit()
# ================== 启动前校验(不需要业务表)==================
def check_connection(uri=None):
"""连接探活 + 环境/库名一致性校验。失败抛 DBConfigError,由调用方决定是否退出。
不需要业务表,因此可以在 init_db 之前跑,做到"配置错就早点死"。
"""
uri = uri or build_db_uri()
if is_sqlite(uri):
_log.warning("数据库为 SQLite(%s)—— 回退模式,非正式用法", describe_target(uri))
return {"backend": "sqlite", "target": describe_target(uri)}
if DEPLOY_ENV not in _EXPECTED_DB_NAME:
raise DBConfigError(
"DEPLOY_ENV 取值非法: {!r}(只允许 dev / prod)".format(DEPLOY_ENV))
try:
eng = create_engine_from_uri(uri)
with eng.connect() as conn:
version = conn.execute(text("SELECT VERSION()")).scalar() or ""
db_name = conn.execute(text("SELECT DATABASE()")).scalar() or ""
eng.dispose()
except DBConfigError:
raise
except Exception as e:
raise DBConfigError(
"连接数据库失败: {}\n 目标: {}\n 请检查 .env 的 DB_HOST/DB_PORT/"
"DB_USER/DB_PASSWORD 与本机到该主机的网络。/".format(e, describe_target(uri)))
if not version.startswith(_REQUIRED_MYSQL_VERSION):
_log.warning("MySQL 版本为 %s,本项目按 %s.x 验证(更高版本通常兼容,"
"但请留意差异)", version, _REQUIRED_MYSQL_VERSION)
expected = _EXPECTED_DB_NAME[DEPLOY_ENV]
if db_name != expected and not DB_ALLOW_ENV_MISMATCH:
raise DBConfigError(
"环境与库名不匹配,已拒绝启动:\n"
" .env 声明 DEPLOY_ENV={} → 期望库名 {}\n"
" 实际连接的库 → {}\n"
" 目标: {}\n"
"如确认无误(例如临时指向别的库),可在 .env 加 DB_ALLOW_ENV_MISMATCH=1。"
.format(DEPLOY_ENV, expected, db_name, describe_target(uri)))
return {"backend": "mysql", "version": version, "database": db_name,
"target": describe_target(uri)}
# ================== 库标签校验(需要 app_meta 表,init_db 之后调用)==================
def verify_deployment_label():
"""核对 app_meta 里登记的库环境,与 .env 声明的环境是否一致。
首次见到某个库时会自动"认领"(写入标签)——生产库的标签由迁移脚本显式写入,
所以正常情况下不会走到自动认领分支。
"""
from core.models import db
# SQLite(回退模式)也照样打标签:这样"从 dev 的 SQLite 导出的备份"在
# 预览里就能标出来源环境,跨环境导入的防呆才有依据。
recorded = meta_get(_K_ENV)
if recorded is None:
meta_set(_K_ENV, DEPLOY_ENV)
meta_set(_K_ID, uuid.uuid4().hex)
meta_set(_K_CLAIMED, _now())
_log.warning("首次见到该库,已登记为 %s 环境(部署标识 %s)",
DEPLOY_ENV, meta_get(_K_ID))
return
if recorded != DEPLOY_ENV and not DB_ALLOW_ENV_MISMATCH:
raise DBConfigError(
"目标库登记的环境与 .env 声明不符,已拒绝启动:\n"
" .env 声明 → {}\n"
" 库中登记({})→ {}\n"
" 目标: {}\n"
"这通常意味着你正把「{} 的配置」连到「{} 的库」上。\n"
"如确需这样操作,请在 .env 加 DB_ALLOW_ENV_MISMATCH=1。"
.format(DEPLOY_ENV, meta_get(_K_CLAIMED) or "认领时间未知", recorded,
describe_target(), DEPLOY_ENV, recorded))
elif recorded != DEPLOY_ENV:
_log.warning("库环境标签不符已按 DB_ALLOW_ENV_MISMATCH=1 放行:声明 %s / 库登记 %s",
DEPLOY_ENV, recorded)
# ================== 启动横幅 ==================
def print_banner():
"""每次启动打印一次「我现在连的是哪个库」。prod 用 WARNING 级。"""
from core.models import db
eng = db.engine
uri = eng.url
lines = []
if is_sqlite(uri):
lines.append(" 数据库 : SQLite 回退模式 <<< 非正式用法 >>>")
lines.append(" 目标 : " + describe_target())
else:
try:
with eng.connect() as conn:
version = conn.execute(text("SELECT VERSION()")).scalar() or ""
db_name = conn.execute(text("SELECT DATABASE()")).scalar() or ""
coll = conn.execute(text(
"SELECT @@collation_database")).scalar() or ""
n_tables = conn.execute(text(
"SELECT COUNT(*) FROM information_schema.tables "
"WHERE table_schema = DATABASE()")).scalar() or 0
counts = []
for t in ("device", "task_job", "device_group", "agent_conversation"):
try:
n = db.session.execute(
text("SELECT COUNT(*) FROM {}".format(t))).scalar()
counts.append("{}={}".format(t, n))
except Exception:
db.session.rollback()
lines.append(" MySQL : {} 库 {} ({})".format(version, db_name, coll))
lines.append(" 目标 : " + describe_target())
lines.append(" 表 : {} 张".format(n_tables))
lines.append(" 数据量 : " + " ".join(counts))
except Exception as e:
lines.append(" 连接信息采集失败: {}".format(e))
lines.append(" 库标签 : {}({} 认领)".format(
meta_get(_K_ENV) or "未登记", meta_get(_K_CLAIMED) or "—"))
tag = "生产库 PROD" if DEPLOY_ENV == "prod" else "开发库 DEV"
bar = "=" * 64
head = ("{} 数据库已连接 [ {} ]{}".format(
bar, tag, " <<< 这是正式数据,操作前想清楚 >>>" if DEPLOY_ENV == "prod" else ""))
body = "\n".join(lines)
text_block = "\n{}\n{}\n{}".format(head, body, bar)
if DEPLOY_ENV == "prod":
_log.warning(text_block)
else:
_log.info(text_block)
def env_badge():
"""给页面顶部徽标用的环境信息(不含密码)。
防混库的第 4 层:光靠启动日志不够——用户点开任何页面都该一眼看出
自己正在操作哪个库。生产用红底。
"""
try:
from core.models import db
backend = "sqlite" if is_sqlite(db.engine.url) else "mysql"
except Exception:
backend = "?"
try:
target = describe_target()
except Exception:
target = DB_HOST or "(未配置)"
return {"env": DEPLOY_ENV, "is_prod": DEPLOY_ENV == "prod",
"backend": backend, "target": target}
def _now():
import time
return time.strftime("%Y-%m-%d %H:%M:%S")
+5 -6
View File
@@ -61,9 +61,10 @@ def _fmt(ts=None):
def get_settings():
"""读发现配置(app_meta,缺键用 config 默认值补齐)。"""
with _ctx():
from core.db_config import meta_get
def _get(key, default):
v = db.session.execute(
db.text("SELECT value FROM app_meta WHERE key=:k"), {"k": key}).scalar()
v = meta_get(key)
return v if v is not None else default
try:
subnets = json.loads(_get(_K_SUBNETS, "[]")) or DISCOVERY_SUBNETS
@@ -100,10 +101,8 @@ def save_settings(enabled=None, subnets=None, interval=None, port=None, auto_cla
except (TypeError, ValueError):
return False, "参数不合法"
with _ctx():
def _put(key, value):
db.session.execute(
db.text("INSERT OR REPLACE INTO app_meta(key,value) VALUES(:k,:v)"),
{"k": key, "v": str(value)})
from core.db_config import meta_set
_put = meta_set # 方言中立 upsert(app_meta.key 在 MySQL 里是保留字)
if enabled is not None:
_put(_K_ENABLED, "1" if enabled else "0")
if subnets is not None:
+239 -54
View File
@@ -17,6 +17,7 @@
import os
import json
import hashlib
import sqlite3
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
@@ -31,12 +32,33 @@ _log = get_logger("core.models")
db = SQLAlchemy()
def _long_text():
"""长文本列:SQLite 用 TEXT,MySQL 用 MEDIUMTEXT。
裸 TEXT 在 MySQL 只有 64KB(而且是"字节"),AI 会话消息、任务参数这类
JSON 文本很容易超;超了在严格模式下直接报错(不是截断)。
"""
from sqlalchemy.dialects.mysql import MEDIUMTEXT
return db.Text().with_variant(MEDIUMTEXT(), "mysql")
def _DOUBLE():
"""REAL/DOUBLE:MySQL 的 FLOAT 是单精度,评分这类值会出现 7.8000001 这种尾巴。"""
from sqlalchemy.dialects.mysql import DOUBLE
return DOUBLE()
@event.listens_for(Engine, "connect")
def _sqlite_pragma(dbapi_connection, connection_record):
"""SQLite 并发写优化:WAL 模式 + 忙等待超时 + 降同步级别。
多 worker 后台线程同时写库(任务参数/分组)时,避免 database is locked。
注意:这个监听器挂在 Engine 基类上,对**所有方言**的连接都会触发;
MySQL 下执行 PRAGMA 会直接报语法错误导致连接失败,所以必须先判类型。
"""
if not isinstance(dbapi_connection, sqlite3.Connection):
return
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=5000")
@@ -261,35 +283,108 @@ class PendingDevice(db.Model):
"fingerprint": self.fingerprint or ""}
# 版本化 schema 迁移:新增结构变更时在此追加 (版本号, 说明, SQL)
# 版本号单调递增,只执行比当前 schema_version 新的迁移。
class AppMeta(db.Model):
"""全局 KV 配置(AI 配置、设备发现参数、schema_version、库环境标签…)。
⚠️ 列名沿用历史的 `key`/`value`:**`key` 在 MySQL 里是保留字**,
因此不要直接拼裸 SQL 读写本表,统一走 `core/db_config.meta_get/meta_set`
(方言中立、自动按方言加引号)。
ORM 属性名用 `k`:`key` 在 SQLAlchemy 声明式 API 里是保留名,不能直接当属性。
"""
__tablename__ = "app_meta"
k = db.Column("key", db.String(64), primary_key=True)
value = db.Column(db.Text)
def __repr__(self):
return f"<AppMeta {self.k}>"
class AgentExperience(db.Model):
"""AI 控制台经验记忆:任务成功后的操作配方,下次相似任务检索注入。
recipe/tool_seq 存 JSON 文本;hits 是被召回引用的次数(巡检按它判重要性)。
"""
__tablename__ = "agent_experience"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
task_prompt = db.Column(_long_text(), default="")
recipe = db.Column(_long_text(), default="")
tool_seq = db.Column(_long_text(), default="")
hits = db.Column(db.Integer, default=0)
created_at = db.Column(db.String(20), default="")
def __repr__(self):
return f"<AgentExperience {self.id}>"
class ExperienceAudit(db.Model):
"""经验巡检记录(AI 质检):疑似有问题的经验标 pending,删除只走人工确认。"""
__tablename__ = "experience_audit"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
exp_id = db.Column(db.Integer, nullable=False) # 对应的 agent_experience.id
verdict = db.Column(db.String(20), default="") # keep / delete
score = db.Column(db.Float().with_variant(_DOUBLE(), "mysql"), default=0)
reason = db.Column(_long_text(), default="")
hits = db.Column(db.Integer, default=0) # 巡检时的引用次数
action = db.Column(db.String(20), default="pending") # pending/kept/deleted
audited_at = db.Column(db.String(20), default="")
class AgentAction(db.Model):
"""动作库:带语义名的可复用动作单元(含元素定位、不含坐标)。
与任务级配方(agent_experience)互补;执行前按 name/别名/App 召回注入。
"""
__tablename__ = "agent_action"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String(120), nullable=False)
app = db.Column(db.String(80), default="")
aliases = db.Column(_long_text(), default="[]")
params = db.Column(_long_text(), default="[]")
steps = db.Column(_long_text(), nullable=False)
preconditions = db.Column(_long_text(), default="")
hits = db.Column(db.Integer, default=0)
source_prompt = db.Column(_long_text(), default="")
created_at = db.Column(db.String(20), default="")
updated_at = db.Column(db.String(20), default="")
class AgentConversation(db.Model):
"""AI 控制台会话:整个消息序列以 JSON 存在一行里(单会话几十 KB,够用)。"""
__tablename__ = "agent_conversation"
id = db.Column(db.String(20), primary_key=True)
title = db.Column(db.String(100), default="")
messages = db.Column(_long_text(), default="[]")
created_at = db.Column(db.String(20), default="")
updated_at = db.Column(db.String(20), default="")
# 版本化 schema 迁移账本 (版本号, 说明, 数据回填 SQL 或 None)
#
# 注意:**建表与补列不再由这张表驱动**——改由 db.create_all() + _sync_columns()
# 按模型定义自动完成(SQLite / MySQL 两种方言都正确)。这里只保留:
# - 版本号(写进 app_meta.schema_version,备份/恢复时用来判断新旧)
# - 未来可能需要的数据回填语句(纯数据操作,与方言无关)
# 历史条目(v1~v6 建表/加列)保留在账本里以便追溯,SQL 位置一律为 None。
SCHEMA_MIGRATIONS = [
(1, "用户权限位:user 表新增 perms 列(JSON 数组,默认空=无业务权限,管理员不受限)",
"ALTER TABLE user ADD COLUMN perms TEXT DEFAULT '[]'"),
(2, "设备池:device 表(本地设备清单,替代 STF 池)",
"CREATE TABLE IF NOT EXISTS device ("
"serial VARCHAR(120) PRIMARY KEY,"
"name VARCHAR(80) DEFAULT '',"
"enabled BOOLEAN DEFAULT 1,"
"note TEXT DEFAULT '',"
"created_at VARCHAR(20) DEFAULT '')"),
(3, "设备池:device 表新增 model 列(型号,在线时自动采集)",
"ALTER TABLE device ADD COLUMN model TEXT DEFAULT ''"),
(4, "自动发现:pending_device 待连接池表(扫描发现的设备,用户确认后才入正式池)",
"CREATE TABLE IF NOT EXISTS pending_device ("
"serial VARCHAR(120) PRIMARY KEY,"
"source VARCHAR(20) DEFAULT '',"
"first_seen VARCHAR(20) DEFAULT '',"
"last_seen VARCHAR(20) DEFAULT '')"),
(5, "设备池:device 表新增 fingerprint 列(设备指纹 ro.serialno,换 IP 后认领回原记录)",
"ALTER TABLE device ADD COLUMN fingerprint VARCHAR(120) DEFAULT ''"),
(6, "自动发现:pending_device 表新增 fingerprint 列(扫描时读取,用于提示是已有设备换了 IP)",
"ALTER TABLE pending_device ADD COLUMN fingerprint VARCHAR(120) DEFAULT ''"),
(1, "用户权限位:user 表新增 perms 列(JSON 数组,默认空=无业务权限,管理员不受限)", None),
(2, "设备池:device 表(本地设备清单,替代 STF 池)", None),
(3, "设备池:device 表新增 model 列(型号,在线时自动采集)", None),
(4, "自动发现:pending_device 待连接池表(扫描发现的设备,用户确认后才入正式池)", None),
(5, "设备池:device 表新增 fingerprint 列(设备指纹 ro.serialno,换 IP 后认领回原记录)", None),
(6, "自动发现:pending_device 表新增 fingerprint 列(扫描时读取,用于提示是已有设备换了 IP)", None),
]
# 唯一索引(部分索引:空值不参与唯一约束,兼容历史未命名/未采指纹的老数据)
# 当前 schema 版本(备份/恢复用它判断新旧,也写进 app_meta.schema_version)
CURRENT_SCHEMA_VERSION = max(v for v, _, _ in SCHEMA_MIGRATIONS)
# 唯一索引(语义 = 部分索引:空值不参与唯一约束,兼容历史未命名/未采指纹的老数据)
# 名称唯一 = 设备的人可读标识;指纹唯一 = 一台物理设备在池中只能有一条记录
_UNIQUE_INDEXES = (
#
# SQLite 支持带 WHERE 的部分索引,直接写;MySQL 5.7 不支持过滤索引,
# 改用「虚拟生成列 + 唯一索引」复刻同一语义(唯一索引允许多个 NULL)——
# 见 _ensure_unique_indexes() 的 MySQL 分支。
_UNIQUE_INDEXES_SQLITE = (
("ux_device_name", "CREATE UNIQUE INDEX IF NOT EXISTS ux_device_name "
"ON device(name) WHERE name IS NOT NULL AND name <> ''"),
("ux_device_fingerprint", "CREATE UNIQUE INDEX IF NOT EXISTS ux_device_fingerprint "
@@ -297,66 +392,118 @@ _UNIQUE_INDEXES = (
"AND fingerprint <> ''"),
)
# MySQL:为空值生成 NULL 的伴随列,唯一索引建在它上面
_MYSQL_UQ_COLUMNS = (
# (表, 源列, 生成列名, 生成列类型)
("device", "name", "name_uq", "VARCHAR(80)"),
("device", "fingerprint", "fingerprint_uq", "VARCHAR(120)"),
)
_MYSQL_UNIQUE_INDEXES = (
("ux_device_name", "device", "name_uq"),
("ux_device_fingerprint", "device", "fingerprint_uq"),
)
def init_db(app):
"""在 Flask app context 里初始化数据库 + 创建默认管理员。
web_server 启动时调用。自动迁移旧 groups.json/jobs.json 到 SQLite。
web_server 启动时调用。自动迁移旧 groups.json/jobs.json 到数据库。
顺序:建表(create_all)→ 补列(_sync_columns)→ 版本账本 → 唯一索引 → 管理员/旧 JSON。
建表与补列都以模型定义为准,SQLite / MySQL 两种方言自动匹配,不再手写 DDL。
"""
db.init_app(app)
with app.app_context():
db.create_all()
_sync_columns()
_migrate_schema()
_ensure_unique_indexes()
_ensure_default_admin()
_migrate_old_json()
def _migrate_schema():
"""按 SCHEMA_MIGRATIONS 顺序执行版本化迁移,记录当前 schema_version。
def _sync_columns():
"""按模型定义补齐"实表缺的列"(幂等)。
create_all 只负责首次建表;结构变更必须走迁移,避免改了模型后老库对不上。
取代原来靠 "duplicate column name" 报错文本判断的老写法:那是 SQLite 时代的
容错路径,换 MySQL 后报错文本/错误码都不同,靠字符串匹配太脆。
现在直接读数据库元数据(sqlalchemy.inspect)比对,缺什么补什么。
"""
from sqlalchemy import inspect
from sqlalchemy.schema import CreateColumn
try:
db.session.execute(text(
"CREATE TABLE IF NOT EXISTS app_meta (key TEXT PRIMARY KEY, value TEXT)"))
insp = inspect(db.engine)
existing = set(insp.get_table_names())
except Exception as e:
_log.error(f"读取表结构失败,跳过补列: {e}")
return
added = []
for table in db.metadata.sorted_tables:
if table.name not in existing:
continue # 缺整表交给 create_all
try:
have = {c["name"] for c in insp.get_columns(table.name)}
except Exception as e:
_log.warning(f"读取 {table.name} 列失败: {e}")
continue
for col in table.columns:
if col.name in have:
continue
try:
ddl = CreateColumn(col).compile(dialect=db.engine.dialect)
db.session.execute(
text("ALTER TABLE {} ADD COLUMN {}".format(table.name, ddl)))
db.session.commit()
cur = db.session.execute(
text("SELECT value FROM app_meta WHERE key='schema_version'")).scalar()
current = int(cur) if cur else 0
for version, desc, sql in SCHEMA_MIGRATIONS:
added.append(f"{table.name}.{col.name}")
except Exception as e:
db.session.rollback()
_log.warning(f"补列失败 {table.name}.{col.name}: {e}")
if added:
_log.info("schema 补列: " + ", ".join(added))
def _migrate_schema():
"""维护 schema_version 账本 + 执行数据回填类迁移。
建表/加列已由 create_all + _sync_columns 按模型自动完成,本函数不再写 DDL;
SCHEMA_MIGRATIONS 里带 SQL 的条目只允许是方言无关的数据操作。
"""
from core import db_config
try:
try:
current = int(db_config.meta_get("schema_version") or 0)
except Exception:
current = 0
for version, desc, backfill_sql in SCHEMA_MIGRATIONS:
if version <= current:
continue
if sql:
if backfill_sql:
try:
db.session.execute(text(sql))
except Exception as e:
# 幂等兜底:目标结构已存在也视为该迁移生效。典型场景——
# create_all 已按当前模型把列/表直接建好(如 perms、device.model),
# 而 schema_version 又因历史中断没记录,导致每次启动重复 ALTER 报错。
# duplicate column name 说明列已存在=迁移目标已达成:回滚本次语句后
# 仍记录版本号,一次启动即自愈;其它异常才中止本批迁移。
if "duplicate column name" not in str(e).lower():
raise
db.session.rollback()
_log.info(f"schema 迁移 {version} 目标已存在,跳过: {desc}")
db.session.execute(
text("INSERT OR REPLACE INTO app_meta(key,value) VALUES('schema_version',:v)"),
{"v": str(version)})
db.session.execute(text(backfill_sql))
db.session.commit()
_log.info(f"schema 迁移到版本 {version}: {desc}")
_ensure_unique_indexes()
except Exception as e:
db.session.rollback()
_log.error(f"迁移 v{version} 数据回填失败: {e}")
_log.info(f"schema 迁移到版本 {version}(结构由模型自动同步): {desc}")
if current < CURRENT_SCHEMA_VERSION:
db_config.meta_set("schema_version", str(CURRENT_SCHEMA_VERSION))
except Exception as e:
_log.error(f"schema 迁移失败(不阻塞启动): {e}")
def _ensure_unique_indexes():
"""建设备池的唯一索引(幂等)。
"""建"空值不参与唯一约束"的设备名/指纹唯一索引(幂等,方言分叉)。
单独抽出来是因为索引不属于某个版本迁移:老库升级后也要补建。
历史数据若存在重复(名称/指纹撞车),建索引会失败——只告警不回滚、
不阻塞启动,由管理页提示用户改名(唯一约束从此刻起对新数据生效)。
"""
for name, sql in _UNIQUE_INDEXES:
if db.engine.dialect.name == "mysql":
_ensure_unique_indexes_mysql()
return
for name, sql in _UNIQUE_INDEXES_SQLITE:
try:
db.session.execute(text(sql))
db.session.commit()
@@ -365,6 +512,44 @@ def _ensure_unique_indexes():
_log.warning(f"唯一索引 {name} 创建失败(历史数据可能有重复): {e}")
def _ensure_unique_indexes_mysql():
"""MySQL 5.7 没有过滤索引,用「虚拟生成列 + 唯一索引」复刻部分索引语义。
生成列把空值映射成 NULL,而唯一索引允许多个 NULL —— 正好等于
「空值不参与唯一约束」。生成列不进 ORM 模型(进了 create_all 会尝试写入它
并报 Error 3105),所以只能在这里用 DDL 补。
"""
from sqlalchemy import inspect
coll = "utf8mb4_bin" # 与库默认一致:逐码点比较,等价 SQLite 的大小写敏感
for table, col, gen, typ in _MYSQL_UQ_COLUMNS:
try:
have = {c["name"] for c in inspect(db.engine).get_columns(table)}
if gen in have:
continue
db.session.execute(text(
"ALTER TABLE {t} ADD COLUMN {g} {ty} COLLATE {coll} "
"GENERATED ALWAYS AS (IF({c} IS NULL OR {c} = '', NULL, {c})) VIRTUAL"
.format(t=table, g=gen, ty=typ, coll=coll, c=col)))
db.session.commit()
_log.info(f"已建生成列 {table}.{gen}(部分唯一索引的 MySQL 替代)")
except Exception as e:
db.session.rollback()
_log.warning(f"生成列 {table}.{gen} 创建失败: {e}")
for name, table, gen in _MYSQL_UNIQUE_INDEXES:
try:
have = {i["name"] for i in inspect(db.engine).get_indexes(table)}
if name in have:
continue
db.session.execute(
text("CREATE UNIQUE INDEX {} ON {}({})".format(name, table, gen)))
db.session.commit()
_log.info(f"已建唯一索引 {name}")
except Exception as e:
db.session.rollback()
_log.warning(f"唯一索引 {name} 创建失败(历史数据可能有重复): {e}")
def _ensure_default_admin():
"""首次启动创建默认管理员 admin/admin123。"""
if not User.query.filter_by(username="admin").first():
+278 -102
View File
@@ -1,18 +1,25 @@
"""系统数据备份导出/导入(数据库 + APK 文件)。
导出:用 sqlite3 在线备份 API 对 data/users.db 做一致性快照 → zip
(users.db + manifest.json + 可选 apks/*.apk)。
导入:上传(zip/db) → 暂存校验预览 → 应用(先把当前库快照到
BACKUP_DIR/pre_restore_*.db,再把暂存库移到 RESTORE_PENDING_DIR)→
下次启动 consume_pending_restore() 换位生效。
**归档格式:zip(`users.db` + `manifest.json` + 可选 `apks/*.apk`)。**
SQLite 在这里的角色是**交换格式**,不是运行时数据库——这在迁到 MySQL 之后依然成立,
带来三个好处:
1. 前端三个接口的请求/响应结构一行都不用改
2. 校验逻辑(完整性 / 必需表 / schema 版本 / 未登记表反向自检)可以整体沿用
3. 2026-09-13 之前导出的老备份继续可导入;也不依赖任何外部二进制
(`sqlite3` 是 Python 标准库,python:slim 容器里现成)
为什么必须重启生效:web_server 单进程内 SQLAlchemy engine + 多个常驻后台线程
(TaskManager 内存态 / 设备发现 / Agent 等)持有 users.db(WAL);Windows 下
不能直接替换正被打开的文件,且 TaskManager 启动时才把 groups/jobs 读入内存。
故导入只落一个「待生效恢复任务」,由 web_server.py 在 init_db 之前消费。
导出:把**当前库**(MySQL 或 SQLite)整库读出来,写进一个临时 SQLite 文件 → zip。
MySQL 下把这条连接提到 REPEATABLE READ,保证所有表读的是同一时刻。
导入:上传(zip/db) → 暂存校验预览 → 应用(先自动导出一份当前库到
BACKUP_DIR/pre_restore_*.zip 作安全网,再把暂存归档移到 RESTORE_PENDING_DIR)
→ 下次启动 consume_pending_restore() 在**单个事务内**整库替换生效。
安全红线:全程只读现有库 / 新增或移动自己目录下的文件;绝不 kill-server、
绝不 disconnect(与全项目一致)。
为什么仍然要重启生效:TaskManager / device_pool 把任务与设备**缓存在内存**里,
整库替换后内存副本全部失效。(SQLite 时代还有"Windows 无法替换被持有的文件"这层,
改用 MySQL 之后只剩内存态这一层。)
安全红线:全程只读现有库 / 只写自己目录下的文件;替换数据用单事务,失败回滚,
绝不留下半新半旧的库;绝不 kill-server、绝不 disconnect(与全项目一致)。
"""
import json
import os
@@ -23,29 +30,24 @@ import uuid
import zipfile
from urllib.parse import quote
from sqlalchemy import create_engine, inspect, select, text
from config import (DATA_DIR, APK_DIR, BACKUP_DIR,
RESTORE_STAGING_DIR, RESTORE_PENDING_DIR)
from core.logger import get_logger
from core.models import SCHEMA_MIGRATIONS
from core.models import CURRENT_SCHEMA_VERSION, db
_log = get_logger("core.backup")
# 当前代码期望的 schema 版本(来自版本化迁移表)
CURRENT_SCHEMA_VERSION = max(v[0] for v in SCHEMA_MIGRATIONS) if SCHEMA_MIGRATIONS else 0
DB_FILE = os.path.join(DATA_DIR, "users.db")
# 判定「本平台备份库」的必需表(缺任何一张即拒绝导入)
REQUIRED_TABLES = ("app_meta", "user", "task_job", "device_group")
# 预览/清单里展示行数的业务表(存在才统计)
# ⚠️ 红线:**新增任何持久化表,必须同步登记到这里**——备份覆盖清单以此为准,
# 漏登记的表在导出清单/预览里不可见(2026-09-10 动作库 agent_action 即因此
# 被误判为"没有备份":数据其实在 users.db 快照里,只是清单没列)。
SUMMARY_TABLES = (
"app_meta", "user", "device_group", "task_job", "custom_action",
"apk_file", "device", "pending_device",
"agent_conversation", "agent_experience", "experience_audit", "agent_action",
)
# 备份覆盖清单:**由模型元数据派生**,不再手工维护。
# 这样「新增持久化表必须登记进备份清单」这条红线从"靠人记"变成结构上不可能漏
# (2026-09-10 动作库 agent_action 就漏过:数据其实在快照里,只是清单没列,
# 导出预览里看不到那一行 → 被误判成"没备份")。
SUMMARY_TABLES = tuple(sorted(t.name for t in db.metadata.tables.values()))
TABLE_LABELS = {
"app_meta": "系统配置(app_meta)",
"user": "用户", "device_group": "设备分组", "task_job": "任务计划",
@@ -66,9 +68,19 @@ def _ts():
return time.strftime("%Y%m%d_%H%M%S")
def _sqlite_uri(path):
"""把路径转成 sqlite URI(兼容含空格/中文/反斜杠的 Windows 路径)。"""
return "file:{}".format(quote(os.path.abspath(path).replace("\\", "/"), safe="/:"))
def _readonly_uri(path):
"""把文件路径转成 sqlite 只读 URI(兼容含空格/中文/反斜杠的 Windows 路径)。"""
return "file:{}?mode=ro".format(quote(os.path.abspath(path).replace("\\", "/"), safe="/:"))
"""sqlite 只读 URI。"""
return _sqlite_uri(path) + "?mode=ro"
def _sqlite_engine_url(path):
"""SQLAlchemy 用的 SQLite URL(注意与 file: URI 不同,engine 不吃那个格式)。"""
return "sqlite:///" + os.path.abspath(path).replace("\\", "/")
def _connect_readonly(path):
@@ -114,34 +126,6 @@ def _table_rows(con, table):
return None
# ================== 在线快照 ==================
def snapshot_db(dest_path, src_path=None):
"""对 src(默认当前 users.db)做 sqlite 在线一致快照到 dest_path。
用标准库备份 API(不依赖 SQLAlchemy engine,WAL 模式下读一致快照安全)。
备份完成后把目标强制落回单文件(journal_mode=DELETE + checkpoint),
避免副本以 WAL 模式残留 -wal/-shm 或主文件缺刚提交帧。
"""
src_path = src_path or DB_FILE
if not os.path.exists(src_path):
raise BackupError(f"数据库不存在: {src_path}")
con = _connect_readonly(src_path)
out = sqlite3.connect(dest_path)
try:
con.backup(out)
try:
out.execute("PRAGMA journal_mode=DELETE") # checkpoint 并转回 DELETE
except sqlite3.Error:
pass
finally:
out.close()
con.close()
# 防御:确保没有任何残留附属文件
remove_quiet(dest_path + "-wal")
remove_quiet(dest_path + "-shm")
# ================== 导出 ==================
def _summary_info(con):
"""表行数摘要(只列出存在的表)。"""
rows = []
@@ -168,13 +152,123 @@ def _prune_old_exports(max_age=3600):
pass
def _prune_stale_staging():
"""清理超时未应用的暂存目录(TTL 后自动删除)。"""
now = time.time()
if not os.path.isdir(RESTORE_STAGING_DIR):
return
for name in os.listdir(RESTORE_STAGING_DIR):
p = os.path.join(RESTORE_STAGING_DIR, name)
try:
if os.path.isdir(p) and now - os.path.getmtime(p) > _STAGE_TTL:
remove_quiet(p)
except OSError:
pass
def _current_env_label():
"""当前库登记的环境标签(dev/prod)与库唯一标识(未登记则空)。"""
from core import db_config
try:
return db_config.meta_get("deployment_env") or "", db_config.meta_get("deployment_id") or ""
except Exception:
return "", ""
# ================== 导出:当前库 → SQLite 归档 ==================
def dump_to_sqlite(dest_path):
"""把当前数据库整库导出成一个 SQLite 文件(备份归档格式)。
一致性:MySQL 下把这条连接提到 REPEATABLE READ,让所有表读同一个时间点
(应用全局是 READ COMMITTED,事务提交后自增/被改的行会"漂移")。
SQLite 下不需要——单文件库天然一致。
"""
_ensure_dir(os.path.dirname(os.path.abspath(dest_path)))
remove_quiet(dest_path)
remove_quiet(dest_path + "-wal")
remove_quiet(dest_path + "-shm")
out_eng = create_engine(_sqlite_engine_url(dest_path))
is_mysql = db.engine.dialect.name == "mysql"
conn = db.engine.connect()
if is_mysql:
conn = conn.execution_options(isolation_level="REPEATABLE READ")
dumped = []
try:
# 归档库用同一套模型建表(mysql_* 表选项在 SQLite 下被忽略)
db.metadata.create_all(out_eng)
src_cols = {}
insp = inspect(conn)
for table in db.metadata.sorted_tables:
try:
src_cols[table.name] = {c["name"] for c in insp.get_columns(table.name)}
except Exception:
src_cols[table.name] = set()
with out_eng.begin() as out:
for table in db.metadata.sorted_tables:
have = src_cols.get(table.name) or set()
cols = [c for c in table.columns if c.name in have]
if not cols:
_log.warning(f"导出跳过 {table.name}:源库没有这张表")
continue
try:
rows = [dict(r._mapping)
for r in conn.execute(select(*cols))]
except Exception as e:
_log.error(f"导出读取 {table.name} 失败: {e}")
raise BackupError(f"导出失败:读取 {table.name} 出错({e})")
if rows:
out.execute(table.insert(), rows)
dumped.append((table.name, len(rows)))
finally:
conn.close()
out_eng.dispose()
# 归档库收尾:落回单文件 + 补"空值不参与唯一"的唯一索引。
# SQLAlchemy 建的 sqlite 连接会被 PRAGMA 监听器设成 WAL,留下 -wal/-shm;
# zip 里只打包主文件,所以必须先 checkpoint 回 DELETE 模式再删附属文件。
try:
con = sqlite3.connect(dest_path)
try:
con.execute("PRAGMA journal_mode=DELETE")
except sqlite3.Error as e:
_log.warning(f"归档库切回单文件模式失败: {e}")
for name, sql in (("ux_device_name",
"CREATE UNIQUE INDEX IF NOT EXISTS ux_device_name "
"ON device(name) WHERE name IS NOT NULL AND name <> ''"),
("ux_device_fingerprint",
"CREATE UNIQUE INDEX IF NOT EXISTS ux_device_fingerprint "
"ON device(fingerprint) WHERE fingerprint IS NOT NULL "
"AND fingerprint <> ''")):
try:
con.execute(sql)
except Exception as e:
_log.warning(f"归档库建索引 {name} 失败(数据可能有重复): {e}")
con.commit()
con.close()
except Exception as e:
_log.warning(f"归档库收尾失败(不影响导出): {e}")
remove_quiet(dest_path + "-wal")
remove_quiet(dest_path + "-shm")
_log.info("已导出到 SQLite 归档: %s(%d 张表)",
os.path.basename(dest_path), len(dumped))
return dumped
# 兼容旧名(历史代码/脚本里叫 snapshot_db)
snapshot_db = dump_to_sqlite
def create_export(include_apk=True):
"""生成导出 zip,返回 (zip_path, filename, manifest)。"""
_ensure_dir(BACKUP_DIR)
_prune_old_exports()
base = os.path.join(BACKUP_DIR, f"export_{_ts()}")
snap = base + ".snapshot.db"
snapshot_db(snap)
dump_to_sqlite(snap)
apk_meta = []
if include_apk and os.path.isdir(APK_DIR):
@@ -186,14 +280,18 @@ def create_export(include_apk=True):
except OSError:
pass
env_label, db_id = _current_env_label()
zip_path = base + ".zip"
con = _connect_readonly(snap)
try:
manifest = {
"format": "auto_control_backup",
"version": 1,
"version": 2,
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"schema_version": _read_schema_version(con),
"db_backend": db.engine.dialect.name, # mysql / sqlite
"deployment_env": env_label, # 来源环境(跨环境导入时告警)
"source_db_id": db_id, # 来源库唯一标识
"include_apk": include_apk,
"tables": _summary_info(con),
"apks": apk_meta,
@@ -244,7 +342,7 @@ def validate_backup(db_file):
if schema_version < CURRENT_SCHEMA_VERSION:
warnings.append(
f"备份 schema 较旧(v{schema_version} < 当前 v{CURRENT_SCHEMA_VERSION}),"
f"应用后首次启动会自动迁移补齐结构与新表")
f"缺失的表/列按当前结构补空,不会报错但那些数据回不来")
elif schema_version > CURRENT_SCHEMA_VERSION:
warnings.append(
f"备份 schema 较新(v{schema_version} > 当前 v{CURRENT_SCHEMA_VERSION}),"
@@ -252,14 +350,15 @@ def validate_backup(db_file):
if missing_optional:
warnings.append("备份缺少部分可选表(" + "、".join(
TABLE_LABELS.get(t, t) for t in missing_optional)
+ "),应用后启动会自动补建空表")
+ "),导入后这些表为空")
# 反向自检:备份里有"未登记"的表 → 提醒把它纳入覆盖清单(红线)
extra_tables = sorted(t for t in tables
if t not in SUMMARY_TABLES and not t.startswith("sqlite_"))
if extra_tables:
warnings.append("备份含未登记的其它表(" + "、".join(extra_tables)
+ ")——如属业务数据,请登记进 core/system_backup.py 的覆盖清单")
warnings.append("备份为全量快照:含用户口令哈希、AI 配置里的 API Key 等敏感信息,请妥善保管")
+ ")——如属业务数据,请登记进 core/models.py 的模型与 "
"core/system_backup.py 的 TABLE_LABELS")
warnings.append("备份为全量数据:含用户口令哈希、AI 配置里的 API Key 等敏感信息,请妥善保管")
return True, {
"integrity": integrity,
"schema_version": schema_version,
@@ -277,7 +376,7 @@ def validate_backup(db_file):
# ================== 导入:暂存 + 预览 ==================
def _extract_zip(zip_path, staging_dir):
"""解压备份 zip:取顶层 users.db 与可选 apks/*.apk(防路径穿越,只用 basename)。"""
"""解压备份 zip:取顶层 users.db、可选 apks/*.apk 与 manifest.json(防路径穿越)。"""
db_dest = os.path.join(staging_dir, "users.db")
apk_dest = os.path.join(staging_dir, "apks")
got_db = False
@@ -290,6 +389,10 @@ def _extract_zip(zip_path, staging_dir):
if name == "users.db" and base == "users.db":
z.extract(info, staging_dir) # 已确认顶层名字,无穿越
got_db = True
elif name == "manifest.json":
with z.open(info) as src, open(
os.path.join(staging_dir, "manifest.json"), "wb") as dst:
shutil.copyfileobj(src, dst)
elif name.startswith("apks/") and base.lower().endswith(".apk"):
_ensure_dir(apk_dest)
with z.open(info) as src, open(
@@ -300,18 +403,15 @@ def _extract_zip(zip_path, staging_dir):
return db_dest
def _prune_stale_staging():
"""清理超时未应用的暂存目录(TTL 后自动删除)。"""
now = time.time()
if not os.path.isdir(RESTORE_STAGING_DIR):
return
for name in os.listdir(RESTORE_STAGING_DIR):
p = os.path.join(RESTORE_STAGING_DIR, name)
def _read_staged_manifest(staging_dir):
p = os.path.join(staging_dir, "manifest.json")
if not os.path.exists(p):
return {}
try:
if os.path.isdir(p) and now - os.path.getmtime(p) > _STAGE_TTL:
remove_quiet(p)
except OSError:
pass
with open(p, encoding="utf-8") as f:
return json.load(f) or {}
except Exception:
return {}
def stage_upload(file_storage):
@@ -333,12 +433,25 @@ def stage_upload(file_storage):
db_file = os.path.join(staging_dir, "users.db")
os.replace(raw_path, db_file)
else:
raise BackupError("仅支持 .zip(平台导出)或 .db(sqlite 库)文件")
raise BackupError("仅支持 .zip(平台导出)或 .db(备份库文件)")
ok, info = validate_backup(db_file)
if not ok:
raise BackupError(info)
info["file_name"] = orig_name
info["file_size"] = os.path.getsize(db_file)
# 跨环境提示(防混库第 5 层):备份来自哪个环境、当前连的是哪个环境
man = _read_staged_manifest(staging_dir)
info["source_env"] = man.get("deployment_env") or ""
info["source_db_id"] = man.get("source_db_id") or ""
info["source_backend"] = man.get("db_backend") or "sqlite(旧版备份)"
cur_env, _ = _current_env_label()
info["current_env"] = cur_env
if info["source_env"] and cur_env and info["source_env"] != cur_env:
info["warnings"].append(
f"该备份来自 {info['source_env']} 环境,而当前连接的是 {cur_env} 库 —— "
f"导入会覆盖当前全部数据。确认这是你要的操作再继续(跨环境导入默认被拒绝)。")
# 暂存目录里留住 manifest,apply 时还要用它做环境校验
return token, info
except BackupError:
remove_quiet(staging_dir)
@@ -349,8 +462,8 @@ def stage_upload(file_storage):
# ================== 导入:应用(落待生效任务) ==================
def apply_restore(token):
"""校验 token → 快照当前库 → 把暂存库移到 restore_pending。
def apply_restore(token, force_env_mismatch=False):
"""校验 token → 自动备份当前库(zip 安全网)→ 把暂存归档移到 restore_pending。
返回 dict {ok, backup_name, message};失败抛 BackupError。token 形如 12 位 hex。
"""
@@ -364,25 +477,41 @@ def apply_restore(token):
if not ok:
raise BackupError(info)
# 1) 自动备份当前库(安全网,可回滚)
# 跨环境硬停:默认不许把 prod 的备份灌进 dev 库(反之亦然)
man = _read_staged_manifest(staging_dir)
src_env = man.get("deployment_env") or ""
cur_env, _ = _current_env_label()
if src_env and cur_env and src_env != cur_env and not force_env_mismatch:
raise BackupError(
f"备份来自 {src_env} 环境,当前库是 {cur_env} 环境 —— 已拒绝导入。\n"
f"如确需跨环境恢复,请在确认页勾选「允许跨环境导入」后重试。")
# 1) 自动备份当前库(安全网,可回滚;导出成 zip,能直接再导入回来)
_ensure_dir(BACKUP_DIR)
backup_name = f"pre_restore_{_ts()}.db"
snapshot_db(os.path.join(BACKUP_DIR, backup_name))
try:
zip_path, _, _ = create_export(include_apk=True)
backup_name = f"pre_restore_{_ts()}.zip"
shutil.move(zip_path, os.path.join(BACKUP_DIR, backup_name))
except Exception as e:
raise BackupError(f"导入前自动备份当前库失败,已中止导入: {e}")
# 2) 落待生效恢复任务(旧未消费任务被覆盖——上一份已无意义)
pending_dir = RESTORE_PENDING_DIR
if os.path.isdir(pending_dir):
remove_quiet(pending_dir)
_ensure_dir(pending_dir)
os.replace(db_file, os.path.join(pending_dir, "users.db"))
if os.path.isdir(RESTORE_PENDING_DIR):
remove_quiet(RESTORE_PENDING_DIR)
_ensure_dir(RESTORE_PENDING_DIR)
os.replace(db_file, os.path.join(RESTORE_PENDING_DIR, "users.db"))
for extra in ("manifest.json",):
src = os.path.join(staging_dir, extra)
if os.path.exists(src):
shutil.move(src, os.path.join(RESTORE_PENDING_DIR, extra))
apk_src = os.path.join(staging_dir, "apks")
if os.path.isdir(apk_src) and os.listdir(apk_src):
shutil.move(apk_src, os.path.join(pending_dir, "apks"))
shutil.move(apk_src, os.path.join(RESTORE_PENDING_DIR, "apks"))
remove_quiet(staging_dir)
schema_version = info.get("schema_version", 0)
_log.info(f"导入已落恢复任务: schema v{schema_version},"
f"重启 web_server 后生效;当前库已快照 {backup_name}")
f"重启 web_server 后生效;当前库已自动备份 {backup_name}")
return {
"ok": True,
"backup_name": backup_name,
@@ -390,32 +519,43 @@ def apply_restore(token):
}
# ================== 启动消费(web_server.py init_db 前调用) ==================
def consume_pending_restore():
"""把 restore_pending/users.db 换位为当前 users.db(删除旧 wal/shm,合并 apks)。
# ================== 启动消费(init_db 之后、TaskManager 之前调用)==================
def _pending_db_path():
"""待生效恢复的归档路径(兼容旧版扁平布局)。"""
p = os.path.join(RESTORE_PENDING_DIR, "users.db")
return p if os.path.exists(p) else None
必须在 SQLAlchemy engine 首次打开 users.db 之前执行(web_server 装配时调用)。
失败不阻塞启动:坏恢复文件会挪到 BACKUP_DIR/restore_failed_*/ 并继续用旧库。
def consume_pending_restore():
"""把 restore_pending/users.db 的内容在**单个事务内**整库替换进来。
与 SQLite 时代的"换文件"不同:现在是 DELETE 全表 + 分块 INSERT,全程一个事务——
任何一步失败就 rollback,当前数据保持原样(比换文件更安全)。
必须在 init_db() 之后调用(表已建好、app context 可用);失败不阻塞启动:
坏归档挪到 BACKUP_DIR/restore_failed_*/ 并继续用当前数据。
返回是否真的应用了恢复。
"""
pending_db = os.path.join(RESTORE_PENDING_DIR, "users.db")
if not os.path.exists(pending_db):
pending_db = _pending_db_path()
if not pending_db:
return False
# 消费前轻量复验(防止意外损坏文件把主库换掉)
# 消费前轻量复验(防止意外损坏的文件把主库换掉)
ok, msg = validate_backup(pending_db)
if not ok:
fail_dir = os.path.join(BACKUP_DIR, f"restore_failed_{_ts()}")
_ensure_dir(fail_dir)
os.replace(pending_db, os.path.join(fail_dir, "users.db"))
shutil.move(pending_db, os.path.join(fail_dir, "users.db"))
remove_quiet(RESTORE_PENDING_DIR)
_log.error(f"恢复任务校验失败,已搁置到 {fail_dir},继续使用当前库: {msg}")
return False
_ensure_dir(DATA_DIR)
os.replace(pending_db, DB_FILE)
for ext in ("-wal", "-shm"):
p = DB_FILE + ext
if os.path.exists(p):
remove_quiet(p)
try:
applied = _apply_archive_to_db(pending_db)
except Exception as e:
_log.error(f"应用备份恢复失败,已回滚(当前数据未改动): {e},"
f"归档保留在 {RESTORE_PENDING_DIR} 供排查")
return False
# 合并 apks(覆盖同名)
apk_src = os.path.join(RESTORE_PENDING_DIR, "apks")
if os.path.isdir(apk_src):
@@ -427,6 +567,42 @@ def consume_pending_restore():
os.remove(dst)
shutil.move(src, dst)
remove_quiet(RESTORE_PENDING_DIR)
schema_version = msg.get("schema_version", 0) if isinstance(msg, dict) else "?"
_log.info(f"已应用备份恢复: users.db 替换完成(schema v{schema_version}),apks 已合并")
schema_version = msg.get("schema_version", "?") if isinstance(msg, dict) else "?"
_log.info(f"已应用备份恢复(单事务替换): {applied},schema v{schema_version},apks 已合并")
return True
def _apply_archive_to_db(db_file, chunk=500):
"""把 SQLite 归档里的数据整库写进当前数据库,单事务、失败回滚。
用 DELETE 而不是 TRUNCATE:TRUNCATE 是 DDL,会隐式提交,破坏原子性。
"""
con = _connect_readonly(db_file)
arch_tables = _list_tables(con)
stats = []
try:
# 单事务:MySQL/SQLite 都支持事务内 DELETE + INSERT
with db.engine.begin() as dst:
for table in reversed(db.metadata.sorted_tables):
dst.execute(table.delete())
for table in db.metadata.sorted_tables:
if table.name not in arch_tables:
_log.warning(f"归档里没有 {table.name},该表导入后为空")
continue
have = {r[1] for r in con.execute(
'PRAGMA table_info("%s")' % table.name)}
cols = [c for c in table.columns if c.name in have]
if not cols:
_log.warning(f"归档表 {table.name} 没有可用列,跳过")
continue
sel = "SELECT {} FROM \"{}\"".format(
", ".join('"%s"' % c.name for c in cols), table.name)
rows = con.execute(sel).fetchall()
names = [c.name for c in cols]
for i in range(0, len(rows), chunk):
batch = [dict(zip(names, r)) for r in rows[i:i + chunk]]
dst.execute(table.insert(), batch)
stats.append(f"{table.name}={len(rows)}")
finally:
con.close()
return " ".join(stats)
+23 -11
View File
@@ -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_<ts>.db`(安全网),再把暂存库落到 `data/restore_pending/`。
`{"token":"…", "force_env_mismatch": false}` → 先自动把当前库导出成
`data/backups/pre_restore_<ts>.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
---
+6 -6
View File
@@ -75,9 +75,9 @@
| 阶段 | 位置 | 做了什么 |
|------|------|---------|
| **B** | `:28-41` | `Flask(__name__)`;会话密钥(`.env` 的 `WEB_SECRET_KEY`,缺失则随机生成并 warning);`TEMPLATES_AUTO_RELOAD=True`;`SQLALCHEMY_DATABASE_URI=sqlite:///data/users.db`;`LoginManager` + `login_view="auth.login"` |
| **C** | `:43-50` | **恢复任务消费** `consume_pending_restore()` —— 必须在 engine 首次打开 `users.db` **之前**(Windows 无法替换被持有的文件)。失败只记日志,不阻塞启动 |
| **D** | `:53-61` | `init_db(app)`(建表 → 版本化迁移 → 默认管理员 → 旧 JSON 迁移);`device_pool.init_app`(**起一次性线程**,3s 后采集型号);`device_discovery.init_app`(**起常驻扫描线程**);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)` |
| **B** | `:28-58` | `Flask(__name__)`;会话密钥(`.env` 的 `WEB_SECRET_KEY`,缺失则随机生成并 warning);`TEMPLATES_AUTO_RELOAD=True`;**数据库目标由 `core/db_config` 装配**(`.env` 的 `DEPLOY_ENV`/`DB_*` → URI + 引擎参数),配置错直接 `SystemExit(2)`;`LoginManager` + `login_view="auth.login"` |
| **C** | `:60-67` | **恢复任务消费** `consume_pending_restore()`。SQLite 时代它必须在 engine 首次打开 `users.db` **之前**(Windows 无法替换被持有的文件);改用 MySQL 后这一步的语义会变成"启动期事务替换",见 §7 |
| **D** | `:69-80` | `init_db(app)`(建表 → 补列 → 版本账本 → 唯一索引 → 默认管理员 → 旧 JSON 迁移)→ **库环境标签校验 + 启动横幅**(`db_config.verify_deployment_label/print_banner`,不符拒绝启动);`device_pool.init_app`(**起一次性线程**,3s 后采集型号);`device_discovery.init_app`(**起常驻扫描线程**);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)` |
### 2.3 阶段 E~G:蓝图、巡检调度器、真正启动
@@ -123,7 +123,7 @@
| `_scan_lock` / `_stop_event` | `core/device_discovery.py` | 定时/手动扫描互斥 | `acquire(blocking=False)` |
| `_status_cache_lock` | `core/task_manager.py` | 状态缓存(TTL 5s) | 避免 `/api/status` 每次都查库 + adb |
**无锁部分**:`device_pool` 与 `models` 不持显式锁,依赖"每次操作独立 app context + SQLite WAL + `busy_timeout=5000`"。
**无锁部分**:`device_pool` 与 `models` 不持显式锁,依赖"每次操作独立 app context" + 数据库自身的并发控制(MySQL 下是 InnoDB 行锁 + READ COMMITTED,回退 SQLite 时是 WAL + `busy_timeout=5000`)。
### 3.3 错峰与心跳
@@ -319,14 +319,14 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set
| 决策 | 为什么 | 代价 / 注意 |
|------|--------|------------|
| **SQLite + WAL** 而非 MySQL/PG | 单机部署、零运维;WAL 支持多线程读写 | 写并发有限;必须设 `busy_timeout`;`data/` 不入 git |
| **数据库走 MySQL,SQLite 仅作回退** | 多环境(dev 库 / 正式库)需要同一套代码指向不同库;备份/恢复也不再受"文件被占用"掣肘 | 多一套连接配置与防混库校验(`core/db_config.py`);`DB_HOST` 为空时回退 SQLite,生产禁止静默回退 |
| **threading 而非 asyncio** | uiautomator2 是同步阻塞库;每设备一线程模型直观 | 线程数随设备数增长;跨线程访问 DB 必须自推 app context |
| **状态放内存** 而非落库 | 状态每秒都变,落库纯属浪费 | 重启丢失运行中状态(进程重启 = 任务终止) |
| **蓝图按功能域拆包** | `web_server.py` 只做装配,路由就近维护 | 拆包易出现 import 遗漏 → 用 `scripts/regression_test.py` 兜底 |
| **单页应用 + 全局脚本** | 无构建步骤、无 npm 依赖,改完强刷即生效 | 11 个文件共享全局作用域,需手工维护加载顺序 |
| **任务类型注册表** | 新增任务类型不动调度器 | 删改类型要做兼容(未知类型必须显式报错,不能静默) |
| **自建设备池**(不用 STF) | 规避共享 adb transport 的历史坑,清单可控 | 需自己处理在线状态、型号、自动发现 |
| **备份"重启生效"** | Windows 无法替换被持有的 db;调度器/worker 持有内存态 | 导入后必须重启(在 `init_db` 之前消费恢复任务) |
| **备份"重启生效"** | 调度器 / worker / 设备池都把任务与设备**缓存在内存**里,整库替换后内存副本全部失效 | 导入后必须重启(重启时消费恢复任务)。SQLite 时代还有"Windows 无法替换被持有的文件"这层原因,改用 MySQL 后只剩内存态这一层 |
| **uiautodev 独立子进程** | 元素抓取是重活,隔离崩溃、可单独重启 | 固定端口 20242;PID 文件防重复拉起(杀之前必须校验 cmdline,防误杀同容器进程) |
| **MCP 独立端口** | 外部 AI 用标准协议接入,不侵入 Web 会话 | MCP 端点自身无鉴权,靠网络隔离;写操作用开关门控 |
+102 -67
View File
@@ -2,7 +2,7 @@
> 适用读者:改后端 / 排数据问题的开发者与运维。
> 相关文档:[ARCHITECTURE.md](ARCHITECTURE.md)(运行时架构)、[DEPLOY.md](DEPLOY.md) §数据备份(备份覆盖红线)、[API.md](API.md)(消费这些数据的接口)。
> **表结构以 `core/models.py` 与各模块的建表 SQL 为准**,本文是它们的映射说明。
> **表结构以 `core/models.py` 的 ORM 模型为唯一准**(2026-09-13 起各模块的裸建表 SQL 已全部并入模型),本文是它们的映射说明。
---
@@ -10,32 +10,49 @@
| 项 | 值 |
|----|----|
| 引擎 | SQLite(单文件 `data/users.db`) |
| driver | Flask-SQLAlchemy(SQLAlchemy 2.x) |
| 连接 PRAGMA | `journal_mode=WAL`、`busy_timeout=5000`、`synchronous=NORMAL` |
| 建表方式 | ① 模型表:`db.create_all()`;② 版本化迁移 `SCHEMA_MIGRATIONS`;③ 幂等原生 `CREATE TABLE IF NOT EXISTS` |
| 主库文件 | `data/users.db`(运行时另有 `-wal` / `-shm`) |
| 当前 schema 版本 | `app_meta.schema_version = 4` |
| 引擎 | MySQL(正式用法;目标由 `.env` 的 `DEPLOY_ENV` + `DB_*` 决定)/SQLite(回退模式,`DB_HOST` 为空时) |
| driver | Flask-SQLAlchemy(SQLAlchemy 2.x)+ PyMySQL |
| 连接参数 | 见 `core/db_config.py`:utf8mb4、排序规则 `utf8mb4_bin`、`pool_pre_ping`、`pool_recycle=1800`、隔离级别 READ COMMITTED、`sql_mode=STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION` |
| SQLite 回退时的 PRAGMA | `journal_mode=WAL`、`busy_timeout=5000`、`synchronous=NORMAL`(监听器按连接类型守卫,MySQL 连接不会执行) |
| 建表方式 | **唯一真相是模型**:`db.create_all()`(建缺表)+ `_sync_columns()`(补缺列);`SCHEMA_MIGRATIONS` 只作版本账本与数据回填 |
| 库位置 | MySQL:由 `DB_HOST/DB_NAME` 指定;SQLite:`data/users.db` |
| 当前 schema 版本 | `app_meta.schema_version = 6` |
**表清单(12 张业务表 + 1 张 SQLite 内部表)**
**环境与库的绑定**(防混库,见 [DEPLOY.md](DEPLOY.md) §2.2)
| # | 表 | 来源 | 用途 |
|---|---|------|------|
| 1 | `user` | 模型 | 登录用户与权限 |
| 2 | `device_group` | 模型 | 设备分组(JSON 存 serial 列表) |
| 3 | `task_job` | 模型 | 任务计划 |
| 4 | `custom_action` | 模型 | 自定义动作(可复用步骤包) |
| 5 | `apk_file` | 模型 | APK 记录 |
| 6 | `device` | 模型 + 迁移 v2/v3 | 设备池 |
| 7 | `pending_device` | 模型 + 迁移 v4 | 待确认的发现设备 |
| 8 | `app_meta` | 原生(迁移前建) | KV 配置(schema 版本、AI 配置、发现配置) |
| 9 | `agent_conversation` | 原生(`web/agent_api.py`) | AI 控制台会话 |
| 10 | `agent_experience` | 原生(`web/agent_api.py`) | 经验库(任务级配方) |
| 11 | `experience_audit` | 原生(`web/agent_api.py`) | 经验巡检结论 |
| 12 | `agent_action` | 原生(`web/agent_api.py`) | 动作库(命名动作) |
| — | `sqlite_sequence` | SQLite 内部 | `AUTOINCREMENT` 的附带产物(备份自检时按 `sqlite_` 前缀排除) |
| `DEPLOY_ENV` | 期望库名 | 用途 |
|---|---|---|
| `dev` | `auto_control_dev` | 本地开发 |
| `prod` | `auto_control` | 正式环境(220 容器) |
> **没有外键、没有关系(relationship)、没有索引**:全部靠应用层维护一致性。分组 ↔ 设备是多对多的 **JSON 列表**(`device_group.serials`),删除设备不会级联清理分组里的 serial。
启动时校验「`.env` 声明」与「库名」「库中登记的 `app_meta.deployment_env`」三方一致,不符**拒绝启动**。
**表清单(12 张,全部是 `core/models.py` 里的 ORM 模型)**
| # | 表 | 用途 |
|---|---|------|
| 1 | `user` | 登录用户与权限 |
| 2 | `device_group` | 设备分组(JSON 存 serial 列表) |
| 3 | `task_job` | 任务计划 |
| 4 | `custom_action` | 自定义动作(可复用步骤包) |
| 5 | `apk_file` | APK 记录 |
| 6 | `device` | 设备池 |
| 7 | `pending_device` | 待确认的发现设备 |
| 8 | `app_meta` | KV 配置(schema 版本、AI 配置、发现配置、库环境标签) |
| 9 | `agent_conversation` | AI 控制台会话 |
| 10 | `agent_experience` | 经验库(任务级配方) |
| 11 | `experience_audit` | 经验巡检结论 |
| 12 | `agent_action` | 动作库(命名动作) |
> 2026-09-13 之前,`app_meta` 与 4 张 `agent_*` 表是各模块里的裸 `CREATE TABLE`
> (不进模型层)。迁 MySQL 时那批 SQL 的 `AUTOINCREMENT`/`TEXT DEFAULT ''`/`TEXT PRIMARY KEY`
> 全都建不出来,而异常被 `except: pass` 吞掉 —— 表现为「经验库/动作库静默失灵」。
> 现在全部升为模型,建表只有一条路径。
> **没有外键、没有关系(relationship)**:全部靠应用层维护一致性。分组 ↔ 设备是多对多的
> **JSON 列表**(`device_group.serials`),删除设备不会级联清理分组里的 serial。
>
> **唯一索引只有两个**(设备名 / 指纹,见 §4.2),且都是「空值不参与唯一约束」的语义。
---
@@ -140,50 +157,64 @@
| `experience_audit` | `id`(PK) · `exp_id` · `verdict`(keep/delete) · `score`(REAL) · `reason` · `hits` · `action`(pending/kept/deleted) · `audited_at` |
| `agent_action` | `id`(PK) · `name` · `app` · `aliases`(JSON) · `params`(JSON) · `steps`(JSON) · `preconditions` · `hits` · `source_prompt` · `created_at` · `updated_at` |
> ⚠️ 这四张表**不走 `SCHEMA_MIGRATIONS`,无版本管理**,由各模块首次使用时 `CREATE TABLE IF NOT EXISTS` 幂等创建;建表失败会被 `except: pass` 吞掉(后续 SQL 才会报错)。新增此类表时,务必同时登记进备份覆盖清单(§6)。
> 2026-09-13 起这四张表**已升为 ORM 模型**(见 §1 的说明),建表统一走 `db.create_all()`。
---
## 4. 迁移机制
### 4.1 版本化迁移 `SCHEMA_MIGRATIONS`
### 4.1 建表与补列(以模型为准)
| 版本 | 内容 | SQL 幂等性 |
|------|------|-----------|
| 1 | `user` 增加 `perms` | `ALTER TABLE`(非幂等,靠 duplicate column 兜底) |
| 2 | 建 `device` 表(**无 `model` 列**) | 幂等(`IF NOT EXISTS`) |
| 3 | `device` 增加 `model` | `ALTER TABLE`(同上兜底) |
| 4 | 建 `pending_device` 表 | 幂等 |
| 5 | `device` 增加 `fingerprint`(设备指纹) | `ALTER TABLE`(靠 duplicate column 兜底) |
| 6 | `pending_device` 增加 `fingerprint` | 同上 |
**模型定义是唯一真相**。启动时:
执行器 `_migrate_schema()`:
| 步骤 | 做什么 | 幂等性 |
|------|--------|--------|
| `db.create_all()` | 建**缺的表**(模型里有的都在) | 幂等 |
| `_sync_columns()` | 用 `sqlalchemy.inspect` 比对模型与实表,**补实表缺的列** | 幂等 |
| `_migrate_schema()` | 维护 `app_meta.schema_version` 账本 + 执行数据回填 | 幂等 |
| `_ensure_unique_indexes()` | 建设备名/指纹唯一索引(见 §4.2) | 幂等 |
| `_ensure_default_admin()` | 首次创建 `admin/admin123` | 幂等 |
| `_migrate_old_json()` | 旧 `groups.json`/`jobs.json` 一次性迁移 | 见 §4.3 |
1. 先 `CREATE TABLE IF NOT EXISTS app_meta(...)`(幂等)
2. 读 `app_meta.schema_version`(缺省 0)
3. 只执行 `version > current` 的迁移;**SQL 报 `duplicate column name` 时回滚该语句但仍记版本号**(自愈:`create_all` 已建列而版本未记录时不再卡住),其它异常才抛出
4. 每条迁移后 `INSERT OR REPLACE app_meta('schema_version', v)` 并提交
5. 整体包 try:失败只记 error 日志,**不阻塞启动**
> 2026-09-13 之前,补列靠 `ALTER TABLE` 报错文本里有没有 `duplicate column name` 来判断
> 「列已存在」——那是 SQLite 时代的写法,换方言(MySQL 的错误码/文本都不同)就失效了。
> 现在改为直接读数据库元数据比对,**缺什么补什么,两种方言一套代码**。
### 4.2 唯一索引(设备身份)
`_migrate_schema()` 末尾会补建两个**部分唯一索引**(幂等,与版本号无关,老库升级时也会补):
两个「空值不参与唯一约束」的唯一索引,与版本号无关,每次启动都补建:
| 索引 | 作用 |
|------|------|
| `ux_device_name` | 设备**名称唯一**(`WHERE name <> ''`,兼容历史空名) |
| `ux_device_fingerprint` | **一台物理设备在池中只有一条记录**(`WHERE fingerprint <> ''`) |
| `ux_device_name` | 设备**名称唯一**(空名不参与,兼容历史未命名设备) |
| `ux_device_fingerprint` | **一台物理设备在池中只有一条记录**(空指纹不参与) |
实现按方言分叉(`_ensure_unique_indexes()`):
- **SQLite**:直接用带 `WHERE name <> ''` 的**部分索引**
- **MySQL 5.7**:不支持过滤索引,改用「**虚拟生成列 + 唯一索引**」——
生成列把空值映射成 `NULL`(`IF(col IS NULL OR col='', NULL, col)`),
而唯一索引允许多个 `NULL`,正好等于「空值不参与唯一」。生成列名 `name_uq` /
`fingerprint_uq`,**只由 DDL 添加、不进 ORM 模型**(进了 `create_all` 会尝试写入
它并报 Error 3105)。
> 用**部分索引**而不是表级约束:历史数据可能有空名称/空指纹,`<> ''` 让它们不参与唯一性判断。
> 若历史数据里已有重复(建索引失败),只告警不回滚、不阻塞启动——约束从此刻起对新数据生效,
> 老的重复行由管理页「改名」处理。
### 4.2.1 排序规则为什么必须是 `utf8mb4_bin`
SQLite 的文本比较是**逐字节**的(大小写敏感)。MySQL 默认的 `utf8mb4_general_ci`
是大小写**不**敏感,会让 `Admin`/`admin`、`Phone1`/`phone1` 被判成重复,唯一索引和
等值查询语义全变。所以库、表、连接三处都统一用 `utf8mb4_bin`(逐码点比较,对合法
UTF-8 等价于字节序)。
唯一的语义差异:`LIKE` 在 `_bin` 下是大小写敏感的(SQLite 对 ASCII 默认不敏感)。
当前代码里没有任何 `LIKE`/`ilike` 查询,暂无影响。
### 4.3 旧 JSON 迁移(一次性)
启动时若存在 `data/groups.json` / `data/jobs.json`:对应表为空则导入,随后把文件重命名为 `<name>.json.migrated` 归档。**库非空但 JSON 仍在 → 直接归档**,防止"用户删空数据后重启又复原"。
`init_db(app)` 顺序:`db.init_app` → `create_all()` → `_migrate_schema()` → `_ensure_default_admin()`(首次创建 `admin/admin123`)→ `_migrate_old_json()`。
---
## 5. `app_meta` 键清单
@@ -200,37 +231,37 @@
| `discovery_subnets` | 扫描网段 JSON 数组 | 同上 |
| `discovery_interval` | 扫描周期秒(10-3600) | 同上 |
| `discovery_port` | adb 探测端口(1-65535) | 同上 |
| `discovery_auto_claim` | 指纹匹配时自动认领(`"1"`/`"0"`,默认关) | 同上 |
| `deployment_env` | **库环境标签**(`dev`/`prod`),启动时与 `.env` 比对 | `core/db_config.py`(首次连接)/ 迁移脚本 |
| `deployment_id` | 库唯一标识(uuid),用于识别"这份备份来自哪个库" | 同上 |
| `deployment_claimed_at` | 标签写入时间 | 同上 |
> ⚠️ `agent_api_key` 是**明文存储**,导出备份的 zip 里也含它——备份预览会固定给出"含敏感信息"告警。
> `app_meta` 的列名 `key` 在 MySQL 里是保留字,**不要直接拼裸 SQL**,统一走
> `core/db_config.meta_get / meta_set`(方言中立、自动加引号)。
---
## 6. 备份覆盖清单(红线)
`core/system_backup.py` 的 `SUMMARY_TABLES`(12 张,与库中实际业务表一一对应):
`core/system_backup.py` 的 `SUMMARY_TABLES` **由模型元数据派生**:
| 表 | 中文标签 |
|----|---------|
| `app_meta` | 系统配置(app_meta) |
| `user` | 用户 |
| `device_group` | 设备分组 |
| `task_job` | 任务计划 |
| `custom_action` | 自定义动作 |
| `apk_file` | APK 记录 |
| `device` | 设备池 |
| `pending_device` | 待连接设备 |
| `agent_conversation` | AI 会话 |
| `agent_experience` | 经验库 |
| `experience_audit` | 经验巡检 |
| `agent_action` | 动作库 |
```python
SUMMARY_TABLES = tuple(sorted(t.name for t in db.metadata.tables.values()))
```
也就是说——**新增一张 ORM 表,自动就进备份覆盖清单**,不可能再漏。
`TABLE_LABELS`(预览页的中文标签)仍是手工维护,缺标签时回退显示表名。
**双向自检**:
- **导出侧**:登记在 `SUMMARY_TABLES` 但快照里缺失 → 写入 `manifest.coverage_missing` + 日志告警
- **导入侧**:备份里出现未登记的表(排除 `sqlite_` 前缀)→ 预览告警"请登记进 `core/system_backup.py` 的覆盖清单"(字段 `extra_tables`)
- `REQUIRED_TABLES = (app_meta, user, task_job, device_group)`:缺任一直接拒绝导入
- **导入侧**:备份里出现未登记的表(排除 `sqlite_` 前缀)→ 预览告警(字段 `extra_tables`)
- `REQUIRED_TABLES = (app_meta, user, task_job, device_group)`:缺任一直接拒绝导入(这四项是"判定这是不是本平台备份"的最小集合,故意手工维护)
> **红线**:**新增任何持久化表(含原生建表的 agent 类表),必须同时登记进 `SUMMARY_TABLES` 与 `TABLE_LABELS`**,并更新 [DEPLOY.md](DEPLOY.md) §数据备份。历史教训:`agent_action` 曾漏登记,导致"动作库看起来没备份"(数据其实在快照里,只是清单没列)。
> **红线**:新增持久化表时**同时补 `TABLE_LABELS` 的中文标签**并更新 [DEPLOY.md](DEPLOY.md) §数据备份。
> 覆盖清单本身不再需要手工登记(已由 metadata 派生)。历史教训:`agent_action` 曾漏登记,
> 导致"动作库看起来没备份"(数据其实在快照里,只是清单没列)。
---
@@ -238,11 +269,15 @@
| 路径 | 内容 | 进 git |
|------|------|--------|
| `data/users.db`(+`-wal`/`-shm`) | SQLite 主库 | 否 |
| `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/<token>/` | 导入暂存(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 迁移归档 | 否 |
+81 -13
View File
@@ -63,11 +63,25 @@ pip install -r requirements.txt
```ini
WEB_SECRET_KEY=<随机 64 hex> # 不配则每次重启登录态失效
DEPLOY_ENV=prod # 声明"这是生产环境",与库名 auto_control 绑定
DB_HOST=<MySQL 主机> # 数据库目标(不配则回退 SQLite,生产会拒绝启动)
DB_USER=<账号>
DB_PASSWORD=<口令>
DB_NAME=auto_control
# USB_ADB_HOST=100.100.10.1 # USB 设备所在的部署机(默认值通常可用)
# TAILSCALE_API_KEY=<...> # 需要 Tailscale 管理功能时
# MCP_PLATFORM_PASS=<admin 的密码> # 改过 admin 密码必须同步,否则 MCP 登录失败
```
**防混库**(`DEPLOY_ENV` 与库名/库标签双向校验,配置项说明见 [DEVELOPMENT.md](DEVELOPMENT.md) §4.2
与 `.env.example` 的「数据库」段):启动时若
「`.env` 声明的环境」与「库名」或「库中登记的 `app_meta.deployment_env`」不符,**直接拒绝启动**并打印
两边分别是什么。每次启动还会打印一条横幅写明当前连的是哪个库(生产用 WARNING 级),
页面顶部也常驻一个环境徽标(生产红底 `PROD · <库>`,开发灰底 `DEV`),
**浏览器标签名也带环境前缀**(开发 `dev-设备自动化后台`,生产 `设备自动化后台`)——
同时开着两个环境时一眼分得清哪个标签是哪个。
两个逃生阀 `DB_ALLOW_ENV_MISMATCH` / `DB_ALLOW_SQLITE_FALLBACK` 默认关闭。
其余配置项与默认值见 [DEVELOPMENT.md](DEVELOPMENT.md) §配置速查。
### 2.3 启动
@@ -172,25 +186,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(明文)**,注意保管与传输。
@@ -226,9 +271,32 @@ docker restart python-app # 容器场景
### 7.2 数据库迁移
- 表结构变化由 `init_db()` 自动处理:`create_all()` 建新表 + `SCHEMA_MIGRATIONS` 补列(幂等,失败不阻塞启动)
- **表结构变化**由 `init_db()` 自动处理:`create_all()` 建缺表 + `_sync_columns()` 补缺列(幂等,失败不阻塞启动,见 [DATA_MODEL.md](DATA_MODEL.md) §4)
- 旧 `groups.json` / `jobs.json` 首次启动自动迁移并归档为 `.migrated`
- **跨版本恢复**:用「导入恢复」上传旧库 → 预览会提示 schema 版本差异 → 应用后重启(新版本会自动补迁移)
- **跨版本恢复**:用「导入恢复」上传旧库 → 预览会提示 schema 版本差异 → 应用后重启(新版本会自动补列/补空表)
#### SQLite → MySQL(一次性)
首次把平台从单文件 SQLite 换到 MySQL 时,用迁移脚本搬数据:
```bash
# 0) 先在 .env 里配好 DB_HOST/DB_USER/DB_PASSWORD/DB_NAME(或用 --target-url)
# 1) 干跑:审计源库 + 建目标 schema,不搬数据
python scripts/migrate_sqlite_to_mysql.py --sqlite data/users.db --env dev --dry-run
# 2) 正式搬(目标库已有的数据会被整表替换;单事务,失败自动回滚)
python scripts/migrate_sqlite_to_mysql.py --sqlite data/users.db --env dev
# 3) 复验(只比对不写)
python scripts/migrate_sqlite_to_mysql.py --sqlite data/users.db --env dev --mode verify
```
脚本做的事:源库完整性校验 → **列宽审计**(SQLite 不强制长度,MySQL 严格模式下超长会直接报错,
所以先拦下来)→ 建目标 schema(复用平台的 `create_all` + 补列 + 唯一索引)→ 逐表搬行
(单事务)→ **逐表行数 + 全行 SHA-256 比对** → 写库环境标签与 `schema_version`。
> `--env prod` 必须额外加 `--allow-prod`,交互终端还要手打 `prod` 确认(这是唯一会往生产库灌数据的入口)。
> 目标库若已登记为别的环境(`app_meta.deployment_env`),除非 `--force-env` 否则拒绝。
建库建账号见 `scripts/sql/init_mysql_5.7.sql`(utf8mb4 + `utf8mb4_bin`,两个库对应 dev/prod)。
### 7.3 跨机迁移(换部署机)
+7 -1
View File
@@ -117,7 +117,7 @@ MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<密码> \
### 4.1 配置文件与优先级
- `config.py`:**程序级常量**(端口、路径、USB 远程 adb 等),改它要重启
- `.env`(项目根,不入 git):**密钥与可覆盖配置**,逐行解析 + `os.environ.setdefault`(**真实环境变量优先**)
- `.env`(项目根,不入 git):**密钥与可覆盖配置**,逐行解析 + `os.environ.setdefault`(**真实环境变量优先**);**数据库目标(`DEPLOY_ENV` + `DB_*`)也在这里**
- `app_meta`(数据库 KV):**运行时可改的配置**(AI 配置、设备发现参数),在界面上改
### 4.2 `config.py` 常量
@@ -128,6 +128,11 @@ MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<密码> \
| `WEB_HOST` / `WEB_PORT` | `0.0.0.0` / `18050` | 硬编码 | 18050 避开 Windows 动态端口段 |
| `DATA_DIR` / `APK_DIR` | `data/` / `data/apks/` | 代码计算 | |
| `BACKUP_DIR` / `RESTORE_STAGING_DIR` / `RESTORE_PENDING_DIR` | `data/backups` / `data/restore_staging` / `data/restore_pending` | 代码计算 | 备份相关 |
| `DEPLOY_ENV` | `dev` | `.env` 可覆盖 | 声明这套配置连哪个环境的库;与库名绑定(dev→`auto_control_dev`,prod→`auto_control`),不符拒绝启动 |
| `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_NAME` | 空 / `3306` / 空 / 空 / 空 | `.env` 可覆盖 | MySQL 目标;`DB_HOST` 为空则回退 SQLite(**生产禁止静默回退**,需 `DB_ALLOW_SQLITE_FALLBACK=1`) |
| `DB_CHARSET` / `DB_COLLATION` | `utf8mb4` / `utf8mb4_bin` | `.env` 可覆盖 | 排序规则必须用 `_bin`(逐码点比较,等价 SQLite 的大小写敏感语义) |
| `DATABASE_URL` | 空 | `.env` 可覆盖 | 完整连接串,优先级最高;脚本临时指向别的库时用 |
| `DB_ALLOW_ENV_MISMATCH` / `DB_ALLOW_SQLITE_FALLBACK` | `0` | `.env` | 逃生阀,见 [DEPLOY.md](DEPLOY.md) |
| `USB_ADB_HOST` / `USB_ADB_PORT` | `100.100.10.1` / `5037` | `.env` 可覆盖 | USB 设备所在部署机的远程 adb server |
| `TAILSCALE_API_KEY` / `TAILSCALE_TAILNET` | 空 | `.env` | Tailscale 管理功能 |
| `DISCOVERY_PORT` / `DISCOVERY_INTERVAL` | `5555` / `60` | `.env` 可覆盖 | 设备发现(网段在工具页配置,存 `app_meta`) |
@@ -140,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 密码要同步) |
+5 -9
View File
@@ -32,14 +32,6 @@
- **涉及**:`web_server.py`(注册钩子)+ 需要回归所有写接口(前端、脚本、MCP 的 `platform_client` 都已带 token,风险点是第三方直接调 API)。
- **验收**:注册后所有写接口在缺 token 时返回 403,且前端/MCP 全链路正常。
### A4. 路由回归脚本在 Windows 上不可用
- **现象**:`python scripts/regression_test.py` 直接报 `AttributeError: module 'signal' has no attribute 'alarm'` 退出。
- **原因**:脚本用了 Unix 专有的 `signal.alarm`(第 29 行),docstring 里的示例路径也是 `.venv/bin/python`。
- **影响**:本机(Windows)开发时没有"一条命令扫全接口 500"的手段——A1/A2 这类 import 遗漏本可被它第一时间发现。
- **涉及**:`scripts/regression_test.py`(把 `signal.alarm` 换成跨平台超时,或 `hasattr` 守卫)。
- **验收**:Windows 与 Linux 都能跑;能扫出 A1。
### A5. 前端 `--card-line` 变量未定义
- **现象**:AI 控制台的会话/经验/动作模态框边框失效(`border:1px solid var(--card-line)` 解析为无效值)。
@@ -74,7 +66,7 @@
- `ANDROID_ADB_SERVER_ADDRESS/HOST/PORT` 会被 `.env` 注入 `os.environ`,**adb 客户端**据此把所有调用指向 220;`ANDROID_ADB_SERVER_HOST` 是非标准键(adb 不认)。
- 期望:在配置速查里写明该行为与取舍,或本地去掉这三行改走本机 adb。
- [ ] **`.gitignore` 漏项**:`data/mcp_audit.log` 与 `data/uiauto.pid` 未被忽略(`git status` 会显示为未跟踪);`data/apks/` 目录无占位文件。
- [ ] **`.gitignore` 漏项**:`data/mcp_audit.log` 已补(`data/*.log`,2026-09-13);**`data/uiauto.pid` 仍未忽略**(`git status` 会显示为未跟踪);`data/apks/` 目录无占位文件。
- [ ] 220 的 Tailscale / adb server(5037) 可达性巡检(曾出现连不通超时)。
@@ -129,3 +121,7 @@
- [x] 监控页任务卡去掉「编辑/删除」,只留执行/停用 + 显示**覆盖设备**。
- [x] 备份覆盖清单补 `agent_action` / `app_meta` + 导出/导入双向自检。
- [x] AI 控制台:Markdown 渲染、推理链可折叠、token 用量显示。
- [x] 路由回归脚本 Windows 可用(2026-09-13:`signal.alarm` 加 `hasattr` 守卫)+ 新增安全闸:
配置或目标库被标为 `prod` 时**拒绝运行**(脚本会发真实写请求,不能拿生产数据做实验)。
- [x] 数据库从 SQLite 迁到 MySQL:连接层多环境配置 + 防混库校验、schema 收敛到 ORM metadata、
备份/恢复改为「SQLite 归档 + 单事务整库替换」、迁移脚本(2026-09-13)。
+4
View File
@@ -12,6 +12,10 @@ Flask>=2.3,<4.0
Flask-Login>=0.6
Flask-SQLAlchemy>=3.0,<4.0
# MySQL 驱动(纯 Python,slim 容器里无需编译依赖)。
# 连接参数与多环境配置见 core/db_config.py 与 .env.example 的「数据库」段。
PyMySQL>=1.1
# 定时任务调度
APScheduler>=3.10,<4.0
+367
View File
@@ -0,0 +1,367 @@
# -*- coding: utf-8 -*-
"""把 SQLite 库整体迁移进 MySQL(一次性工具,长期保留)。
用途:
* 平台从 SQLite 迁到 MySQL 时搬数据
* 从老备份(导出 zip 里的 users.db)恢复进 MySQL
* 换环境(dev 库 ↔ 正式库)
用法示例:
# 先干跑:只审计源库 + 建 schema,不搬数据
python scripts/migrate_sqlite_to_mysql.py --sqlite data/users.db --env dev --dry-run
# 正式迁(目标库已有数据会被整表替换)
python scripts/migrate_sqlite_to_mysql.py --sqlite data/users.db --env dev --mode replace
# 迁完只做校验(逐表 SHA-256 比对,不写任何数据)
python scripts/migrate_sqlite_to_mysql.py --sqlite data/users.db --env dev --mode verify
# 生产(必须显式二次确认)
python scripts/migrate_sqlite_to_mysql.py --sqlite data/users.db --env prod --allow-prod --yes
目标库怎么定:默认读 .env / 环境变量的 DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME
(也可用 --target-url 直接给完整连接串)。脚本**不会**碰源库(只读打开)。
安全设计:
* 全程只读源库;目标库的写入在**单个事务**里,失败回滚,可放心重跑
* 搬完做逐表行数 + 逐表全行 SHA-256 校验,不通过就报错
* `--env prod` 必须额外 `--allow-prod`,交互终端还要手打 prod 确认
* 目标库若已登记为别的环境(app_meta.deployment_env),除非 --force-env 否则拒绝
"""
import argparse
import hashlib
import json
import os
import sqlite3
import sys
import time
import uuid
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
# Windows 控制台默认 GBK,输出里带 ✔ 之类的符号会直接抛 UnicodeEncodeError
# (本脚本是运维在终端里手跑的,崩在最后一步会让人以为迁移失败)
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
from sqlalchemy import inspect as sa_inspect, text as sa_text # noqa: E402
def _fail(msg):
print("\n[错误] " + msg)
sys.exit(2)
def _readonly_uri(path):
from urllib.parse import quote
return "file:{}?mode=ro".format(
quote(os.path.abspath(path).replace("\\", "/"), safe="/:"))
def parse_args():
ap = argparse.ArgumentParser(description="SQLite → MySQL 数据迁移")
ap.add_argument("--sqlite", default=os.path.join(ROOT, "data", "users.db"),
help="源 SQLite 库路径(默认 data/users.db)")
ap.add_argument("--env", choices=["dev", "prod"], required=True,
help="目标库属于哪个环境(写入 app_meta.deployment_env)")
ap.add_argument("--mode", choices=["replace", "verify"], default="replace",
help="replace=建 schema 并搬数据;verify=只比对不写")
ap.add_argument("--dry-run", action="store_true",
help="只审计源库并建 schema,不搬数据")
ap.add_argument("--schema-only", action="store_true",
help="只建目标库的表结构,不搬数据")
ap.add_argument("--target-url", default="",
help="目标连接串(默认取 .env / 环境变量里的 DB_*)")
ap.add_argument("--allow-prod", action="store_true",
help="允许对生产库操作(--env prod 时必填)")
ap.add_argument("--yes", action="store_true",
help="非交互环境下的最终确认(与 --allow-prod 一起用)")
ap.add_argument("--force-env", action="store_true",
help="目标库已登记为别的环境时仍然继续(危险)")
return ap.parse_args()
# ================== 1. 源库审计 ==================
def audit_source(src_path, tables):
"""校验源库可读、完整,并做列宽审计。返回 {表: {列: 最大长度}}。"""
if not os.path.exists(src_path):
_fail("源库不存在: " + src_path)
con = sqlite3.connect(_readonly_uri(src_path), uri=True)
con.text_factory = str
try:
integrity = con.execute("PRAGMA integrity_check").fetchone()[0]
print(f"源库完整性: {integrity}")
if integrity != "ok":
_fail("源库完整性校验失败,请先修复源库再迁移")
have = {r[0] for r in con.execute(
"SELECT name FROM sqlite_master WHERE type='table'")}
print(f"源库表({len(have)}): " + ", ".join(sorted(have)))
missing = [t for t in tables if t not in have]
if missing:
print(f" 注意:模型里有但源库没有的表(迁移后为空): {', '.join(missing)}")
# 列宽审计:SQLite 不强制长度,MySQL 严格模式下超长会直接报错
widths = {}
for t in sorted(have):
if t.startswith("sqlite_"):
continue
widths[t] = {}
cols = [r[1] for r in con.execute('PRAGMA table_info("%s")' % t)]
for c in cols:
try:
n = con.execute(
'SELECT MAX(LENGTH("%s")) FROM "%s"' % (c, t)).fetchone()[0]
except sqlite3.Error:
continue
if n:
widths[t][c] = n
return widths
finally:
con.close()
def check_widths(widths):
"""把源库实测列宽与模型声明比对,超限直接拦下(MySQL 严格模式会报错)。"""
from core.models import db
problems = []
print("\n列宽审计(源库实测最大长度 / 模型上限):")
for table in db.metadata.sorted_tables:
w = widths.get(table.name) or {}
for col in table.columns:
n = w.get(col.name)
limit = getattr(col.type, "length", None)
if n is None or not limit:
continue
flag = ""
if n > limit:
flag = " <== 超出!"
problems.append(f"{table.name}.{col.name}: {n} > {limit}")
if n > limit * 0.6:
print(f" {table.name}.{col.name}: {n}/{limit}{flag}")
if problems:
_fail("以下列的实际数据超过模型声明长度,请先加长模型列宽或清理数据:\n "
+ "\n ".join(problems))
print(" (只打印长度超过上限 60% 的列;全部在限内)")
# ================== 2. 目标库 schema ==================
def build_target_app(uri, env):
"""用目标连接串建一个最小 Flask app,复用平台自己的建表逻辑。"""
os.environ["DEPLOY_ENV"] = env
os.environ["DATABASE_URL"] = uri
from flask import Flask
from core.models import db # noqa: E402
app = Flask("migrate")
app.config["SQLALCHEMY_DATABASE_URI"] = uri
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
from core import db_config
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = db_config.engine_options(uri)
db.init_app(app)
with app.app_context():
# 只做结构:建表 / 补列 / 唯一索引(跳过默认管理员与旧 JSON 迁移)
from core.models import _ensure_unique_indexes, _sync_columns
db.create_all()
_sync_columns()
_ensure_unique_indexes()
return app
# ================== 3. 搬数据 + 校验 ==================
def _rows_from_sqlite(con, table, cols):
names = [c.name for c in cols]
sel = "SELECT {} FROM \"{}\"".format(
", ".join('"%s"' % n for n in names), table.name)
cur = con.execute(sel)
return names, cur.fetchall()
def _norm(v):
"""把驱动层返回的值统一成可跨库比较的形态。
两个库的类型系统不一样,直接比 Python 对象会误报:
* 布尔:SQLite 存 1/0(原始读出来是 int),走 ORM 读回来是 True/False
* 整数值的浮点:SQLite 的 REAL 列里可能存着 7,MySQL 的 DOUBLE 读回来是 7.0
* 文本:某些驱动可能给 bytes
"""
if isinstance(v, bool):
return int(v)
if isinstance(v, float) and v.is_integer():
return int(v)
if isinstance(v, (bytes, bytearray)):
return v.decode("utf-8", "replace")
return v
def _canonical(names, rows):
"""把整表行规范化成一个字符串,用于跨库比对(行序无关:先排序再哈希)。"""
packed = [json.dumps([_norm(r[i]) for i in range(len(names))],
ensure_ascii=False, default=str,
sort_keys=False) for r in rows]
packed.sort()
h = hashlib.sha256()
for p in packed:
h.update(p.encode("utf-8"))
h.update(b"\n")
return h.hexdigest()
def copy_and_verify(src_path, table_order, mode, chunk=500):
"""搬运并校验。返回 (逐表统计, 是否全部一致)。
mode="replace":DELETE 全表 + 插入 + 回读比对(单事务,失败整体回滚)
mode="verify" :只回读比对,不写任何数据
"""
from core.models import db
con = sqlite3.connect(_readonly_uri(src_path), uri=True)
con.text_factory = str
stats = []
try:
have = {r[0] for r in con.execute(
"SELECT name FROM sqlite_master WHERE type='table'")}
with db.engine.begin() as dst: # 单事务:失败整体回滚
if mode == "replace":
for table in reversed(table_order):
dst.execute(table.delete())
for table in table_order:
if table.name not in have:
stats.append((table.name, 0, "源库无此表", "-"))
continue
db_cols = {c["name"] for c in sa_inspect(dst).get_columns(table.name)}
cols = [c for c in table.columns if c.name in db_cols]
names, rows = _rows_from_sqlite(con, table, cols)
src_hash = _canonical(names, rows)
if mode == "replace":
for i in range(0, len(rows), chunk):
batch = [dict(zip(names, r)) for r in rows[i:i + chunk]]
dst.execute(table.insert(), batch)
stats.append((table.name, len(rows), src_hash))
finally:
con.close()
# 回读目标库重算(verify 模式只有这一步)
with db.engine.connect() as dst:
out = []
for (name, srows, shash) in stats:
table = next((t for t in table_order if t.name == name), None)
if table is None or shash == "-":
out.append((name, srows, "-", "源库无此表", "-"))
continue
db_cols = {c["name"] for c in sa_inspect(dst).get_columns(name)}
names = [c.name for c in table.columns if c.name in db_cols]
# 用原生 SQL 回读:走 ORM/Core 的 typed select 会做类型转换
# (Boolean→True/False),与源库驱动层读出来的 1/0 对不上
prep = dst.dialect.identifier_preparer
sel = "SELECT {} FROM {}".format(
", ".join(prep.quote(n) for n in names), prep.quote(name))
rows = [tuple(r) for r in dst.execute(sa_text(sel)).fetchall()]
thash = _canonical(names, rows)
same = (len(rows) == srows) and (thash == shash)
out.append((name, srows, len(rows), "一致" if same else "不一致", shash))
return out, all(r[3] == "一致" or r[3] == "源库无此表" for r in out)
def print_stats(stats):
print("\n%-20s %6s %6s %-10s %s" % ("表", "源", "目标", "结果", "SHA-256(前12)"))
for (name, srows, trows, verdict, shash) in stats:
print("%-20s %6s %6s %-10s %s" % (name, srows, trows, verdict, (shash or "-")[:12]))
# ================== 主流程 ==================
def main():
args = parse_args()
if args.env == "prod":
if not args.allow_prod:
_fail("--env prod 必须同时给 --allow-prod(这条命令会写生产库)")
if sys.stdin.isatty() and not args.yes:
print("即将对【生产库】执行迁移。")
if input("请输入 prod 确认: ").strip() != "prod":
_fail("未确认,已中止")
elif not args.yes:
_fail("非交互环境下请追加 --yes 明确确认")
from core import db_config
uri = args.target_url or db_config.build_db_uri()
if db_config.is_sqlite(uri):
_fail("目标仍是 SQLite —— 请在 .env 里配好 DB_HOST/DB_USER/DB_PASSWORD/DB_NAME,"
"或用 --target-url 指定 MySQL 连接串")
print(f"源库 : {args.sqlite}")
print(f"目标库 : {db_config.describe_target(uri)} (环境 {args.env})")
from core.models import db
tables = list(db.metadata.sorted_tables)
print(f"模型表({len(tables)}): " + ", ".join(t.name for t in tables))
widths = audit_source(args.sqlite, [t.name for t in tables])
check_widths(widths)
if args.mode == "verify":
# 不建 schema、不写数据,直接比对(用于迁移后复验)
os.environ["DEPLOY_ENV"] = args.env
os.environ["DATABASE_URL"] = uri
from flask import Flask
app = Flask("verify")
app.config["SQLALCHEMY_DATABASE_URI"] = uri
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = db_config.engine_options(uri)
db.init_app(app)
with app.app_context():
stats, ok = copy_and_verify(args.sqlite, tables, "verify")
print_stats(stats)
print("\n校验结果: " + ("全部一致 OK" if ok else "存在不一致 FAIL"))
sys.exit(0 if ok else 1)
# 目标库环境标签检查(防止误把 dev 数据灌进生产库)
app = build_target_app(uri, args.env)
with app.app_context():
recorded = db_config.meta_get("deployment_env")
if recorded and recorded != args.env and not args.force_env:
_fail(f"目标库已登记为 {recorded} 环境,与 --env {args.env} 不符。"
f"确认无误请加 --force-env")
if not recorded:
print(f"目标库首次使用,将登记为 {args.env} 环境")
if args.schema_only or args.dry_run:
print("\n[dry-run] schema 已建好,未搬数据。")
if args.dry_run:
print(" 去掉 --dry-run 即正式搬运。")
return
print("\n开始搬运(单事务,失败自动回滚)…")
t0 = time.time()
stats, ok = copy_and_verify(args.sqlite, tables, "replace")
print_stats(stats)
if not ok:
_fail("搬运后校验不一致 —— 事务已回滚,目标库保持原样。请把上面的表格发给我排查")
print(f"\n搬运完成,用时 {time.time() - t0:.1f}s,逐表校验一致 OK")
# 4) 写库标签与版本
src = sqlite3.connect(_readonly_uri(args.sqlite), uri=True)
try:
src_id = None
try:
row = src.execute(
"SELECT value FROM app_meta WHERE key='deployment_id'").fetchone()
src_id = row[0] if row else None
except sqlite3.Error:
pass
finally:
src.close()
db_config.meta_set("deployment_env", args.env)
db_config.meta_set("deployment_id", src_id or uuid.uuid4().hex)
db_config.meta_set("deployment_claimed_at",
time.strftime("%Y-%m-%d %H:%M:%S"))
from core.models import CURRENT_SCHEMA_VERSION
db_config.meta_set("schema_version", str(CURRENT_SCHEMA_VERSION))
print(f"已登记库环境标签: {args.env}(部署标识 {db_config.meta_get('deployment_id')})")
print("\n下一步:把 .env 的 DEPLOY_ENV/DB_* 配好,重启 web_server 即连到新库。")
if __name__ == "__main__":
main()
+71 -5
View File
@@ -21,29 +21,79 @@ import signal
# 项目根加入 sys.path(脚本在 scripts/ 下运行,保证可 import web_server/config)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Windows 控制台默认 GBK:汇总里的 ⚠/❌/✅ 会抛 UnicodeEncodeError,
# 而且崩在"打印汇总"这一步 —— 看起来像脚本挂了、其实探测已经全跑完
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
# 测试模式:禁用 cron 调度器(否则 test_client 会按 DB 里的任务 cron 真跑任务,
# 干扰回归/占用设备——实测 ocr关键字任务每 30 分钟触发导致回归超时)
os.environ["DISABLE_SCHEDULER"] = "1"
# 单接口超时保护:adb 相关接口可能慢(连接/查询设备),给足时间
signal.alarm(300)
# 单接口超时保护:adb 相关接口可能慢(连接/查询设备),给足时间。
# Windows 没有 signal.alarm(会 AttributeError 直接崩),那里就靠调用方自己掐时间。
if hasattr(signal, "alarm"):
signal.alarm(300)
from web_server import app # noqa: E402 (导入即完成 app 装配,与生产一致)
# ================== 安全闸:绝不对生产库跑写操作 ==================
# 本脚本会发真实写请求(设备池增删、剪贴板注入、亮灭屏…)。跑在连生产库的配置上
# 就是拿正式数据做实验——直接拒绝启动。
from core import db_config # noqa: E402
if db_config.DEPLOY_ENV == "prod":
print("=" * 70)
print("拒绝运行:当前 .env 声明 DEPLOY_ENV=prod,本脚本会发真实写请求。")
print("回归测试请在 dev 环境跑(或先把 .env 指向 dev 库/临时库)。")
print("=" * 70)
sys.exit(3)
with app.app_context():
_labeled = db_config.meta_get("deployment_env")
if _labeled == "prod":
print("=" * 70)
print("拒绝运行:目标库 %s 被登记为 prod 环境(app_meta.deployment_env)。"
% db_config.describe_target())
print("回归测试会写数据,不能对生产库执行。")
print("=" * 70)
sys.exit(3)
# 流式/二进制接口:无法用普通 GET 断言,跳过(已有单独验证路径)
_SKIP_PREFIXES = (
"/api/screen/stream", "/api/screen/thumb",
"/api/uiauto/screenshot", "/api/device/screenshot",
)
# 测试用的目标设备:运行期从设备池里挑一台真实的(占位串是池空时的兜底)。
# 以前这里写死 Tailscale 时代的 100.100.10.11:5555,设备早换了 → 每次回归都要等
# 好几轮 30s 的 adb connect 超时,还会误报"关键 POST 失败"。
_TEST_SERIAL = "100.100.10.11:5555"
_SERIAL_PH = "@SERIAL@"
def _pick_serial():
"""从设备池里挑一台启用的设备当测试目标(池空则返回占位串)。"""
try:
from core.models import Device, db as _db
with app.app_context():
d = _db.session.query(Device).filter(Device.enabled.is_(True)).order_by(
Device.serial).first()
return d.serial if d else _TEST_SERIAL
except Exception:
return _TEST_SERIAL
# 关键业务 POST:用真实参数验证(这些覆盖了核心链路)
_KEY_POSTS = [
("一键亮屏", "/api/device/screen_all", {"mode": "on"}),
("一键息屏", "/api/device/screen_all", {"mode": "off"}),
("剪贴板注入", "/api/tools/clipboard/set",
{"serials": ["100.100.10.11:5555"], "text": "回归测试"}),
{"serials": [_SERIAL_PH], "text": "回归测试"}),
("测试步骤(wait)", "/api/steps/test",
{"serial": "100.100.10.11:5555",
{"serial": _SERIAL_PH,
"step": {"type": "wait", "label": "等待", "params": {"max": 1, "min": 1, "probability": 100}}}),
("adb 终端执行", "/api/adb/cmd", {"cmd": "adb devices"}),
("adb 红线拦截", "/api/adb/cmd", {"cmd": "adb kill-server"}),
@@ -53,10 +103,21 @@ _KEY_POSTS = [
_EXPECT_REJECT = {"/api/adb/cmd"}
def _sub_serial(obj):
"""把请求体里的 @SERIAL@ 占位替换成运行期选定的设备串(支持嵌套 list/dict)。"""
if isinstance(obj, str):
return _TEST_SERIAL if obj == _SERIAL_PH else obj
if isinstance(obj, list):
return [_sub_serial(x) for x in obj]
if isinstance(obj, dict):
return {k: _sub_serial(v) for k, v in obj.items()}
return obj
def _fill_path_params(rule):
"""把路由路径参数 <xxx> 替换为测试值(serial 用真实设备)。"""
url = rule
url = url.replace("<serial>", "100.100.10.11:5555")
url = url.replace("<serial>", _TEST_SERIAL)
url = url.replace("<apk_id>", "x")
url = url.replace("<action_id>", "x")
url = url.replace("<device_id>", "x")
@@ -135,7 +196,12 @@ def main():
print(f"写操作路由: {len(write_rules)} 个(空 body 探测)")
# ========== 3. 关键业务 POST(真实参数) ==========
global _TEST_SERIAL
_TEST_SERIAL = _pick_serial()
print(f"测试目标设备: {_TEST_SERIAL}")
for name, url, body in _KEY_POSTS:
# 占位符换成运行期挑到的真实设备(含嵌套列表)
body = _sub_serial(body)
try:
r = c.post(url, json=body, headers=H)
j = r.get_json() if r.is_json else None
+50
View File
@@ -0,0 +1,50 @@
-- ==============================================================================
-- auto_control —— MySQL 5.7 环境初始化(建库 + 建账号 + 授权)
--
-- 用法(在 MySQL 主机上以 root 执行):
-- mysql -uroot -p < scripts/sql/init_mysql_5.7.sql
--
-- 建两个库,与 .env 的 DEPLOY_ENV 一一绑定(core/db_config.py 会强制校验):
-- auto_control_dev ← 本地开发机(DEPLOY_ENV=dev)
-- auto_control ← 生产 220 容器(DEPLOY_ENV=prod)
--
-- 字符集固定 utf8mb4 + utf8mb4_bin:
-- * utf8mb4:中文/emoji 都不能丢(MySQL 的 "utf8" 是 3 字节残缺版)
-- * _bin :逐码点比较(大小写敏感),等价 SQLite 的字节序语义。
-- 用默认的 utf8mb4_general_ci 会让 Admin/admin、Phone1/phone1 被判重复,
-- 唯一索引与等值查询语义全变。
--
-- ⚠️ 执行前把下面的 <改我> 换成强口令,并把主机段按实际情况收紧:
-- '192.168.20.%' 局域网
-- '100.100.10.%' Tailscale(若走 tailnet 连库)
-- 220 容器是 host 网络,出口 IP 取决于路由,两条都要覆盖;图省事可用 '%' + 强口令。
-- ==============================================================================
CREATE DATABASE IF NOT EXISTS auto_control
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;
CREATE DATABASE IF NOT EXISTS auto_control_dev
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;
-- 一个专用账号,只授权这两个库(不用 root 跑应用)
CREATE USER IF NOT EXISTS 'auto_control'@'%' IDENTIFIED BY '<改我>';
GRANT ALL PRIVILEGES ON auto_control.* TO 'auto_control'@'%';
GRANT ALL PRIVILEGES ON auto_control_dev.* TO 'auto_control'@'%';
FLUSH PRIVILEGES;
-- 授权说明:
-- * 应用需要 DDL 权限(建表 / 补列 / 建生成列与索引),所以给的是 ALL;
-- 若你的 DBA 只肯给 DML,就先用下面的「可选」段预建 schema,再只授
-- SELECT/INSERT/UPDATE/DELETE。
-- * 表结构由平台启动时自动创建(db.create_all + _sync_columns),
-- 正常情况下不需要手工建表。
-- ---------------------------------------------------------------------------
-- 可选:只给 DML 权限时,先让平台在别的环境把 schema 建好,再由本脚本导入。
-- 也可用迁移脚本的「先建 schema」路径:
-- python scripts/migrate_sqlite_to_mysql.py --sqlite data/users.db --env dev --schema-only
-- ---------------------------------------------------------------------------
-- 校验(执行完可以跑一下):
-- SELECT schema_name, default_character_set_name, default_collation_name
-- FROM information_schema.schemata WHERE schema_name LIKE 'auto_control%';
-- SHOW GRANTS FOR 'auto_control'@'%';
+4 -1
View File
@@ -16,7 +16,7 @@ python3 - <<'PY'
import importlib.util, sys
mods = ("flask", "flask_login", "flask_sqlalchemy", "apscheduler",
"uiautomator2", "uiautodev", "rapidocr_onnxruntime", "PIL",
"fastmcp")
"fastmcp", "pymysql")
if all(importlib.util.find_spec(m) for m in mods):
try:
import cv2
@@ -78,4 +78,7 @@ if [ -f mcp_server/mcp_server.py ] && [ "${MCP_ENABLED:-1}" = "1" ]; then
python3 -m mcp_server.mcp_server > /tmp/mcp_server.log 2>&1 &
fi
# 启动前把"连的是哪个库"打在最显眼的位置(web_server 启动横幅是权威,
# 这里再报一次是防止日志只截到容器入口这几行时看不出环境)
echo "[start] DEPLOY_ENV=${DEPLOY_ENV:-<未设置,默认 dev>} DB_NAME=${DB_NAME:-<未设置>} DB_HOST=${DB_HOST:-<未设置>}"
exec python -u web_server.py
+9 -2
View File
@@ -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');
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 · 设备自动化后台</title>
<title>{% if db_env and not db_env.is_prod %}{{ db_env.env }}-{% endif %}登录 · 设备自动化后台</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;
+15 -4
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>设备自动化后台</title>
<title>{% if db_env and not db_env.is_prod %}{{ db_env.env }}-{% endif %}设备自动化后台</title>
<style>
/* ========== 仪表控制台主题(Instrument Console) ==========
自托管字体:Bricolage Grotesque(展示/数字)+ IBM Plex Mono(数据/串号)
@@ -46,6 +46,8 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
.brand-dot{width:9px;height:9px;border-radius:50%;background:var(--primary);box-shadow:0 0 0 0 rgba(15,118,110,.6);animation:brandPulse 2.4s infinite}
@keyframes brandPulse{0%{box-shadow:0 0 0 0 rgba(15,118,110,.45)}70%{box-shadow:0 0 0 8px rgba(15,118,110,0)}100%{box-shadow:0 0 0 0 rgba(15,118,110,0)}}
.navbar .tabs{display:flex;gap:2px;flex:1;align-self:stretch}
.env-badge{margin-right:18px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:700;letter-spacing:.5px;background:#2b3038;color:#9aa3af;border:1px solid #3a4048;white-space:nowrap}
.env-badge-prod{background:#4a1519;color:#ff9b9b;border-color:#8a2b31}
.navbar .tab{position:relative;padding:0 18px;color:#9aa3af;font-size:14px;font-weight:500;cursor:pointer;border:none;background:none;transition:color .15s;letter-spacing:.2px}
.navbar .tab::after{content:"";position:absolute;left:14px;right:14px;bottom:0;height:2px;border-radius:2px;background:var(--primary);transform:scaleX(0);transition:transform .18s}
.navbar .tab:hover{color:#e5e7eb}
@@ -392,6 +394,12 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<!-- 顶部导航 -->
<nav class="navbar">
<div class="brand"><span class="brand-dot"></span>设备自动化后台</div>
{% if db_env %}
<span class="env-badge{% if db_env.is_prod %} env-badge-prod{% endif %}"
title="数据库目标:{{ db_env.target }}({{ db_env.backend }})">
{% if db_env.is_prod %}PROD · {{ db_env.target }}{% else %}{{ db_env.env|upper }}{% endif %}
</span>
{% endif %}
<div class="tabs">
<button class="tab active" data-tab="monitor" onclick="showTab('monitor')">监控</button>
<button class="tab" data-tab="tasks" onclick="showTab('tasks')">任务</button>
@@ -930,10 +938,11 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<!-- 子分栏 1:数据备份 -->
<div class="sub-panel active" id="system-sub-backup">
<div class="help">
<b>导出</b>当前系统数据为 zip:含 <code>users.db</code> 在线一致快照(<b>全部业务表</b>:用户 / 任务计划 / 设备分组 /
<b>导出</b>当前系统数据为 zip:把数据库(MySQL 或回退模式的 SQLite)整库一致快照成
<code>users.db</code> 归档(<b>全部业务表</b>:用户 / 任务计划 / 设备分组 /
自定义动作 / APK 记录 / 设备池 / 待连接设备 / AI 会话 / 经验库 / 经验巡检 / <b>动作库</b> / 系统配置)+ 可选的
<code>apks/</code> 应用文件。恢复用:换机 / 整库迁移 / 出问题前留底。
<br><span class="text-muted">清单见预览的「表数据行数」;新增持久化表须登记进备份覆盖清单(红线,见 doc/DEPLOY.md §5.2)。</span>
<br><span class="text-muted">清单见预览的「表数据行数」(由模型元数据自动派生,新增表不会漏);备份里记录了来源环境,跨环境导入默认拒绝。</span>
</div>
<div class="toolbar" style="gap:8px">
<label style="display:inline-flex;align-items:center;gap:4px;margin:0 6px">
@@ -951,7 +960,9 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<div class="sub-panel" id="system-sub-restore">
<div class="help">
<b>导入恢复</b>:上传平台导出的 <code>.zip</code> 或 <code>.db</code> → 先<b>校验预览</b> →
确认后系统会<b>自动备份当前库</b>并把恢复任务挂起,<b>重启 web_server 后生效</b>(导入将覆盖现有全部数据)。
确认后系统会<b>自动备份当前库</b>(<code>data/backups/pre_restore_*.zip</code>,可直接再导入回来)并把恢复任务挂起,
<b>重启 web_server 后生效</b>(导入将覆盖现有全部数据;数据替换在单个事务里完成,失败会自动回滚)。
<br><span class="text-muted">备份来自另一个环境(dev↔prod)时默认拒绝导入,需在预览页显式勾选「允许跨环境导入」。</span>
</div>
<div class="toolbar" style="gap:8px">
<input type="file" id="restore-file" accept=".zip,.db">
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>设备监控大屏</title>
<title>{% if db_env and not db_env.is_prod %}{{ db_env.env }}-{% endif %}设备监控大屏</title>
<style>
/* ========== 监控大屏:深色控制室风格 ========== */
@font-face{font-family:'Bricolage Grotesque';font-style:normal;font-weight:700;font-display:swap;src:url('/static/fonts/bricolage-700.woff2') format('woff2')}
+31 -71
View File
@@ -60,14 +60,14 @@ def set_app(app):
def _meta_get(key):
return db.session.execute(
db.text("SELECT value FROM app_meta WHERE key=:k"), {"k": key}).scalar() or ""
# 走方言中立助手(app_meta 的列名 key 在 MySQL 里是保留字,裸 SQL 会语法错)
from core.db_config import meta_get
return meta_get(key) or ""
def _meta_put(key, value):
db.session.execute(
db.text("INSERT OR REPLACE INTO app_meta(key,value) VALUES(:k,:v)"),
{"k": key, "v": str(value)})
from core.db_config import meta_set
meta_set(key, str(value))
def _read_cfg():
@@ -75,29 +75,10 @@ def _read_cfg():
# ================== 经验记忆(自进化) ==================
# agent_experience:任务成功后的操作配方,下次相似任务检索注入 system prompt。
# 原始 SQLite(CREATE IF NOT EXISTS 幂等),不进模型层迁移。
_EXP_TABLE = """
CREATE TABLE IF NOT EXISTS agent_experience (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_prompt TEXT DEFAULT '',
recipe TEXT DEFAULT '',
tool_seq TEXT DEFAULT '',
hits INTEGER DEFAULT 0,
created_at VARCHAR(20) DEFAULT '')"""
# 经验巡检(AI 质检):每日定时把经验交给模型评审,疑似问题标 pending 待人工确认。
# 删除只允许人工(前端 confirm / 本 API),巡检绝不自动删。
_AUDIT_TABLE = """
CREATE TABLE IF NOT EXISTS experience_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
exp_id INTEGER NOT NULL,
verdict TEXT DEFAULT '', -- keep 保留 / delete 建议删除
score REAL DEFAULT 0, -- 0-10 可用性评分
reason TEXT DEFAULT '', -- 模型给出的理由
hits INTEGER DEFAULT 0, -- 巡检时的引用次数(hits>0 被删要更谨慎)
action TEXT DEFAULT 'pending', -- pending 待人工确认 / kept 已保留 / deleted 已删除
audited_at VARCHAR(20) DEFAULT '')"""
# agent_experience / experience_audit / agent_action / agent_conversation 四张表
# 已在 core/models.py 里定义为 ORM 模型(唯一真相),建表统一由 db.create_all() 完成。
# 以前这里是各写一段 SQLite 的 CREATE TABLE + try/except: pass —— 换 MySQL 后
# AUTOINCREMENT / TEXT DEFAULT 全都建不出来,而异常被吞掉,表现为"功能静默失灵"。""
# 巡检评审提示词:模型只判「保留/建议删除」+ 评分 + 理由,不直接删。
_AUDIT_PROMPT = """你是「手机自动化经验库」质检员。经验库存放 AI 成功操作手机后提炼的
@@ -119,12 +100,20 @@ _AUDIT_PROMPT = """你是「手机自动化经验库」质检员。经验库存
只输出 JSON:{{"keep": true或false, "score": 0到10的整数, "reason": "一句话中文理由"}}"""
def _ensure_exp_table():
def _ensure_tables():
"""确保 agent 系四张表存在(幂等)。create_all 只建缺失的表,开销可以忽略。
失败必须落日志:以前是 except: pass,MySQL 下建表失败会被完全吞掉,
表不存在 → 后续查询全报错,而日志里什么都看不到。
"""
try:
db.session.execute(db.text(_EXP_TABLE))
db.session.commit()
except Exception:
pass
db.create_all()
except Exception as e:
_log.error(f"agent 表创建失败: {e}")
def _ensure_exp_table():
_ensure_tables()
def _bigrams(text):
@@ -366,19 +355,7 @@ def _save_experience(prompt, recipe, tool_seq):
# steps 直接用编辑器 schema(open_app/click/input_text…),带**元素定位**
# (selector_type/selector_value),**不含坐标**(分辨率/旋转/改版即失效)。
# 复用:执行前按 name/别名/App 召回并注入 system prompt,模型可跳过重新探索。
_ACTION_TABLE = (
"CREATE TABLE IF NOT EXISTS agent_action ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"name TEXT NOT NULL,"
"app TEXT DEFAULT '',"
"aliases TEXT DEFAULT '[]',"
"params TEXT DEFAULT '[]',"
"steps TEXT NOT NULL,"
"preconditions TEXT DEFAULT '',"
"hits INTEGER DEFAULT 0,"
"source_prompt TEXT DEFAULT '',"
"created_at TEXT DEFAULT '',"
"updated_at TEXT DEFAULT '')")
# 建表见 core/models.py 的 AgentAction
# 允许沉淀的步骤类型(编辑器 STEP_TYPES 子集;显式排除 click_xy 等坐标类)
_ACTION_STEP_TYPES = {
@@ -398,11 +375,7 @@ _ACTION_TOOLS = {
def _ensure_action_table():
try:
db.session.execute(db.text(_ACTION_TABLE))
db.session.commit()
except Exception:
pass
_ensure_tables()
def _brief_result(result):
@@ -731,11 +704,7 @@ _audit_state = {"running": False, "last": "", "last_summary": ""}
def _ensure_audit_table():
try:
db.session.execute(db.text(_AUDIT_TABLE))
db.session.commit()
except Exception:
pass
_ensure_tables()
def _review_one_exp(cfg, prompt, recipe, tool_seq, hits):
@@ -1002,22 +971,11 @@ def agent_actions_save():
# ================== 历史会话(DeepSeek 式:多会话持久化) ==================
# 会话 = 消息序列(JSON),一轮 run 的 user/assistant 文本完成后追加落库。
# 单表 JSON 存储(每会话几十 KB 内,无需独立消息表)。
_CONV_TABLE = """
CREATE TABLE IF NOT EXISTS agent_conversation (
id VARCHAR(20) PRIMARY KEY,
title VARCHAR(100) DEFAULT '',
messages TEXT DEFAULT '[]',
created_at VARCHAR(20) DEFAULT '',
updated_at VARCHAR(20) DEFAULT '')"""
# 单表 JSON 存储(每会话几十 KB 内,无需独立消息表)。建表见 core/models.py。
def _ensure_conv_table():
try:
db.session.execute(db.text(_CONV_TABLE))
db.session.commit()
except Exception:
pass
_ensure_tables()
def _conv_msgs(conv_id):
@@ -1181,13 +1139,15 @@ def agent_run():
try:
from datetime import datetime as _dt2
now = _dt2.now().strftime("%Y-%m-%d %H:%M")
# 上面刚确认过会话不存在,普通 INSERT 即可;
# 并发下撞主键就回滚忽略(原来用 SQLite 专有的 INSERT OR IGNORE)
db.session.execute(db.text(
"INSERT OR IGNORE INTO agent_conversation(id, messages, "
"INSERT INTO agent_conversation(id, messages, "
"created_at, updated_at) VALUES(:i, '[]', :c, :c)"),
{"i": conv_id, "c": now})
db.session.commit()
except Exception:
pass
db.session.rollback()
with _lock:
if _run["state"] == "running":
return jsonify({"ok": False, "error":
+9 -3
View File
@@ -66,10 +66,16 @@ def api_system_backup_preview():
@bp.route("/api/system/backup/apply", methods=["POST"])
@admin_required
def api_system_backup_apply():
"""确认应用导入:自动备份当前库 → 落待生效恢复任务(重启后生效)。"""
token = (request.json or {}).get("token", "")
"""确认应用导入:自动备份当前库 → 落待生效恢复任务(重启后生效)。
force_env_mismatch 默认 False:备份来源环境与当前库环境不符时拒绝导入,
要跨环境(如把生产数据导进 dev 库)必须显式传 true。
"""
body = request.json or {}
token = body.get("token", "")
force = bool(body.get("force_env_mismatch", False))
try:
res = sb.apply_restore(str(token).strip())
res = sb.apply_restore(str(token).strip(), force_env_mismatch=force)
except sb.BackupError as e:
return jsonify({"ok": False, "error": str(e)}), 400
except Exception as e:
+35 -7
View File
@@ -33,24 +33,52 @@ if not os.environ.get("WEB_SECRET_KEY"):
app.config["SECRET_KEY"] = _web_secret
# debug=False 时模板默认不自动重载,开发期改 HTML 需重启才生效;这里显式开启
app.config["TEMPLATES_AUTO_RELOAD"] = True
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(
os.path.dirname(os.path.abspath(__file__)), "data", "users.db")
# 数据库目标与连接参数统一由 core/db_config 装配(.env 的 DEPLOY_ENV / DB_* 决定)。
# 这里就把配置校验做完:连不上、环境与库名不匹配 → 直接拒绝启动,不要带着错配置跑起来。
from core import db_config
try:
_DB_URI = db_config.build_db_uri()
db_config.check_connection(_DB_URI)
except db_config.DBConfigError as _e:
_log.error("数据库配置校验失败,拒绝启动:\n%s", _e)
raise SystemExit(2)
app.config["SQLALCHEMY_DATABASE_URI"] = _DB_URI
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = db_config.engine_options(_DB_URI)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
login_manager = LoginManager(app)
login_manager.login_view = "auth.login" # 蓝图化后路由前缀 auth
# 消费「待生效的备份恢复」:若存在 data/restore_pending/users.db,在 engine 首次
# 打开数据库之前换位。导入必须重启生效——TaskManager 启动时读库入内存,
# Windows 无法热替换正被持有的库文件。
try:
def _consume_pending_restore():
"""消费「待生效的备份恢复」(若存在 data/restore_pending/users.db)。
必须在 init_db 之后调用:现在是「单事务整库替换」而不是换文件,
要先有建好的表和 app context。导入仍然要重启才生效——因为 TaskManager /
device_pool 把任务与设备缓存在内存里,整库替换后内存副本全部失效。
"""
try:
from core.system_backup import consume_pending_restore
with app.app_context():
consume_pending_restore()
except Exception as _e:
except Exception as _e:
_log.error(f"消费备份恢复任务异常(不影响启动): {_e}")
# 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB)
init_db(app)
# 消费「待生效的备份恢复」——必须在 init_db 之后(表已建好,且现在是事务替换而非换文件)
_consume_pending_restore()
# 库环境标签校验 + 启动横幅:每次启动都明确写出「现在连的是哪个库」,
# 避免在不知情的情况下对着生产库操作(app_meta 表要存在,所以放在 init_db 之后)
try:
with app.app_context(): # db.engine / app_meta 读取都需要 app context
db_config.verify_deployment_label()
db_config.print_banner()
# 页面顶部环境徽标(防混库第 4 层:点开任何页面都能看出连的是哪个库)
app.jinja_env.globals["db_env"] = db_config.env_badge()
except db_config.DBConfigError as _e:
_log.error("数据库环境校验失败,拒绝启动:\n%s", _e)
raise SystemExit(2)
# 设备池(本地清单 + adb 在线状态):任务调度的数据源
from core import device_pool
device_pool.init_app(app)