Files
auto_control/doc/DATA_MODEL.md
T
butubb bd51306a2a feat(任务): 「动作配置」分栏——步骤默认值 + 动作录制器
需求:滑动要能"我自己录制";在自定义动作旁边加一个分栏,能配动作默认值、
能录制操作当动作、能自己建立动作。

**步骤默认值**(core/step_defaults.py,存 app_meta.step_defaults 单键,不建表):
- 覆盖 滑动(方向/时长区间/幅度/抖动/拟人)、点击(等待超时)、长按(时长/超时)、
  等待(区间)、按键、输入文字(模式/候选文案/清空);
- **只影响之后新建的步骤**,已有步骤不动;新建时 `_makeStep` 用出厂值打底 + 默认值覆盖;
- 只存与出厂值不同的字段(以后调出厂默认,没配过的能跟着走);
- 字段白名单 + 范围夹取(SPEC):越界夹边界、非法值丢弃 → 手改 JSON 塞脏值也进不来。

**动作录制器**(static/admin/recorder.js):
- 在设备画面上点/划/按键/输入 → 自动翻译成步骤:
  点→命中元素记「点击元素」(选择器)、没命中退化成「点击坐标」(百分比);
  划→「滑动」方向/幅度/时长全按你实际操作算;停顿≥1s 可选记成「等待」(±15%);
- 两个入口:动作配置页「录制动作」(存成自定义动作,进动作库复用)、
  步骤编辑器滑动那步的「录制手势」(只取手势回填该步骤);
- 画面+元素树走 /api/uiauto/snapshot 一次取齐,**每次操作后自动刷新**,
  所以下一次点击用的是最新的树;执行仍走 /api/screen/{tap,swipe,key,text}(与大屏同一套)。

接口:GET/POST /api/step_defaults(读登录、写需 tasks 权限)。
文档:TASK_DEV §3.1(含翻译规则表与三条实现口径)、API §2.3、DATA_MODEL §5、README。

自测(无头浏览器 + CDP 真模拟拖动):默认值保存→回读→新建滑动步骤确实预填 0.42;
录制器取到画面与元素树;模拟"按住往上划"→ 录出
{direction:up, distance_ratio:0.5, duration_min:0.47, humanize:true}(幅度/时长按实际操作算);
恢复出厂链路 0.6 通过;页面无 JS 报错。
2026-09-20 14:32:35 +08:00

