Files
auto_control/doc/ARCHITECTURE.md
T
butubb 876224f876 feat(去重): 跨设备「已做过」账本 —— 同一个号不会做两次 + 「谁做过了」看得见
用户场景(他原话):一台手机登录 5 个抖音号、一共 5 台手机,每个任务只让其中一个
目标号评论;每天跑一次但不知道什么时候跑完,于是"一直重复跑" → 结果
"一个手机还没评论到,一个手机都评论两次了"。

**根因不是"单设备重复",是跨设备没有共享的判断 + 进度不可见。** 所以做两件事:
① 幂等;② 把"谁做过了、还差谁"摆到台面上(不然只能靠重跑确认,而重跑又在制造重复)。

- `core/models.py`:新表 `done_mark`(迁移账本补 v7)。**判据只有 `scope_key` 的
  唯一索引**——多台设备会同时判断"没做过","先查后插"有竞态(两台都插),
  唯一索引 + `INSERT ... ON DUPLICATE KEY`/`INSERT OR IGNORE` 的**受影响行数**才原子。
- `core/dedup.py`(新):`build_key`(`任务|身份|时间桶`)/ `check` / `mark` /
  `list_marks`(带"今天做了几台/几个号"统计)/ `delete_mark` / `clear_job` / `purge_old`。
  自建 app context(照 device_pool 的 `_ctx()`),任务线程/Web/清理都不用关心。
- 任务侧两个部件(**检查在前、记账在后**):
  · `if_el` 新增条件类型 `selector_type="dedup"`:命中=这个身份做过了 → 走 then 分支。
    身份元素在 `ident_type`/`ident_value`(留空 = 用设备 serial,一号一机场景)。
  · 新步骤 `mark_done`「记为已做」(22 种步骤):放动作**成功之后**。
  拆两步的用意:动作失败就不记账,下次重跑还会重试该设备 —— 失败不丢。
- 有效期(`dedup_reset` = day/all/hours)放**任务级**:检查与记账两处各填一份的话,
  填不一致就算出两个 key、去重会**静默失效**,所以强制只配一处(编辑器顶部下拉)。
- 三条防误伤规则(都有测试兜着):
  · 身份读不到 / 身份值过长 → **不去重、当没做过照常执行**。绝不能把"读不到"
    当成空身份——那会让所有设备共用一个 key、第一台记账后其余全被误判成"做过"。
  · `kind='all'`(只做一次)的记录**永不清理**(清了等于语义失效);清理只删 day/hours。
  · 去重的两个易错点在保存时直接告警:身份元素两边不一致、有检查没记账/有记账没检查。
- 「任务 → 去重记录」新子分栏(`static/admin/dedup.js`):统计行 + 明细表 +
  删单条(那个号重跑)/ 清空任务(整批重跑)。接口 3 个(GET/delete/clear,PERM_TASKS)。
- 每日 04:23 清理(挂现有 APScheduler),`TABLE_LABELS` 补中文名(备份覆盖自动派生)。
- AI 建任务草稿校验同步:`dedup` 走自己的规则(要 ident_value、xpath 前缀校验),
  没填身份元素只警告不拦(用设备当身份是合法用法);普通条件空选择器仍然拦。
- 文档:TASK_DEV §4.6(去重专章 + App 内检测的兜底配方与它的三个局限)、
  DATA_MODEL §2.9、API 三个接口、ARCHITECTURE(分层/装配/子分栏/JS 分工/清理)、
  DEPLOY §5.2(15 张表)、步骤数 21→22 全库同步。

自测:单元 + 集成 33 项(**含 8 线程抢同一个身份、恰好一个成功**的原子性断言,
以及"all 记录不被清理""身份读不到不去重""清了能重跑")、
**真机端到端**(cs1 上"检查→动作→记账"跑两遍:第二遍被拦、换 serial 的"另一台设备"
同样被拦、删记录后能重跑)、草稿校验 5 项、GET 冒烟 56 路由 0 个 500。

(注:本分支基于 feat/if-el-multi-value,因为它俩都要改 task.py 的 STEP_TYPES 与
editor.js 的 STEP_LIB 同一区域,分开从 dev 拉必然冲突——这份是超集,合一次两份都进。)
2026-09-24 10:55:29 +08:00

