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 张表、无覆盖缺失)正常。
This commit is contained in:
2026-09-13 10:30:15 +08:00
parent f22263ab45
commit c834349fac
5 changed files with 276 additions and 145 deletions
+6 -13
View File
@@ -26,26 +26,19 @@ from urllib.parse import quote
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": "任务计划",