345 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 数据模型(DATA_MODEL)
> 适用读者:改后端 / 排数据问题的开发者与运维。
> 相关文档:[ARCHITECTURE.md](ARCHITECTURE.md)(运行时架构)、[DEPLOY.md](DEPLOY.md) §数据备份(备份覆盖红线)、[API.md](API.md)(消费这些数据的接口)。
> **表结构以 `core/models.py` 的 ORM 模型为唯一准**(2026-09-13 起各模块的裸建表 SQL 已全部并入模型),本文是它们的映射说明。
---
## 1. 总览
| 项 | 值 |
|----|----|
| 引擎 | 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` |
**环境与库的绑定**(防混库,见 [DEPLOY.md](DEPLOY.md) §2.2)
| `DEPLOY_ENV` | 期望库名 | 用途 |
|---|---|---|
| `dev` | `auto_control_dev` | 本地开发 |
| `prod` | `auto_control` | 正式环境(220 容器) |
启动时校验「`.env` 声明」与「库名」「库中登记的 `app_meta.deployment_env`」三方一致,不符**拒绝启动**。
**表清单(14 张,全部是 `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` | 动作库(命名动作) |
| 13 | `device_install_log` | 设备端应用商店的下载/安装记录(设备上报,见 [DEVICE_AGENT.md](DEVICE_AGENT.md)) |
| 14 | `task_step_log` | 任务步骤明细(每次步骤执行一条,见 §2.8;**唯一有无界增长风险的表**,靠保留期清理) |
> 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),且都是「空值不参与唯一约束」的语义。
---
## 2. 模型表(`core/models.py`)
### 2.1 `user` — 登录用户
| 列 | 类型 | 默认 | 说明 |
|----|------|------|------|
| `id` | Integer | — | 主键 |
| `username` | String(80) | — | **唯一**,非空 |
| `password_hash` | String(255) | — | werkzeug 加盐哈希;兼容旧裸 SHA-256(校验通过后自动升级) |
| `is_admin` | Boolean | `True` | 管理员不受权限位限制 |
| `perms` | Text | `"[]"` | JSON 数组:`tasks` / `devices` / `apks` / `logs` |
方法:`get_perms` / `set_perms` / `has_perm` / `set_password` / `check_password`。
### 2.2 `device_group` — 设备分组
| 列 | 类型 | 默认 | 说明 |
|----|------|------|------|
| `id` | Integer | — | 主键 |
| `name` | String(80) | — | **唯一**,非空 |
| `serials` | Text | `"[]"` | 组内设备 serial 的 JSON 数组 |
| `description` | Text | `""` | 备注 |
### 2.3 `task_job` — 任务计划
| 列 | 类型 | 默认 | 说明 |
|----|------|------|------|
| `id` | String(32) | — | 主键,uuid 前 8 位 |
| `name` | String(120) | — | 非空 |
| `task_type` | String(60) | `"generic_steps"` | 必须已注册 |
| `target` | Text | `'{"mode":"all"}'` | JSON |
| `params` | Text | `"{}"` | JSON(与任务类默认值合并后使用) |
| `schedule` | Text | `'{"mode":"once"}'` | JSON |
| `retry` | Text | `'{"max_attempts":1,"delay":60}'` | JSON |
| `enabled` | Boolean | `True` | 是否参与调度 |
### 2.4 `custom_action` — 自定义动作
| 列 | 类型 | 默认 | 说明 |
|----|------|------|------|
| `id` | String(32) | — | 主键 |
| `name` | String(120) | — | 非空 |
| `icon` | String(4) | `"📦"` | 展示图标 |
| `steps` | Text | `"[]"` | JSON,schema 与 `generic_steps` 的 `params.steps` 一致 |
| `created_at` | String(20) | `""` | 时间串 |
### 2.5 `apk_file` — APK 记录
| 列 | 类型 | 默认 | 说明 |
|----|------|------|------|
| `id` | String(32) | — | 主键;磁盘文件名为 `<id>.apk` |
| `filename` | String(255) | — | 原始文件名 |
| `display_name` / `package_name` / `version_name` | String | `""` | 解析结果 |
| `version_code` / `size` | Integer | `0` | |
| `upload_time` | String(20) | `""` | |
### 2.6 `device` — 设备池
| 列 | 类型 | 默认 | 说明 |
|----|------|------|------|
| `serial` | String(120) | — | 主键:`IP:5555` 或 USB 序列号 |
| `name` | String(80) | `""` | 备注名 |
| `model` | String(120) | `""` | 型号(迁移 v3 追加) |
| `enabled` | Boolean | `True` | 停用则不参与调度 |
| `note` | Text | `""` | |
| `created_at` | String(20) | `""` | |
| `fingerprint` | String(120) | `""` | **设备指纹**(`ro.serialno`,迁移 v5 追加):同一台物理设备换 IP 后据此认领回原记录 |
### 2.7 `pending_device` — 待确认的发现设备
| 列 | 类型 | 默认 | 说明 |
|----|------|------|------|
| `serial` | String(120) | — | 主键 |
| `source` | String(20) | `""` | `lan` / `tailscale` |
| `first_seen` / `last_seen` | String(20) | `""` | 时间串 |
| `fingerprint` | String(120) | `""` | 扫描时读取的设备指纹(迁移 v6 追加),用于提示"这是已有设备换了地址" |
> ⚠️ SQLAlchemy 模型的 `default=` 是 **Python 侧默认值**,SQLite 建表语句里没有 `DEFAULT` 子句;只有原生建表的表才有真正的 SQL DEFAULT。
### 2.8 `task_step_log` — 任务步骤明细
每一次步骤执行一条(`tasks/generic/task.py:_exec_one` 里记录),是「日志 → 步骤明细」
页的数据源。与 `logs/task.log` 的分工:那边是**排障原文**(什么都写、10MB 滚动),
这边是**结构化的一份**——设备/任务/步骤/结果/耗时都是列,能过滤、能统计、能导出 CSV。
| 列 | 类型 | 默认 | 说明 |
|----|------|------|------|
| `id` | Integer | — | 主键 |
| `run_id` | String(24) | `""` | 一次运行 = 设备 × 任务 × 第几次尝试;`TaskManager._run_with_retry` 每次尝试生成一个(12 位 hex),把这次尝试的所有步骤串起来 |
| `job_id` / `job_name` | String(32/120) | `""` | 任务快照(任务删了明细还在,名字仍可读) |
| `serial` / `device_name` | String(120/80) | `""` | 设备地址与**当时**的名字(快照,改名不影响历史) |
| `step_path` | String(32) | `""` | 嵌套位置,如 `2.1.3`;容器步骤(loop/group/if_el)会记自己那条,children 追加一级 |
| `step_label` / `step_type` | String(120/40) | `""` | 步骤标签与类型(`click_el`/`loop`/`wait`…) |
| `selector` | String(300) | `""` | 元素选择器(长选择器截断) |
| `result` | String(16) | `""` | `ok` / `miss`(handler 返回 False)/ `error`(抛异常)/ `unknown`(未知步骤类型)/ `skip`(概率未触发)/ `cap`(本次运行已达上限) |
| `detail` | String(500) | `""` | 异常消息、跳过原因等 |
| `duration_ms` | Integer | `0` | 本步耗时(慢步骤一眼可辨) |
| `created_at` | String(20) | `""` | 执行时刻(**保留期按它算**) |
索引:`run_id`、`created_at`、`(serial, created_at)`、`(job_id, created_at)`。
**两条硬边界**(都在 `core/step_log.py`):
| 常量 | 默认 | 作用 |
|---|---|---|
| `MAX_ROWS_PER_RUN` | 2000 | 单次运行最多记 2000 条,超出只补一条 `cap` 说明行。**没有它,`forever` 循环任务会瞬间写爆这张表** |
| `KEEP_DAYS` | 14 | 保留期:每天 04:13(+ 每次启动)清理更早的记录 |
> ⚠️ **这张表是唯一有无界增长风险的表**,而它会**自动进整库备份**(§6 的派生规则),
> 所以 `KEEP_DAYS` 直接决定备份包体积。调大之前先想清楚导出的 zip 会有多大。
写入走 `core/step_log.py` 的**专用写线程 + 有界队列**(任务线程只 `put_nowait`,
微秒级;队列满丢弃并计数)——步骤执行是热路径,绝不能在任务线程里同步写库。
---
## 3. 非模型表
### 3.1 `app_meta` — KV 配置
| 列 | 类型 |
|----|------|
| `key` | TEXT PRIMARY KEY |
| `value` | TEXT |
由 `_migrate_schema()` 建表(启动必执行)。**所有键见 §5**。
### 3.2 AI 相关四张表(`web/agent_api.py`,原生建表)
| 表 | 列 |
|----|----|
| `agent_conversation` | `id`(PK) · `title` · `messages`(JSON) · `created_at` · `updated_at` |
| `agent_experience` | `id`(PK AUTOINCREMENT) · `task_prompt` · `recipe` · `tool_seq` · `hits` · `created_at` |
| `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` |
> 2026-09-13 起这四张表**已升为 ORM 模型**(见 §1 的说明),建表统一走 `db.create_all()`。
---
## 4. 迁移机制
### 4.1 建表与补列(以模型为准)
**模型定义是唯一真相**。启动时:
| 步骤 | 做什么 | 幂等性 |
|------|--------|--------|
| `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 |
> 2026-09-13 之前,补列靠 `ALTER TABLE` 报错文本里有没有 `duplicate column name` 来判断
> 「列已存在」——那是 SQLite 时代的写法,换方言(MySQL 的错误码/文本都不同)就失效了。
> 现在改为直接读数据库元数据比对,**缺什么补什么,两种方言一套代码**。
### 4.2 唯一索引(设备身份)
两个「空值不参与唯一约束」的唯一索引,与版本号无关,每次启动都补建:
| 索引 | 作用 |
|------|------|
| `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 仍在 → 直接归档**,防止"用户删空数据后重启又复原"。
---
## 5. `app_meta` 键清单
| key | 用途 | 写入方 |
|-----|------|--------|
| `schema_version` | 迁移版本游标 | `core/models.py` |
| `agent_api_base` | AI 接口地址 | AI 控制台配置页 |
| `agent_model` | 模型名 | 同上 |
| `agent_api_key` | API Key(**明文存库**) | 同上 |
| `agent_default_serial` | 默认目标设备 | 同上 |
| `agent_max_steps` | 最大步数(钳制 1-200,默认 40) | 同上 |
| `agent_task_draft` | **AI 建任务**最近一份任务草稿(JSON `{draft,warnings,prompt,created}`,只留最近一份、超限自动瘦身)。**不是任务**:入库仍要用户在步骤编辑器确认后走 `POST /api/jobs` | AI 建任务页 / `submit_task` 工具 |
| `discovery_enabled` | 自动发现开关(`"1"`/`"0"`) | 工具页「设备池管理」 |
| `discovery_subnets` | 扫描网段 JSON 数组 | 同上 |
| `discovery_interval` | 扫描周期秒(10-3600) | 同上 |
| `discovery_port` | adb 探测端口(1-65535) | 同上 |
| `discovery_auto_claim` | 指纹匹配时自动认领(`"1"`/`"0"`,默认关) | 同上 |
| `agent_store_enabled` | 设备端应用商店开关(`"1"`/`"0"`,默认关) | 应用管理页 |
| `agent_device_token` | **设备端令牌**(Agent 调设备接口用;属凭据) | 同上(启用时自动生成,可重置) |
| `agent_device_token_at` | 令牌生成/重置时间 | 同上 |
| `deployment_env` | **库环境标签**(`dev`/`prod`),启动时与 `.env` 比对 | `core/db_config.py`(首次连接)/ 迁移脚本 |
| `deployment_id` | 库唯一标识(uuid),用于识别"这份备份来自哪个库" | 同上 |
| `deployment_claimed_at` | 标签写入时间 | 同上 |
| `notify_webhooks` | **通知 / Webhook 全部配置**(JSON:`{version, settings, webhooks[]}`,见 [NOTIFY.md](NOTIFY.md) §4)。**不建表**——新增/删除 webhook 都只改这一个键 | 系统 → 通知 页 |
| `step_defaults` | **步骤默认值**(JSON:`{步骤类型: {字段: 值}}`,见 [TASK_DEV.md](TASK_DEV.md) §3.1)。新建步骤时预填用。**只存与出厂值不同的字段**,这样以后调出厂默认能跟着走 | 任务 → 动作配置 页 |
> ⚠️ `agent_api_key` 是**明文存储**,导出备份的 zip 里也含它——备份预览会固定给出"含敏感信息"告警。
> **同理 `notify_webhooks` 里的 webhook URL 本身就是凭据**(企业微信 `?key=`、钉钉 `?access_token=`、
> 飞书 `/hook/<token>`):拿到它就能往群里发消息。接口回显/发送记录/日志一律走
> `notifier.mask_url()/scrub()` 打码,导出备份时也按敏感信息对待。
> `app_meta` 的列名 `key` 在 MySQL 里是保留字,**不要直接拼裸 SQL**,统一走
> `core/db_config.meta_get / meta_set`(方言中立、自动加引号)。
---
## 6. 备份覆盖清单(红线)
`core/system_backup.py` 的 `SUMMARY_TABLES` **由模型元数据派生**:
```python
SUMMARY_TABLES = tuple(sorted(t.name for t in db.metadata.tables.values()))
```
也就是说——**新增一张 ORM 表,自动就进备份覆盖清单**,不可能再漏。
`TABLE_LABELS`(预览页的中文标签)仍是手工维护,缺标签时回退显示表名。
**双向自检**:
- **导出侧**:登记在 `SUMMARY_TABLES` 但快照里缺失 → 写入 `manifest.coverage_missing` + 日志告警
- **导入侧**:备份里出现未登记的表(排除 `sqlite_` 前缀)→ 预览告警(字段 `extra_tables`)
- `REQUIRED_TABLES = (app_meta, user, task_job, device_group)`:缺任一直接拒绝导入(这四项是"判定这是不是本平台备份"的最小集合,故意手工维护)
> **红线**:新增持久化表时**同时补 `TABLE_LABELS` 的中文标签**并更新 [DEPLOY.md](DEPLOY.md) §数据备份。
> 覆盖清单本身不再需要手工登记(已由 metadata 派生)。历史教训:`agent_action` 曾漏登记,
> 导致"动作库看起来没备份"(数据其实在快照里,只是清单没列)。
---
## 7. 数据目录
| 路径 | 内容 | 进 git |
|------|------|--------|
| `data/users.db`(+`-wal`/`-shm`) | SQLite 主库(**仅回退模式用**;连 MySQL 时这些文件不被读写,可留作历史归档) | 否 |
| `data/apks/*.apk` | 上传的 APK | 否 |
| `data/backups/` | 导出临时 zip、`pre_restore_*.zip`(导入前安全网)、`restore_failed_*` | 否 |
| `data/restore_staging/<token>/` | 导入暂存(TTL 1800s 自动清理) | 否 |
| `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 迁移归档 | 否 |
| `logs/*.log` | 运行日志 | 否 |
> `data/` 与 `logs/` 全部是运行时产物,**任何文件都不入 git**。生产机上的库由「系统 → 数据备份导出/导入」或整目录手工备份。
---
## 8. 约定与注意事项
1. **主键用 uuid 前 8 位字符串**(`task_job` / `custom_action` / `apk_file` / `agent_conversation`):可读性好,理论上有碰撞概率(8 位 hex = 32 bit)。
2. **JSON 字段一律 Text 存储**,读写通过 `get_*/set_*` 方法;解析失败回退默认值。
3. **时间统一为字符串**(`"YYYY-MM-DD HH:MM"` 或 `"YYYY-MM-DD HH:MM:SS"`),排序依赖字符串序。
4. **删除必须显式删行**(`delete_job` / `delete_group`):只 upsert 会导致重启后数据"复活"。
5. **跨线程访问 DB 必须自推 app context**(后台线程里用 `with app.app_context()`)。
6. **不要手工改库结构**:走 `SCHEMA_MIGRATIONS`(模型表)或幂等建表(原生表),否则 `create_all` 与版本号会不一致。
7. **改表结构后记得**:① 更新本文;② 若是新表,登记备份覆盖清单(§6 红线)。