407 lines
31 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.
# 架构详解(ARCHITECTURE)
> 适用读者:要改后端 / 前端 / 任务引擎的开发者。
> 相关文档:[DATA_MODEL.md](DATA_MODEL.md)(表结构)、[API.md](API.md)(接口清单)、[DEVELOPMENT.md](DEVELOPMENT.md)(流程与红线)。
> 文中引用为 `文件:行号`,以当前代码为准;**行号会随改动漂移,函数名与常量名是稳定锚点**。
---
## 1. 总览
### 1.1 分层
```
┌──────────────────────────────────────────────────────────────────────┐
│ 表现层 templates/admin/*.html + static/admin/*.js(单页应用,无框架) │
│ 7 个顶级 Tab;轮询 / SSE / MJPEG 三类实时通道 │
└───────────────────────────┬──────────────────────────────────────────┘
│ fetch JSON / SSE / MJPEG / 表单
┌───────────────────────────▼──────────────────────────────────────────┐
│ Web 层 web/(10 个 Flask 蓝图,全部 url_prefix 为空) │
│ auth 鉴权 · monitor 状态与设备操作 · tasks 任务 · admin 用户与日志 │
│ tools 运维 · devices 设备池 · apks 应用 · tailscale · agent AI 控制台 │
│ system 备份 │
└───────────────────────────┬──────────────────────────────────────────┘
│ 直接函数调用(共享对象由 web/context.py 注入)
┌───────────────────────────▼──────────────────────────────────────────┐
│ 领域层 task_manager(调度) device_worker(执行) device_pool(池) │
│ system_backup(备份) apk_manager(应用) device_discovery │
│ device_battery(电量采集 + 低电量告警) dedup(去重账本) │
└───────────────────────────┬──────────────────────────────────────────┘
│
┌───────────────────────────▼──────────────────────────────────────────┐
│ 基础层 adb_helper · u2_helper · uiauto_helper · ocr · clipboard │
│ notifier(通知分发:队列/聚合/限流/适配器) │
│ step_log(步骤明细:队列 + 批量落库 + 保留期清理) │
│ models(SQLite)· logger · config │
└──────────────────────────────────────────────────────────────────────┘
▲
┌───────────────────────────┴──────────────────────────────────────────┐
│ 任务定义层 tasks/(BaseTask 注册表 + generic/ 通用步骤引擎) │
└───────────────────────────┬──────────────────────────────────────────┘
▲ ▲
┌───────────────────────────┴────────┐ ┌───────────┴──────────────────┐
│ MCP Server(mcp_server/,:8033) │ │ AI Agent(mcp_agent/) │
│ 20 个 de_* 工具,供外部 AI 调用 │ │ OpenAI 兼容模型 → MCP 工具 │
└────────────────────────────────────┘ └──────────────────────────────┘
```
### 1.2 各层职责边界
| 层 | 做什么 | 不做什么 |
|----|--------|---------|
| 表现层 | 渲染、交互、轮询/流式拉取、按权限隐藏入口 | 不校验权限(只隐藏);不做业务判断 |
| Web 层 | 参数校验、鉴权装饰器、JSON 序列化、调用领域层 | 不直接操作 adb/u2(`monitor` 的看屏/截图除外,那本身就是"设备操作") |
| 领域层 | 调度、并发、重试、状态机、持久化 | 不感知 HTTP |
| 基础层 | adb / u2 / OCR / 数据库 / 日志的原子能力 | 不含业务规则 |
| 任务定义层 | 任务类型注册 + 具体任务执行逻辑 | 不感知调度与设备获取(`BaseWorker` 已封装) |
**装配方向**:`web_server.py` 是唯一组装点;`web/context.py` 注入 `mgr` / `apk_mgr` / `device_pool`,避免 Web 层与领域层循环 import。
---
## 2. 启动与装配顺序
理解启动顺序很关键——**很多副作用发生在 import 期**。
### 2.1 阶段 A:import 期副作用(`web_server.py:6-25`)
| 顺序 | 触发 | 副作用 |
|------|------|--------|
| 1 | `from core.task_manager import TaskManager` | 链式触发:`config.py` 模块体 → **读取根目录 `.env`**(`os.environ.setdefault`);`core/logger.py` → 创建 `logs/`;`tasks/__init__.py` → **任务类型注册**(`@register_task` 在 import 期执行) |
| 2 | `get_logger` / `core.models` | 得到 `db` / `init_db` / `User` |
| 3 | `from config import WEB_HOST, WEB_PORT` | 读取端口常量 |
> `.env` 是"import config 的副作用",因此 `web_server.py:30` 读 `WEB_SECRET_KEY` 时它已生效。
### 2.2 阶段 B~D:Flask 与领域对象
| 阶段 | 位置 | 做了什么 |
|------|------|---------|
| **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-88` | `init_db(app)`(建表 → 补列 → 版本账本 → 唯一索引 → 默认管理员 → 旧 JSON 迁移)→ `notifier.init_app`(通知 dispatcher/sender 线程)与 `step_log.init_app`(步骤明细写线程)→ **恢复任务消费** `consume_pending_restore()` → **库环境标签校验 + 启动横幅**(`db_config.verify_deployment_label/print_banner`,不符拒绝启动);`device_pool.init_app`(**刷一次 `serial→名称` 内存快照** + **起线程**:3s 后采集型号、另起 `device-names` 每 60s 刷名称);`device_discovery.init_app`(**起常驻扫描线程**);`device_battery.init_app`(**起常驻电量采集线程**);`dedup.init_app`(**去重账本绑 app**,供任务线程/Web/清理自推 context);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)`;`notifier.set_device_name_resolver(device_pool.name_of)`(通知里显示设备名而不是 IP,见 [NOTIFY.md](NOTIFY.md) §7) |
### 2.3 阶段 E~G:蓝图、巡检调度器、真正启动
| 阶段 | 位置 | 做了什么 |
|------|------|---------|
| **E** | `:64-67` | `context.init(...)`;`register_blueprints(app)`(10 个蓝图);`agent_api.set_app(app)`(供后台线程推 app context) |
| **F** | 同上附近 | **第二个独立 APScheduler**:`CronTrigger(hour=3, minute=47)` 挂经验库巡检、`hour=4, minute=13` 挂步骤明细清理(`_purge_step_log`)、`hour=4, minute=23` 挂去重记录清理(`_purge_done_mark`,只清 `day`/`hours` 桶)——两个清理任务都自建 app context;失败仅 warning |
| **G** | `__main__` | `_ensure_uiauto_running()`(拉起 uiautodev:20242,写 `data/uiauto.pid`,`atexit` 清理)→ `_preconnect_pool_devices()`(后台并发 connect 池内网络设备)→ `_purge_step_log_async()`(后台清理超期步骤明细)→ `_run_server()`(候选端口依次 bind:`0.0.0.0:18050` → `127.0.0.1:18050` → `127.0.0.1:18051..18055`);退出时 `notifier.shutdown()` + `step_log.shutdown()` + `mgr.shutdown()` + `device_discovery.shutdown()` + `device_battery.shutdown()` + 停 uiautodev |
> ⚠️ **阶段 A~F 在 import 期就会起线程/调度器**,只有 uiautodev 拉起与预连接在 `__main__` 分支。以 WSGI 方式 import 本模块会得到"半个启动"的进程——本地调试请直接 `python web_server.py`。
---
## 3. 线程与并发模型
### 3.1 常驻线程一览
| 名称 | 启动位置 | 职责 | 周期 |
|------|---------|------|------|
| Flask 请求线程 | `app.run(threaded=True)` | 每请求一线程 | — |
| **任务调度器** `BackgroundScheduler` | `TaskManager.__init__` | cron 触发 / 停止任务 | 按 cron |
| **巡检调度器** `BackgroundScheduler` | `web_server.py` | 经验库 AI 巡检 | 每日 03:47 |
| `worker-watchdog` | `start_watchdog()` | `running/connecting` 心跳超时 → 标 `error` | 30s 检查 / 120s 阈值 |
| `device-discovery` | `device_discovery.init_app` | 网段扫描 + 断联重连 | 首轮延迟 15s,之后 interval(默认 60s) |
| `device-names` | `device_pool.init_app` | 刷 `serial → 名称` 内存快照(通知显示设备名用;只查库) | 60s(另有启动/增删改名时主动刷) |
| `_refresh_models_bg` | `device_pool.init_app` | 启动后采集全部在线设备型号 | 一次性(3s 后) |
| `device-battery` | `device_battery.init_app` | 采设备电量(`dumpsys battery` 只读)+ 低电量告警 | 首轮延迟 20s,之后 interval(默认 60s) |
| `BaseWorker` × N | `TaskManager._run_with_retry` | 单设备任务执行 | 任务期 |
| `_run_with_retry` × N | 同上 | 单设备重试循环 | 任务期 |
| `fg-scan-once` | `_ForegroundScanner.scan_once` | 前台 App 扫描 | 手动触发,`Event` 防重入 |
| `apk-install` | `ApkManager.install` | 并发 5 台安装 APK | 安装期,全局单任务 |
| Agent 执行线程 | `web/agent_api.py` | AI 控制台一轮会话 | 按需 |
| 巡检手动线程 | `web/agent_api.py` | 手动触发巡检 | 按需 |
| **通知 dispatcher** | `core/notifier.init_app` | 通知聚合 + 每 hook 限流 + 折叠摘要 | 常驻 1 个 |
| **通知 sender ×3** | 同上 | 真实发 webhook(退避重试、环形记录) | 常驻 3 个 |
| **步骤明细写线程** | `core/step_log.init_app` | 批量落库 `task_step_log`(队列满丢弃并计数) | 常驻 1 个 |
两个 APScheduler 相互独立,时区均固定 `Asia/Shanghai`。
### 3.2 锁与并发保护
| 锁 | 位置 | 保护对象 | 说明 |
|----|------|---------|------|
| `_ADB_LOCK` | `core/adb_helper.py` | adb connect 串行化 | connect 很快,串行不影响整体并发;不覆盖长命令 |
| `_WORKERS_LOCK` | `core/device_worker.py` | 全局设备状态表 `_WORKERS` | "检查+更新"在同一锁内,避免与任务启动竞态 |
| `TaskManager._lock` | `core/task_manager.py` | `_running` / `_stop_requested` | **抢占时禁止在锁内调 `stop_device`(死锁)** |
| `_engine_lock` | `core/ocr.py` | OCR 引擎懒加载 + 推理串行 | 推理 0.2~0.5s,锁开销可忽略 |
| `_scan_lock` / `_stop_event` | `core/device_discovery.py` | 定时/手动扫描互斥 | `acquire(blocking=False)` |
| `_status_cache_lock` | `core/task_manager.py` | 状态缓存(TTL 5s) | 避免 `/api/status` 每次都查库 + adb |
| `device_battery._lock` | `core/device_battery.py` | 电量缓存 `_cache` + 告警档位 `_tiers` | 采集线程 / `/api/status` / 告警状态机共用 |
**无锁部分**:`device_pool` 与 `models` 不持显式锁,依赖"每次操作独立 app context" + 数据库自身的并发控制(MySQL 下是 InnoDB 行锁 + READ COMMITTED,回退 SQLite 时是 WAL + `busy_timeout=5000`)。
### 3.3 错峰与心跳
- **错峰启动**:`_START_STAGGER_SEC = 0.2`,第 i 台设备延迟 `i × 0.2s` 启动(100 台 ≈ 20s 铺开),避免批量触发时的 adb 连接风暴。
- **心跳看门狗**:任何状态写入都会刷新 `last_heartbeat`;`running/connecting` 设备超过 120s 无心跳 → `status="error"` + `last_error="心跳超时…"`。长耗时的业务循环必须周期性 `self.heartbeat()`(`set_action` / `set_progress` 也会刷新)。
- **单实例约束**:同一 serial 同时只有一个 worker(`TaskManager._running`);重复触发同任务跳过,不同任务未开抢占也跳过。
---
## 4. 设备生命周期
### 4.0 设备身份:名称 + 指纹(2026-09-11)
设备池原以 **serial(IP)当身份**,设备一换 IP 旧记录就成了连不上的"僵尸条目",分组与
serial 模式的任务还吊着死地址。现在拆成三层:
| 概念 | 是否稳定 | 作用 |
|------|---------|------|
| `name`(名称,**必填唯一**) | 稳定 | 人可读身份;分组/任务/日志按名称认设备 |
| `fingerprint`(`ro.serialno`) | 稳定 | **机器识别**:认出"这是同一台设备" |
| `serial`(IP:5555 / USB 序号) | **可变** | 当前连接地址 |
**认领**(`device_pool.claim_device` 自动 / `relocate_device` 人工):指纹命中或人工指认后,
把旧记录迁到新地址(名称/型号/备注/启用状态/添加时间全保留)并同步引用。
> ⚠️ **引用同步必须同时改库与内存**:分组、任务在 `TaskManager` 里还有一份内存副本,
> **调度用的是内存对象**——只改库不重启不生效(表现为"分组里少一台、任务仍跑向旧地址")。
> 因此 `device_pool` 迁址后回调 `TaskManager.sync_device_serial`,由装配层用
> `device_pool.set_move_hook(...)` 注册;`device_pool` 不能反向 import `task_manager`(循环依赖)。
### 4.1 入池(三条路径)
| 路径 | 入口 | 过程 |
|------|------|------|
| 手工添加 | `POST /api/devices/pool/add` | `device_pool.add_device`(upsert)→ 有 `:` 则 `adb connect` → 后台采集型号 |
| 自动发现确认 | `POST /api/devices/discovery/confirm` | 扫描写 `pending_device` → 确认后 `add_device` + 删 pending + connect + 采型号 |
| 启动预连接 | `_preconnect_pool_devices()` | 进程启动时并发 connect 池内网络设备(仅 `IP:5555`) |
> 任何一条入池路径在设备可连时都会读取**设备指纹**;指纹命中池中已有设备 = 同一台换了地址
> → 走**认领**(§4.0),不新增记录。
断联设备的自动重连由发现线程每轮执行(只重连 `IP:5555`)。
### 4.2 可用性判定
```
list_configured() 设备池中 enabled=True 的 serial
list_online() 本机 adb devices 中 state=device(池内有 USB 设备时并查 220 远程 adb server)
list_ready() 两者交集 ← 调度 "all" 模式取这个
```
### 4.3 执行期状态字段
设备状态存在内存注册表 `_WORKERS[serial]`(**不落库,重启即清零**):
| 字段 | 含义 |
|------|------|
| `status` | `idle` / `connecting` / `running` / `done` / `error` / `released` / `failed` |
| `serial` / `model` / `device_name` | 标识 |
| `task_job` / `attempt` / `max_attempts` | 当前任务与重试进度 |
| `current_action` / `progress` | 当前动作与进度(前端直接渲染) |
| `last_error` / `last_warning` | 最近错误 / 选择器健康告警 |
| `last_heartbeat` / `end_time` | 看门狗与时长上限 |
| `present` / `ready` | 是否在线(对外 `/api/status` 字段) |
### 4.4 状态迁移
**Worker 侧**(`BaseWorker.run`):
```
connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ setup ──▶ run_task ──▶ teardown
│
未被 stop ──▶ done │
异常 ──▶ error(DeviceOfflineError 单独分类,不重试)
finally ──▶ 仅当仍为 running/connecting 时置 released
```
> `finally` 里的状态判断是为了**不覆盖业务结果**(`done`/`error` 必须保留)。
**调度侧**(`_run_with_retry`):worker 结束后读 `status` → `done` 即成功返回;否则按 `max_attempts` 重试(`[transient]` 错误额外加长退避)→ 重试耗尽置 `status="failed"`,并把**真实失败原因**拼进 `last_error`(截断 200 字符)。
### 4.5 释放
`STFDevice.release()` 是**空实现**——直连模式下**绝不 disconnect**(共享 adb transport 红线)。类名 `STFDevice` / `STFError` 是 STF 时代的历史命名,功能上已与 STF 无关。
---
## 5. 任务调度链路
### 5.1 完整调用链
```
① 注册 add_job / update_job / toggle_job
→ _add_cron:scheduler.add_job(_on_cron_trigger, CronTrigger.from_crontab(...),
id=f"job_{id}_start", replace_existing=True)
(cron_stop 额外注册 job_{id}_stop;启动时 _load() 为 enabled 任务重注册)
② 触发 APScheduler → _on_cron_trigger(job_id)
→ 任务存在?→ _in_run_window(schedule)?→ _run_job(job)
③ 解析 job.resolve_serials(self) # all=池内在线 / group=分组∩池 / serial=指定
→ 为空则 warning 返回
④ 任务类 get_task_class(job.task_type) → 未知则 error 返回(run_job_now 会直接报错)
→ task_cls();max_attempts = max(1, retry.max_attempts)
⑤ 铺开 对每台设备起线程 _run_with_retry(task, serial, job, ..., idx * 0.2s)
⑥ 单设备 _run_with_retry:
sleep(错峰) → 停止检查 → 单实例/抢占判定 → 登记 _running[serial]
→ _update_status(task_job=..., attempt=...)
→ worker = task.create_worker(serial, job.params)
→ worker.start() → worker.join()
→ 读 status:done 成功;否则重试([transient] 退避 max(delay,120)s)
→ 耗尽:status="failed" + last_error(含真实原因)
→ finally:清停止标志;本任务若是抢占任务则归还设备(重跑被抢占任务)
⑦ 上报 worker 内 _update_status → 内存注册表
→ TaskManager.get_status(5s 缓存)
→ _merge_status(合并池信息、清理陈旧条目)
→ GET /api/status → 前端 5s 轮询渲染
⑧ 停止 stop_device / stop_all → _stop_requested.add + worker.stop()(置 Event)
业务循环检查 self.stopped()
⑨ 下次运行时间 next_run_of → _next_run_time(考虑运行窗口,最多向后探测 200 次)
```
### 5.2 抢占机制
任务参数 `preempt=true` 时:`all` 模式目标集合变为"全部在线池内设备"(含正在跑的);遇到设备已被占用时在**锁外**调 `stop_device` 并最多等 30s 接管;本任务结束后自动重新启动被抢占的任务(`preempted_job` 必须定义在重试循环外,否则归还信息会丢)。
---
## 6. 前端架构
### 6.1 单页应用
- 主页面 `templates/admin/monitor.html`:一个内联 `<style>` + 7 个 Tab 面板 + 10 个模态框容器 + 16 个 `<script src>`
- 独立页面:`login.html`(登录)、`wall.html`(监控大屏,**完全自包含**,自带 CSS/JS,不加载 `static/admin/*.js`)
- 服务端内联页:`GET /locate`(设备端定位大字页,免登录)
- 响应头强制 `no-store`,避免后台改版后浏览器拿旧页面
### 6.2 Tab 与子分栏
| 顶级 Tab | `data-tab` | 权限 | 子分栏 |
|---------|-----------|------|--------|
| 监控 | `monitor` | 登录即可 | — |
| 任务 | `tasks` | 登录即可(写操作需 `tasks`) | `plan` 任务计划 / `actions` 自定义动作 / `actioncfg` 动作配置 / `dedup` 去重记录 |
| 日志 | `logs` | `logs` | — |
| 用户 | `users` | `admin` | — |
| 工具 | `tools` | `admin` | `clipboard` / `adb` / `ts` / `apks` / `appver` / `devapps` / `devpool` / `groups` |
| AI 控制台 | `agent` | `admin` | — |
| 系统 | `system` | `admin` | `backup` 数据备份 / `restore` 导入恢复 / `notify` 通知 Webhook |
子分栏会记住上次选中位置(`_activeSubs`);`devpool` 子分栏自带 10s 轮询,切走即停。
### 6.3 JS 分工
| 文件 | 职责 | 备注 |
|------|------|------|
| `base.js` | esc / CSRF / 权限 / API 封装 / Toast / Tab 与子分栏切换 / 模态框 / 常量表 | 所有模块的公共底座 |
| `markdown.js` | 轻量 Markdown 渲染(`renderMarkdown`) | 无 CDN 依赖;**先转义再套标记** |
| `list.js` | 统一列表组件:搜索 + 分页 + 排序 | 状态注册表 `_LIST_PAGERS`;`setListPager` **不重置**页码/搜索/排序(避免轮询刷新打断用户) |
| `monitor.js` | 监控页:设备表(含**电量列**,按 `battery.tier` 上色)、批量操作、异常汇总、任务概况卡片(含覆盖设备 chip) | 5s 轮询 + 脏检查(签名不变不重渲染) |
| `editor.js` | 步骤编辑器(拖拽 / 参数表单 / 条件分支 / 元素抓取 / 测试此步骤)+ `saveTask` | 最大的前端文件;`_stepEditor` 单例;设备选择卡 `_devCard` 供「抓取元素」「测试此步骤」共用(**名字优先**,型号·地址作副标题) |
| `tasks.js` | 任务 Tab:任务 CRUD / 调度解析 / 运行窗口 + 自定义动作 | |
| `tools.js` | 工具 Tab:剪贴板、adb 终端、Tailscale、设备池、自动发现、远程看屏、**电量监控配置**(阈值/间隔/立即采集) | 电量表单只回填一次,10s 轮询不冲掉用户正在输的值 |
| `dedup.js` | 任务 Tab·**去重记录**:账本列表 + 统计("今天做了几个号")+ 删单条/清空任务 | 数据来自 `done_mark` 表,见 [TASK_DEV.md](TASK_DEV.md) §4.6 |
| `apps.js` | 应用管理:APK 上传/安装/删除、设备已装应用 | |
| `admin.js` | 分组、日志、用户 + **全局初始化入口**(末尾 `initCsrf(); loadMe(); showTab('monitor')`) | |
| `agent.js` | AI 控制台·聊天:会话、SSE 流、Markdown / 推理链 / token 渲染、实时画面、经验库 / 动作库 | |
| `taskgen.js` | AI 控制台·**AI 建任务**子页:发起探索、回放工具卡、草稿预览(步骤树/提醒/证据)、打开步骤编辑器预填 | 运行槽与聊天共用,按 `mode` 互斥订阅事件流 |
| `system.js` | 系统 Tab:备份导出 / 导入预览与应用 | |
加载顺序见根 [README](../README.md);都是全局脚本(非 ES module),靠加载顺序保证依赖。
### 6.4 实时通道
| 通道 | 场景 | 机制 |
|------|------|------|
| **轮询** | 设备状态(5s)、日志(3s,可关)、自动发现(10s)、截图(3s)、APK 安装(3s)、AI 运行状态(8s) | `setInterval`,切 Tab 时统一清理 |
| **SSE** | AI 控制台一轮会话 | `EventSource /api/agent/stream`;`onerror` **刻意不结束运行**,靠自动重连 + 服务端事件队列补发 |
| **MJPEG** | 实时看屏 | `img.src = /api/screen/stream?...`,浏览器原生长连接 |
### 6.5 前端权限
三条并存:① `data-perm` 属性(`loadMe()` 时统一隐藏无权限元素);② `_can(perm)`(动态拼 HTML 时决定是否出按钮);③ 后端装饰器兜底(403)。
> 前端隐藏只是体验优化,**安全完全依赖后端**;`data-perm` 只在页面加载时求值一次,改权限后需刷新页面。
---
## 7. 关键设计决策
| 决策 | 为什么 | 代价 / 注意 |
|------|--------|------------|
| **数据库走 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 的历史坑,清单可控 | 需自己处理在线状态、型号、自动发现 |
| **备份"重启生效"** | 调度器 / worker / 设备池都把任务与设备**缓存在内存**里,整库替换后内存副本全部失效 | 导入后必须重启(重启时消费恢复任务)。SQLite 时代还有"Windows 无法替换被持有的文件"这层原因,改用 MySQL 后只剩内存态这一层 |
| **uiautodev 独立子进程** | 元素抓取是重活,隔离崩溃、可单独重启 | 固定端口 20242;PID 文件防重复拉起(杀之前必须校验 cmdline,防误杀同容器进程) |
| **MCP 独立端口** | 外部 AI 用标准协议接入,不侵入 Web 会话 | MCP 端点自身无鉴权,靠网络隔离;写操作用开关门控 |
---
## 8. 技术红线与实现位置
| 红线 | 为什么 | 代码里的体现 |
|------|--------|-------------|
| **绝不 `adb kill-server`** | 会断掉所有设备的 adb transport,运行中任务全废 | `adb_helper` 只 connect 不 kill;Web 层硬拦截 `kill-server` / `disconnect` 字符串 |
| **绝不对 `IP:5555` 设备 `disconnect`** | 该地址的 adb transport 是共享的 | `adb_disconnect` 全项目**零调用方**;`STFDevice.release()` 空实现 |
| **空闲设备扫描不碰 adb** | 避免扰动共享连接 | 前台扫描对空闲设备直接返回"空闲";设备发现用 socket 探测而非 adb |
| **adb key 不变** | 设备信任当前 key,换 key 全部变 unauthorized | 部署沿用既有 `~/.android/adbkey` |
| **生产只读原则** | 220 是生产机 | 任何写操作(重启 / pull / 改文件)都需负责人确认 |
| **备份覆盖清单** | 漏登记 = 等于没备份 | `core/system_backup.py` 的 `SUMMARY_TABLES`,导出/导入双向自检(见 [DATA_MODEL.md](DATA_MODEL.md) §6) |
| **文档同步** | 文档落后会误导开发与运维 | 功能/配置/接口改动的同一个 commit 里更新 `doc/`(索引见 [doc/README.md](README.md)) |
---
## 9. 已知问题与"坑"
### 9.1 代码缺陷(截至 2026-09-10,详见 [backlog/TODO.md](backlog/TODO.md))
| 问题 | 现象 | 位置 |
|------|------|------|
| `GET /locate` 返回 500 | 设备定位大字页打不开(NameError:`render_template_string` / `_esc` 未导入) | `web/monitor.py` |
| `POST /api/device/locate`(`show=true`)失败 | 在设备浏览器打开定位页那步抛异常(`urllib` 未导入),被转成 502 | `web/monitor.py` |
| CSRF 未实际启用 | 前端会取并携带 `X-CSRF-Token`,但服务端**没有注册** `before_request` 校验 | `web_server.py` 只 import 了 `_csrf_protect` |
| 回归脚本在 Windows 不可用 | `scripts/regression_test.py` 用了 `signal.alarm`(Windows 无此 API),直接报错退出 | `scripts/regression_test.py` |
| 前端 `--card-line` 未定义 | AI 控制台相关深色容器边框失效(该变量只在 wall.html 定义) | `templates/admin/monitor.html` 的 `:root` |
| `countParts` 未定义 | 进度为 0 时可能抛 ReferenceError(被上层 try/catch 吞掉,用户无感) | `static/admin/monitor.js` |
### 9.2 代码里写明的坑(改代码前务必读)
| 坑 | 说明 |
|----|------|
| **跨线程访问 DB 必须自推 app context** | 后台线程用 `with app.app_context()`(各模块 `_ctx()` / `_db()` 已封装) |
| **`debug=False` 不热重载** | 改 `core/`、`tasks/` 后必须重启;改 JS 需强刷浏览器 |
| **Windows adb 输出不能用 `text=True`** | adb 输出含非 GBK 字节会崩;必须收 bytes 再解码 |
| **`u2.connect` / `d.info` 可能永久 hang** | 必须放 `ThreadPoolExecutor` 里加超时 |
| **Windows TCP 端口耗尽(WinError 10048)** | 属临时错误,重试需更长退避(代码识别后打 `[transient]`,退避 `max(delay,120)` 秒) |
| **抢占时不能在锁内 `stop_device`** | 会死锁 |
| **`preempted_job` 必须在重试循环外** | 否则被抢占任务永不归还 |
| **删除任务/分组必须显式删行** | 只 upsert 的话,重启会从库里"复活" |
| **旧 JSON 迁移后必须归档** | 否则用户删空数据后重启又复原 |
| **XPath 位置谓词语义** | `//*[@id="x"][k]` = 父节点下第 k 个;`(//*[@id="x"])[k]` = 第 k 个匹配。抓取器生成后者,执行器会自动纠正历史写法 |
| **容器重启后 PID 会被复用** | 杀残留 uiautodev 前必须校验 `/proc/<pid>/cmdline`,否则可能误杀同容器进程 |
| **`.env` 用 `setdefault` 注入** | 真实环境变量优先于 `.env` |
| **备份必须重启才生效** | 恢复任务在 `init_db` 之前被消费 |
---
## 10. 扩展点(改哪里)
| 想做什么 | 改哪里 | 别忘了 |
|---------|--------|--------|
| 加一种步骤类型 | `tasks/generic/task.py` 的 `STEP_TYPES` + `_exec_<type>`;`static/admin/editor.js` 的 `STEP_LIB` | 两处保持一致;更新 [TASK_DEV.md](TASK_DEV.md) |
| 加一个专属任务类型 | `tasks/<app>/`(照 `tasks/generic/` 结构)+ `tasks/__init__.py` 注册 | 重启生效;更新 [TASK_DEV.md](TASK_DEV.md) |
| 加一个 HTTP 接口 | 对应功能域的 `web/xxx_api.py` | 加鉴权装饰器;更新 [API.md](API.md) |
| 加一个蓝图 | 新建 `web/xxx_api.py` + 在 `web/__init__.py` 注册 | 更新 [API.md](API.md) 与本文 §1 |
| 加一张表 | `core/models.py` 模型 + `SCHEMA_MIGRATIONS`(老库) | **登记进备份覆盖清单** + [DATA_MODEL.md](DATA_MODEL.md)(红线) |
| 加一个常驻线程 | 参考 `device_discovery` 的 `init_app` / `shutdown` 模式 | 更新本文 §3 与 [DEVELOPMENT.md](DEVELOPMENT.md) |
| 加一个前端模块 | `static/admin/<name>.js` + 在 `monitor.html` 按序引入 | 更新本文 §6 与 [DEVELOPMENT.md](DEVELOPMENT.md) |
| **加一个通知事件** | `core/notify_events.py` 的 `EVENTS` 加一条 + 触发点调 `notifier.notify(key, **fields)` | 事件目录要做全、默认不启用;**notify 必须放在锁外**(见 [NOTIFY.md](NOTIFY.md) §7) |
| 加一种通知格式 | `core/notifier.py` 的 `ADAPTERS` 注册一个 `BaseAdapter` 子类 | 补字节上限/默认限流;更新 [NOTIFY.md](NOTIFY.md) §5 |
| 加一个 MCP 工具 | `mcp_server/mcp_server.py`(需要时加 `direct_ops.py`) | 写操作要挂门控三连;更新 [MCP.md](MCP.md) |
| 加一个配置键 | `config.py`(程序级)或 `.env`(密钥类) | 更新 `.env.example` + [DEVELOPMENT.md](DEVELOPMENT.md) 配置速查 |