docs: doc/ 全量重整——按现状重写并建立文档索引;项目统一更名 auto_control

背景:文档长期落后于代码(Tab 数、任务类型、接口示例等多处与现状不符),
且信息分散重复。这次按当前代码状态逐篇重写,并建立统一的文档体系。

新增
- doc/README.md:文档总索引(文档地图 / 推荐阅读路径 / **文档维护约定**)
- doc/DATA_MODEL.md:数据模型(7 张模型表 + 5 张非模型表、迁移机制、app_meta 键、
  数据目录、备份覆盖清单与双向自检)
- doc/AI_CONSOLE.md:AI 控制台机制(会话与 SSE、经验库/动作库蒸馏与召回、巡检、
  Markdown 渲染、推理链、token 统计、故障排查)

重写(按现状,去掉过时与重复)
- README.md:7 个 Tab、18 种步骤、设备生命周期、调度/窗口语义、常见问题;修掉
  「6 个 Tab / 分组为顶级 Tab」等过时内容与损坏的目录树
- doc/ARCHITECTURE.md:补启动装配顺序(import 期副作用、A~G 七阶段)、线程与锁清单、
  设备状态机、调度全链路、前端结构与实时通道、设计决策、**已知缺陷与踩坑清单**、扩展点
- doc/API.md:按蓝图重建「接口总索引」(107 条路由含鉴权)+ 分域详细说明 +
  非 JSON 响应汇总 + 错误分支速查
- doc/TASK_DEV.md:18 种步骤全表(参数/默认值/语义)、容器与公共参数、
  选择器与 XPath 序号语义、抓取器建议规则、新增任务类型骨架
- doc/DEPLOY.md:容器入口 start.sh 三件事、发布流程与检查清单、备份覆盖红线、
  按现象分类的故障排查
- doc/DEVELOPMENT.md:流程/红线/本地开发/**测试与写测试的约定**/配置速查/文档同步
- doc/MCP.md:19 个工具的参数级清单、坐标空间、写门控三连、安全与审计
- doc/MCP_DESIGN.md、doc/AI_TASK_GEN.md:标注设计 vs 实现现状,补交叉链接
- doc/backlog/TODO.md:新增「已知缺陷」小节(含复现与影响)+ 已完成留档
- .env.example:按代码实际读取的键重写(补 USB/DISCOVERY/MCP/AGENT,删死配置)

其它
- 项目名统一 auto_control:README/文档/scripts/pack.py 产物名;代码内的
  doc 章节引用(templates/admin/monitor.html)同步更新
- 校验:16 篇文档 156 条相对链接全部可解析;文档中的关键数字与代码核对一致
  (19 个 MCP 工具 / 18 种步骤 / 12 张备份表 / 1 种任务类型)
This commit is contained in:
2026-09-10 22:19:18 +08:00
parent f4b5316436
commit 24d57d3b96
18 changed files with 2664 additions and 3667 deletions
+294 -363
View File
@@ -1,441 +1,372 @@
# 架构详解
# 架构详解(ARCHITECTURE)
本文面向想深入理解 `auto_control` 内部设计的开发者。如果你只想使用,看 [README.md](../README.md) 即可。
> 适用读者:要改后端 / 前端 / 任务引擎的开发者。
> 相关文档:[DATA_MODEL.md](DATA_MODEL.md)(表结构)、[API.md](API.md)(接口清单)、[DEVELOPMENT.md](DEVELOPMENT.md)(流程与红线)。
> 文中引用为 `文件:行号`,以当前代码为准;**行号会随改动漂移,函数名与常量名是稳定锚点**。
---
## 1. 分层设计
## 1. 总览
平台按"配置 / 核心 / 任务 / 前端 / 数据 / 日志 / 工具"分层,职责清晰、互不交叉:
### 1.1 分层
| 层 | 路径 | 职责 |
|----|------|------|
| 配置层 | `config.py` | 项目根配置:adb 路径、web 端口、USB 远程 adb server 等基础设施。**不放任务参数** |
| 核心层 | `core/` | 框架运行时:日志、设备池、adb 操作、Worker 基类、任务管理器、u2 辅助、Action 基类 |
| 任务层 | `tasks/` | 每个 App 一个子包,自包含 `task.py` + `actions/`,互不依赖 |
| 前端层 | `templates/admin/` | 单页应用(纯 HTML+CSS+JS,无框架) |
| 数据层 | `data/` | SQLite 持久化 |
| 日志层 | `logs/` | 四类日志,10MB 滚动保留 5 份 |
| 工具层 | `bin/adb/` | adb 可执行文件 |
| 脚本层 | `scripts/` | 实用脚本 |
```
┌──────────────────────────────────────────────────────────────────────┐
│ 表现层 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 │
└───────────────────────────┬──────────────────────────────────────────┘
│
┌───────────────────────────▼──────────────────────────────────────────┐
│ 基础层 adb_helper · u2_helper · uiauto_helper · ocr · clipboard │
│ models(SQLite)· logger · config │
└──────────────────────────────────────────────────────────────────────┘
▲
┌───────────────────────────┴──────────────────────────────────────────┐
│ 任务定义层 tasks/(BaseTask 注册表 + generic/ 通用步骤引擎) │
└───────────────────────────┬──────────────────────────────────────────┘
▲ ▲
┌───────────────────────────┴────────┐ ┌───────────┴──────────────────┐
│ MCP Server(mcp_server/,:8033) │ │ AI Agent(mcp_agent/) │
│ 19 个 de_* 工具,供外部 AI 调用 │ │ OpenAI 兼容模型 → MCP 工具 │
└────────────────────────────────────┘ └──────────────────────────────┘
```
### 分层原则
### 1.2 各层职责边界
- **任务自包含**:每个任务的参数、Worker、操作都放在 `tasks/<app>/` 下,不污染全局
- **核心不依赖任务**:`core/` 不 import `tasks/`,任务通过注册机制接入
- **配置最小化**:`config.py` 只放基础设施配置,任务参数在各自 `task.py` 顶部
| 层 | 做什么 | 不做什么 |
|----|--------|---------|
| 表现层 | 渲染、交互、轮询/流式拉取、按权限隐藏入口 | 不校验权限(只隐藏);不做业务判断 |
| Web 层 | 参数校验、鉴权装饰器、JSON 序列化、调用领域层 | 不直接操作 adb/u2(`monitor` 的看屏/截图除外,那本身就是"设备操作") |
| 领域层 | 调度、并发、重试、状态机、持久化 | 不感知 HTTP |
| 基础层 | adb / u2 / OCR / 数据库 / 日志的原子能力 | 不含业务规则 |
| 任务定义层 | 任务类型注册 + 具体任务执行逻辑 | 不感知调度与设备获取(`BaseWorker` 已封装) |
**装配方向**:`web_server.py` 是唯一组装点;`web/context.py` 注入 `mgr` / `apk_mgr` / `device_pool`,避免 Web 层与领域层循环 import。
---
## 2. 数据流
## 2. 启动与装配顺序
```
┌──────────────┐ 创建/编辑任务 ┌─────────────┐ 分发 worker ┌──────────────┐
│ 单页应用前端 │ ───────────────► │ TaskManager │ ─────────────► │ Worker(设备) │
│ (monitor.html│ └─────────────┘ └──────────────┘
│ fetch+DOM) │ ▲ │
└──────────────┘ │ 状态/心跳 │ u2 操作
│ │ ▼
│ JSON API │ ┌─────────────────┐
▼ ┌──────────────┐ │ 设备 adb │
┌──────────────┐ │ 看门狗监控 │ │ (IP:5555 直连 │
│ web/ 蓝图包 │ └──────────────┘ │ / USB 远程) │
│ (Flask API) │ └─────────────────┘
└──────────────┘
│
▼
┌──────────────┐
│ data/users.db│ SQLite 持久化(用户/分组/任务/设备池/待连接设备/自定义动作/APK记录/AI会话/经验库 + app_meta KV)
└──────────────┘
```
理解启动顺序很关键——**很多副作用发生在 import 期**。
### 任务执行流程
### 2.1 阶段 A:import 期副作用(`web_server.py:6-25`)
1. 前端创建 TaskJob(HTTP POST `/api/jobs`)
2. `TaskManager` 保存到 SQLite,如启用 cron 则注册到 APScheduler
3. 手动执行或 cron 触发时,`_run_job` 解析目标设备列表
4. 每台设备起一个线程 `_run_with_retry`,含重试循环
5. 线程内 `task.create_worker()` 创建 Worker,`worker.start()` 启动
6. `BaseWorker.run()` 执行设备生命周期:占用 → 连接 → setup → run_task → teardown → 释放
7. Worker 通过 `_update_status()` 实时上报状态到全局 `_WORKERS` 字典
8. 前端轮询 `/api/status`(5 秒缓存)获取设备 + Worker 状态
| 顺序 | 触发 | 副作用 |
|------|------|--------|
| 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-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)` |
### 2.3 阶段 E~G:蓝图、巡检调度器、真正启动
| 阶段 | 位置 | 做了什么 |
|------|------|---------|
| **E** | `:64-67` | `context.init(...)`;`register_blueprints(app)`(10 个蓝图);`agent_api.set_app(app)`(供后台线程推 app context) |
| **F** | `:71-80` | **第二个独立 APScheduler**:`CronTrigger(hour=3, minute=47)` 挂经验库巡检;失败仅 warning |
| **G** | `:253-265`(`__main__`) | `_ensure_uiauto_running()`(拉起 uiautodev:20242,写 `data/uiauto.pid`,`atexit` 清理)→ `_preconnect_pool_devices()`(后台并发 connect 池内网络设备)→ `_run_server()`(候选端口依次 bind:`0.0.0.0:18050` → `127.0.0.1:18050` → `127.0.0.1:18051..18055`);退出时 `mgr.shutdown()` + `device_discovery.shutdown()` + 停 uiautodev |
> ⚠️ **阶段 A~F 在 import 期就会起线程/调度器**,只有 uiautodev 拉起与预连接在 `__main__` 分支。以 WSGI 方式 import 本模块会得到"半个启动"的进程——本地调试请直接 `python web_server.py`。
---
## 3. 核心模块详解
## 3. 线程与并发模型
### 3.1 DevicePool(`core/device_pool.py`)
### 3.1 常驻线程一览
设备池(已摘除 OpenSTF):SQLite `devices` 表 = 设备清单,本机 adb = 在线状态。
| 名称 | 启动位置 | 职责 | 周期 |
|------|---------|------|------|
| 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) |
| `_refresh_models_bg` | `device_pool.init_app` | 启动后采集全部在线设备型号 | 一次性(3s 后) |
| `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` | 手动触发巡检 | 按需 |
**关键设计**:
- `list_configured()` — 清单(管理页维护,enabled=False 不参与调度)
- `list_online()` — 本机 adb 在线设备;池内有 USB 设备(serial 无冒号)时合并 220
远程 adb server(`USB_ADB_HOST:PORT`,host 网络模式 5037)状态
- `list_ready()` — 清单 ∩ 在线(任务调度用)
- CRUD — `add_device`(upsert)/ `remove_device` / `set_enabled`
- 单实例互斥由 `TaskManager._running[serial]` 内存锁保证(无跨实例占用概念)
- 全模块不 connect/kill-server/disconnect(遵守共享 adb transport 红线)
两个 APScheduler 相互独立,时区均固定 `Asia/Shanghai`。
### 3.2 STFDevice + BaseWorker(`core/device_worker.py`)
### 3.2 锁与并发保护
**STFDevice** — 单设备生命周期管理:
- `acquire()`:IP:5555 直连 adb connect;USB(serial 无冒号)校验 220 远程 adb server 可见性
- `release()`:无操作(不 disconnect,红线)
- 互斥由 TaskManager `_running` 保证,设备池负责在线判断
| 锁 | 位置 | 保护对象 | 说明 |
|----|------|---------|------|
| `_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 |
**BaseWorker** — 通用 Worker 基类(继承 threading.Thread):
**无锁部分**:`device_pool` 与 `models` 不持显式锁,依赖"每次操作独立 app context + SQLite WAL + `busy_timeout=5000`"。
```
run() 主循环(不要重写):
1. acquire 设备
2. u2.connect(30s 超时保护)
3. setup(d) ← 子类可选钩子
4. run_task(d) ← 子类必须实现
5. teardown(d) ← 子类可选钩子
6. finally: release 设备
```
### 3.3 错峰与心跳
**超时保护**:
- `u2.connect()` 用 `ThreadPoolExecutor + 30s 超时`,防止 atx-agent 无响应永久 hang
- `d.info` 用 `ThreadPoolExecutor + 10s 超时`
- **错峰启动**:`_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`);重复触发同任务跳过,不同任务未开抢占也跳过。
**心跳看门狗**(`_Watchdog`):
- 后台线程,每 30 秒扫描一次
- Worker 超过 120 秒无心跳 → 标记 `error`
- 防止设备被占用却不干活
---
**全局状态注册表**(`_WORKERS`):
- `serial -> status dict`,线程安全(`_WORKERS_LOCK`)
- 供 `web_server` 读取实时状态,前端通过 `/api/status` 展示
## 4. 设备生命周期
### 3.3 TaskManager(`core/task_manager.py`)
### 4.1 入池(三条路径)
统一管理:任务类型注册、设备分组、任务计划、定时调度、重试、持久化。
**核心组成**:
| 组件 | 说明 |
|------|------|
| `scheduler` | APScheduler BackgroundScheduler,cron 触发任务 |
| `groups` | 设备分组(内存业务对象,持久化到 SQLite) |
| `jobs` | 任务计划(内存业务对象,持久化到 SQLite) |
| `_running` | 运行中的 worker(serial -> worker 信息) |
| `_stop_requested` | 用户请求停止的 serial 集合(阻止后续重试) |
| `_fg_scanner` | 前台 App 扫描器(不打扰设备) |
**调度模式**:
- `once`:不注册 cron,手动执行
- `cron`:注册启动 cron,到点启动所有目标设备
- `cron_stop`:注册启动 cron + 停止 cron,到点停止本任务 worker
**重试策略**:
- `DeviceOfflineError`:立即放弃,不重试(设备掉线短时间不会自愈)
- 其他异常:按 `retry.max_attempts` 重试,间隔 `retry.delay`
- 临时错误(端口耗尽):退避 max(delay, 120s)
- 用户停止:加入 `_stop_requested`,阻止任何后续重试
**并发控制**:同一 serial 同时只允许一个 worker,避免冲突。
**状态缓存**:`get_status()` 带 5 秒缓存,避免每次 /api/status 都查库/adb 阻塞前端。
### 3.4 前台 App 扫描器(`_ForegroundScanner`)
**设计原则:不打扰设备**,扫描不会让设备退出当前 App。
| 设备状态 | 处理方式 | 是否打扰 |
|---------|---------|---------|
| worker 运行中(IP:5555) | 复用已有 ADB 连接查询 | 否 |
| worker 运行中(USB) | 经 220 远程 adb server 查询 | 否 |
| 完全空闲 | 返回"空闲"(不主动 connect) | 否 |
> **为什么不扫描空闲设备的前台 App**:IP:5555 的 adb transport 是共享的(历史与 STF provider 共用),外部 connect/disconnect 会扰动共享连接,遵守既有红线。
### 3.5 ADB 操作(`core/adb_helper.py`)
**全局锁串行化**:`_ADB_LOCK` 确保所有 adb 调用串行执行,避免多线程竞争 adb server。
**铁律:绝不 kill-server**:
- `adb kill-server` 会断开所有设备的 adb transport
- 全部设备连接被重建,影响所有运行中的任务
- 同理绝不 disconnect IP:5555(共享 transport 红线,见 DEVELOPMENT.md)
| 函数 | 说明 |
|------|------|
| `adb_connect(url, retries=5)` | adb connect(带重试,绝不 kill-server) |
| `adb_connect_light(url)` | 轻量 connect(单次尝试,扫描专用) |
| `adb_disconnect(url)` | adb disconnect |
| `screenshot(serial)` | 截图(adb exec-out screencap -p,只读安全) |
| `get_foreground_app(url)` | 获取前台 App 包名(dumpsys window) |
| `identify_device(serial)` | 让设备响铃识别 |
### 3.6 数据模型(`core/models.py`)
SQLAlchemy 模型,存于 `data/users.db`:
| 模型 | 表名 | 说明 |
| 路径 | 入口 | 过程 |
|------|------|------|
| `User` | user | 后台用户(Flask-Login 认证,Werkzeug 哈希密码;`is_admin` 管理员 + `perms` 业务权限位) |
| `DeviceGroup` | device_group | 设备分组(serials 存 JSON) |
| `TaskJob` | task_job | 任务计划(target/params/schedule/retry 存 JSON) |
| `CustomAction` | custom_action | 自定义动作(步骤打包,steps 存 JSON) |
| `ApkFile` | apk_file | APK 文件元信息 |
| `Device` | device | 设备池清单(替代 STF 池;enabled=False 不参与调度,含 model 型号列) |
| `PendingDevice` | pending_device | 自动发现「待连接池」(扫描发现、用户确认后才入正式池) |
| 手工添加 | `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`) |
> 表名默认取类名小写(models.py 未写 `__tablename__`)。另有三张非模型表,由原生 SQL 幂等创建、**不走 SCHEMA_MIGRATIONS**:
> - `app_meta`(KV):`_migrate_schema()` 内建表,存 `schema_version`、`discovery_*`、agent 配置 `agent_*` 等;
> - `agent_conversation` / `agent_experience` / `experience_audit`:AI 控制台会话 / 任务级经验(配方)/ 经验巡检(`web/agent_api.py` 顶部 `CREATE TABLE IF NOT EXISTS`)。
> - `agent_action`:**动作经验库**(命名动作 = 可复用单元,steps 用编辑器 schema 且带元素定位、禁坐标);由任务成功后从**成功步骤**蒸馏,执行前按名字/别名召回并注入(`web/agent_api.py` `_distill_actions/_find_actions`)。
>
> **备份覆盖(红线)**:持久化表须登记进 `core/system_backup.py` 的 `SUMMARY_TABLES`(→ 导出清单/预览可见、覆盖自检生效),并同步 `doc/DEPLOY.md` §3.5;未登记的表在备份预览里不可见,会被误判为"没备份"。
断联设备的自动重连由发现线程每轮执行(只重连 `IP:5555`)。
**数据库初始化**(`init_db`):
- 创建所有表
- 首次启动创建默认管理员 `admin/admin123`
- 自动迁移旧 `groups.json` / `jobs.json` 到 SQLite(迁移后归档为 `.migrated`)
- 版本化 schema 迁移(`SCHEMA_MIGRATIONS`,**当前到 v4**,见 `core/models.py`):结构变更必须追加迁移条目,`create_all` 只建新表不加列
### 4.2 可用性判定
### 权限模型
```
list_configured() 设备池中 enabled=True 的 serial
list_online() 本机 adb devices 中 state=device(池内有 USB 设备时并查 220 远程 adb server)
list_ready() 两者交集 ← 调度 "all" 模式取这个
```
- `User.perms` 存业务权限位 JSON 数组(`tasks`/`devices`/`apks`/`logs`),`is_admin=true` 拥有全部权限(`has_perm` 短路)
- 后端统一用 `@perm_required(PERM_X)` / `@admin_required` 装饰器拦截(web/auth.py),无权限返回 403;
查看类 GET 接口只要求登录;用户管理、adb 终端(`/api/adb/cmd`)强制 `admin_required`
- adb 终端安全红线:拒绝 `kill-server` / `disconnect`(共享 adb transport)
- 前端 `loadMe()` 拉取 `/api/me`,用 `data-perm` 属性隐藏无权限的 tab/按钮,行内按钮用 `_can(perm)` 判断
- 安全兜底:**前端隐藏只是 UX,权限强制在后端**;新增路由时按"写操作必须带权限装饰器"的约定
### 4.3 执行期状态字段
### 3.7 APK 管理(`core/apk_manager.py`)
设备状态存在内存注册表 `_WORKERS[serial]`(**不落库,重启即清零**):
APK 上传/解析/批量安装。
**设备连接策略(直连)**:
- 直接 `adb connect serial`(serial 是 IP:5555)
- 不经过占用/释放(单实例互斥由调度器内存锁保证)
- 安装后不主动 disconnect(共享 adb transport 红线)
**安装流程**:
1. 上传 APK → 保存到 `data/apks/` → pyaxmlparser 解析包名/版本 → 入库
2. 批量安装 → 后台线程 → 每台设备直连 adb install → 验证包名
3. 跳过 worker 运行中的设备(避免打断任务)
### 3.8 元素抓取(`core/uiauto_helper.py`)
封装 uiautodev 本地服务(端口 20242)的客户端。
| 函数 | 说明 |
| 字段 | 含义 |
|------|------|
| `is_running()` | 探测 uiauto2 服务是否运行 |
| `list_devices()` | 获取 uiauto2 已连接的设备列表 |
| `get_screenshot(serial)` | 获取设备截图(JPEG) |
| `get_elements(serial)` | 获取设备 UI 元素树(扁平化列表) |
| `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` 字段) |
元素树解析:递归提取每个节点的 `resource-id/text/content-desc/class/bounds` 等属性,并推荐最佳选择器(优先 xpath)。
### 4.4 状态迁移
**XPath 序号语义(重要)**:同一属性多个实例时,生成 **`(//*[@resource-id="x"])[k]`**(整体加括号 = 第 k 个匹配)。
不可写成 `//*[@resource-id="x"][k]`——那在 XPath 里是"**在其父节点中排第 k**",多实例时 `[2..n]` 会全部匹配不到
(2026-09-10 实测修复:抖音底部 4 个 tab 同 id,旧写法除 `[1]` 外全失效)。执行器 `tasks/generic/task.py`
对**历史遗留**的 `//*[@attr=…][k]` 形态做窄范围纠正(`_norm_legacy_xpath`,只改前缀、不动结构路径的兄弟序号)。
**Worker 侧**(`BaseWorker.run`):
### 3.9 屏幕 OCR(`core/ocr.py`)
```
connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ setup ──▶ run_task ──▶ teardown
│
未被 stop ──▶ done │
异常 ──▶ error(DeviceOfflineError 单独分类,不重试)
finally ──▶ 仅当仍为 running/connecting 时置 released
```
条件判断的 `ocr` 选择器实现:截屏 → RapidOCR(ONNX 推理,中英文模型随包内置)→ 关键词匹配 → 返回文字中心像素坐标(与 u2 `d.click` 一致)。
> `finally` 里的状态判断是为了**不覆盖业务结果**(`done`/`error` 必须保留)。
- **跨平台**(Windows/Linux/macOS),依赖 `rapidocr_onnxruntime`;服务器无显示器环境建议将 opencv-python 换成 opencv-python-headless
- 引擎懒加载单例 + 并发加锁(识别约 0.2-0.5s/次)
- 返回坐标约定:像素、原点左上
**调度侧**(`_run_with_retry`):worker 结束后读 `status` → `done` 即成功返回;否则按 `max_attempts` 重试(`[transient]` 错误额外加长退避)→ 重试耗尽置 `status="failed"`,并把**真实失败原因**拼进 `last_error`(截断 200 字符)。
### 3.10 Web 层蓝图包(`web/`)
### 4.5 释放
路由按功能域拆分(模块化开发底线,便于定位问题):
| 模块 | 职责 |
|------|------|
| `auth.py` | 登录/登出/CSRF/权限装饰器/页面路由(/、/wall) |
| `monitor.py` | 状态/运行控制/设备操作/远程看屏(流+缩略图+触控) |
| `tasks_api.py` | 任务计划/分组/自定义动作/元素抓取/步骤测试 |
| `admin_api.py` | 用户管理/日志 |
| `tools_api.py` | adb 终端/剪贴板注入/应用版本 |
| `devices_api.py` | 设备池管理 + 自动发现(扫描/确认/忽略/手动重连/型号采集) |
| `apks_api.py` | 应用管理 |
| `tailscale_api.py` | Tailscale 管理 |
| `agent_api.py` | AI 控制台:会话 / 运行 / SSE / 停止 / 配置 / 经验库与巡检 / **动作库**(读写 `agent_conversation`、`agent_experience`、`agent_action`、app_meta `agent_*`) |
| `system_api.py` | 系统数据备份导出 / 导入恢复(`/api/system/backup/*`,仅 admin) |
| `common.py` | 跨模块共享(合并设备列表/屏幕状态) |
| `context.py` | 共享对象注入(mgr/apk_mgr/device_pool) |
`web_server.py` 只做装配与启动(266 行):app 创建;`init_db` 前消费待生效备份恢复(`consume_pending_restore`);初始化 device_pool / device_discovery;装配 TaskManager / ApkManager;注册 10 个蓝图(auth/monitor/tasks/admin/tools/devices/apks/tailscale/agent/system,`web/__init__.py`);注册经验巡检 APScheduler(03:47 Asia/Shanghai);uiautodev 子进程启停与设备池预连接线程;启动。
`STFDevice.release()` 是**空实现**——直连模式下**绝不 disconnect**(共享 adb transport 红线)。类名 `STFDevice` / `STFError` 是 STF 时代的历史命名,功能上已与 STF 无关。
---
## 4. 任务系统设计
## 5. 任务调度链路
### 4.1 注册机制
### 5.1 完整调用链
```
tasks/__init__.py
├── from .base import BaseTask, register_task, list_task_types, get_task_class
└── from .generic import task # 触发 @register_task(当前唯一任务类型)
① 注册 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 次)
```
`@register_task` 装饰器将 Task 类注册到全局 `_TASK_TYPES` 字典,key 为 `task_type` 字符串。
### 5.2 抢占机制
### 4.2 参数深合并
Job 下发时只传需要覆盖的字段,调度器做三层合并:
1. **顶层字段**:Job params 覆盖 DEFAULT_PARAMS
2. **actions 字段**:参数级深合并
- 前端没传的 action → 用默认
- 前端传了 → `enabled` 和 `params` 分别合并
- `params` 再深合并一层(保留前端没传的子参数)
示例:只想改点赞概率,Job params 只需:
```json
{"actions": {"like": {"params": {"rate": 0.5}}}}
```
### 4.3 Action 系统
每个 App 有独立的 Action 注册表(`create_action_registry()`),互不污染。
```
core/actions/base.py — BaseAction 全局基类 + should_trigger + register_action
tasks/<app>/actions/base.py — ACTIONS = create_action_registry() + list/get 函数
tasks/<app>/actions/xxx.py — @register_action(ACTIONS) XxxAction
> 当前没有 App 专属任务包(只剩 `tasks/generic/`,步骤全走通用 STEP_TYPES,不建专属 action 表);
> 新增专属任务类型时按上面三行建 `tasks/<app>/actions/`。
```
**循环导入坑**:`actions/__init__.py` 必须先 `from .base import ACTIONS`,再 `from . import like`。
任务参数 `preempt=true` 时:`all` 模式目标集合变为"全部在线池内设备"(含正在跑的);遇到设备已被占用时在**锁外**调 `stop_device` 并最多等 30s 接管;本任务结束后自动重新启动被抢占的任务(`preempted_job` 必须定义在重试循环外,否则归还信息会丢)。
---
## 5. 前端设计
## 6. 前端架构
### 5.1 单页应用
### 6.1 单页应用
`templates/admin/monitor.html` 是纯 HTML+CSS+JS 单页应用,无框架依赖。
- 主页面 `templates/admin/monitor.html`:一个内联 `<style>` + 7 个 Tab 面板 + 7 个模态框容器 + 11 个 `<script src>`
- 独立页面:`login.html`(登录)、`wall.html`(监控大屏,**完全自包含**,自带 CSS/JS,不加载 `static/admin/*.js`)
- 服务端内联页:`GET /locate`(设备端定位大字页,免登录)
- 响应头强制 `no-store`,避免后台改版后浏览器拿旧页面
- **Tab 切换**:7 个顶级 Tab(监控/任务/日志/用户/工具/AI 控制台/系统),均在 monitor.html 内 `.tab-panel` 切换(纯 DOM 操作);独立页面仅 `/login`、`/wall`。原"分组"已无顶层入口(移到工具页子分栏)
- **数据获取**:`fetch()` 调 JSON API,5 秒轮询 `/api/status`
- **状态渲染**:设备表格、任务卡片、进度条、徽章,纯 DOM 操作
### 6.2 Tab 与子分栏
**监控页「任务运行概况」卡片(2026-09-10 收敛)**:每张卡片只保留两个操作——**执行任务**(`POST /api/jobs/:id/run`)
与**停用任务/启用任务**(`POST /api/jobs/:id/toggle`,按当前状态切换文案);原"编辑/删除"已移除
(编辑与删除统一去「任务」Tab 操作,避免监控页误删)。卡片新增**覆盖设备**一行:设备清单来自
`/api/jobs` 的 `coverage`(后端按 target 定义解析,见 [API.md](API.md) §5),在线/运行中状态由前端
用已在手的 `/api/status` 数据标注成彩色 chip(在线=蓝、运行中=黄+⏳、离线或不在池=灰划线+✕,
完整 serial 与型号放 tooltip)。
| 顶级 Tab | `data-tab` | 权限 | 子分栏 |
|---------|-----------|------|--------|
| 监控 | `monitor` | 登录即可 | — |
| 任务 | `tasks` | 登录即可(写操作需 `tasks`) | `plan` 任务计划 / `actions` 自定义动作 |
| 日志 | `logs` | `logs` | — |
| 用户 | `users` | `admin` | — |
| 工具 | `tools` | `admin` | `clipboard` / `adb` / `ts` / `apks` / `appver` / `devapps` / `devpool` / `groups` |
| AI 控制台 | `agent` | `admin` | — |
| 系统 | `system` | `admin` | `backup` 数据备份 / `restore` 导入恢复 |
### 5.2 步骤编辑器
子分栏会记住上次选中位置(`_activeSubs`);`devpool` 子分栏自带 10s 轮询,切走即停。
`generic_steps` 任务的步骤编辑器(任务弹窗已加宽到 1000px):
- 左侧操作库(拖拽源,按 交互操作/屏幕与App/流程控制 分组)
- 中间画布(步骤卡片列表,HTML5 Drag API 排序 + 跨层级嵌套:循环套循环、动作组)
- 每个步骤卡片可展开参数表单
- 选择器字段旁有"抓取元素"按钮(独立模态框)
- **容器步骤**(loop/group/if_el):卡片内嵌子步骤容器接收拖入;if_el 有"✅找到时/❌未找到时"两个独立分支容器,分支可嵌套任意步骤
- **条件判断**(if_el):选择器支持 xpath 等 UI 树选择器或 OCR识别(`core/ocr.py`,截屏匹配图片/画布文字,命中可自动点击)
- 所有递归操作(选择打包、存自定义动作、校验、防循环自套)统一遍历 children/then/else 三个子数组
### 6.3 JS 分工
### 5.3 页内子分栏
任务/工具/系统 Tab 用通用 `showSubTab(tabId, name)` 实现页内子分栏:每个子分栏一个 `.sub-panel`,
`_activeSubs` 记住各 Tab 上次选中的子分栏。现状子分栏:
- **任务**:任务计划 / 自定义动作
- **工具**:剪贴板注入 / adb 远程终端 / Tailscale 管理 / 应用管理 / 应用版本管理 / 设备已装应用 / 设备池管理 / 设备分组
- **系统**:数据备份 / 导入恢复
(原"分组"顶级 Tab 与"维护/STF 服务"子分栏已不存在——分组已移入工具页子分栏,STF 已摘除。)
### 5.4 AI 控制台的记忆面板
AI 控制台(顶级 Tab)右上角两个模态框,管理自进化记忆:
- **🧠 经验库**:任务级经验(`agent_experience`,整任务配方)+ 每日 AI 巡检建议(删除需人工确认)。
- **🎬 动作库**:动作级经验(`agent_action`)——命名动作(可含 1~N 步)+ 编辑器 schema 步骤 + **元素定位(禁坐标)**;由任务成功后从**成功步骤**自动蒸馏,执行前按名/别名召回注入;面板支持查看/编辑/删除/手动新建(保存经服务端校验,坐标步骤被拒)。
- **会话列表显示会话 ID**(前 8 位,等宽小字),点击即复制完整 ID——便于反馈问题时引用 `conv=<id>`。
#### 5.4.1 回答渲染与 token(2026-09-10)
| 能力 | 落点 | 说明 |
| 文件 | 职责 | 备注 |
|------|------|------|
| **Markdown 渲染** | `static/admin/markdown.js`(`renderMarkdown()`) | 自研轻量渲染器,**不引 CDN**(生产 220 在内网):标题/段落/软换行/粗斜体/删除线/行内代码/围栏代码块/有序无序列表(含嵌套)/引用/表格/分隔线/链接。**先 `esc()` 转义再套标记**,模型输出里的 HTML 只显示为文本(防注入) |
| **推理链可折叠** | `agent.js` `_appendReasoning()` + `<details class="reasoning">` | 流式思考时自动展开、正文开始时自动收起;用户手动点过 `summary` 后不再自动改(`dataset.touched`);摘要显示「思考过程(N 字)」 |
| **token 显示** | `mcp_agent/agent.py` `_accumulate_usage()` + `agent_api` SSE `usage` 事件 | 每次模型调用完成后推**本轮累计**(`prompt/completion/total/calls`);单条消息脚注 + 顶栏「本会话累计」(历史 + 运行中) |
| **推理链/用量落库** | `_agent_thread` 把 `usage`、`reasoning` 写进会话 assistant 消息 | 刷新页面后仍可回看;**回灌模型上下文时只取 `role`/`content`**(不污染 token) |
| `base.js` | esc / CSRF / 权限 / API 封装 / Toast / Tab 与子分栏切换 / 模态框 / 常量表 | 所有模块的公共底座 |
| `markdown.js` | 轻量 Markdown 渲染(`renderMarkdown`) | 无 CDN 依赖;**先转义再套标记** |
| `list.js` | 统一列表组件:搜索 + 分页 + 排序 | 状态注册表 `_LIST_PAGERS`;`setListPager` **不重置**页码/搜索/排序(避免轮询刷新打断用户) |
| `monitor.js` | 监控页:设备表、批量操作、异常汇总、任务概况卡片(含覆盖设备 chip) | 5s 轮询 + 脏检查(签名不变不重渲染) |
| `editor.js` | 步骤编辑器(拖拽 / 参数表单 / 条件分支 / 元素抓取 / 测试此步骤)+ `saveTask` | 最大的前端文件;`_stepEditor` 单例 |
| `tasks.js` | 任务 Tab:任务 CRUD / 调度解析 / 运行窗口 + 自定义动作 | |
| `tools.js` | 工具 Tab:剪贴板、adb 终端、Tailscale、设备池、自动发现、远程看屏 | |
| `apps.js` | 应用管理:APK 上传/安装/删除、设备已装应用 | |
| `admin.js` | 分组、日志、用户 + **全局初始化入口**(末尾 `initCsrf(); loadMe(); showTab('monitor')`) | |
| `agent.js` | AI 控制台:会话、SSE 流、Markdown / 推理链 / token 渲染、实时画面、经验库 / 动作库 | |
| `system.js` | 系统 Tab:备份导出 / 导入预览与应用 | |
> **token 采集的兼容性**:请求带 `stream_options: {"include_usage": true}`,按「每次模型调用」取末尾 chunk 的 `usage` 累加(多轮工具调用会多次累加)。个别网关不认该参数会直接 **HTTP 400** → `_UsageUnsupported` 捕获后**自动关掉并重试一次**(`self._include_usage=False`),不影响主流程。
>
> **推理链体积**:只保留前 `_REASONING_KEEP`=6000 字符落库(会话消息上限 60 条),避免历史无限膨胀。
加载顺序见根 [README](../README.md);都是全局脚本(非 ES module),靠加载顺序保证依赖。
> **蒸馏健壮性(2026-09-10)**:经验/动作靠**模型蒸馏**落库。推理型模型会把 token 预算烧在 `reasoning` 上,导致 `content` 为空或被截断(`finish_reason=length`)→ 早期只读 `content`,经验/动作被**静默丢弃**("小红书·苏州饭店"案例)。现策略:
> 1. 蒸馏调用**关闭推理**:`"thinking": {"type": "disabled"}`(该代理支持;实测关掉后 reasoning=0、正文正常,配方 3/3 合格)——这是关键修复;
> 2. 配方用**纯文本问法**(不要放可照抄的占位示例,否则模型会原样当配方存下来)+ 质量门槛 `_recipe_ok`(过短/含省略号占位 → 丢弃并重试);
> 3. 动作提炼用 JSON + **截断容忍**提取(`_loads_lenient` 逐对象抢救)+ 顶层 `{action,params}` 形状归一 + 输入/产出限量(≤10 步输入、≤3 动作×4 步);
> 4. 两类失败都有日志(`经验提炼:` / `动作提炼:` 含样本),不再静默。
### 6.4 实时通道
### 5.5 元素抓取模态框
| 通道 | 场景 | 机制 |
|------|------|------|
| **轮询** | 设备状态(5s)、日志(3s,可关)、自动发现(10s)、截图(3s)、APK 安装(3s)、AI 运行状态(8s) | `setInterval`,切 Tab 时统一清理 |
| **SSE** | AI 控制台一轮会话 | `EventSource /api/agent/stream`;`onerror` **刻意不结束运行**,靠自动重连 + 服务端事件队列补发 |
| **MJPEG** | 实时看屏 | `img.src = /api/screen/stream?...`,浏览器原生长连接 |
独立的第二层模态框(`el-picker-overlay`,z-index 1100),不影响任务编辑窗口:
1. 选择设备 → 2. 加载截图 + 元素树 → 3. 点击元素/边界框 → 4. 回填选择器
5. **抓取时直接验证**(每条元素右侧两个按钮,不会与"点击回填"冲突):
- 「▶ 点一下」:按元素 `bounds` 中心在设备上真点一次(`POST /api/screen/tap`,`snap=1` 自动吸附到可点元素),返回吸附结果并自动刷新截图——用于确认位置/是否可达;
- 「✓ 测选择器」:用**将填入的选择器**真跑一次 click(`POST /api/steps/test`),返回 `命中/未找到/已执行`——用于确认回填的选择器在真实界面能命中(元素无有效选择器时不显示此按钮)。
### 6.5 前端权限
三条并存:① `data-perm` 属性(`loadMe()` 时统一隐藏无权限元素);② `_can(perm)`(动态拼 HTML 时决定是否出按钮);③ 后端装饰器兜底(403)。
> 前端隐藏只是体验优化,**安全完全依赖后端**;`data-perm` 只在页面加载时求值一次,改权限后需刷新页面。
---
## 6. 关键设计决策
## 7. 关键设计决策
### 6.1 为什么用 SQLite 而不是 JSON 文件
- 支持用户/分组/任务的关系存储
- 并发安全(WAL 模式)
- 迁移旧 JSON 时归档为 `.migrated`,避免删空后重启又复原
### 6.2 为什么用 threading 而不是 asyncio
- uiautomator2 是同步阻塞库,不适合 asyncio
- 多设备并发用多线程即可,每台设备一个 Worker 线程
- Flask `threaded=True` 处理并发 HTTP 请求
### 6.3 为什么绝不 kill-server
`adb kill-server` / `adb disconnect` 会断开共享的 adb transport(历史与 STF provider 共享;STF 摘除后红线仍保留——多 worker、前台扫描、设备自动发现共用同一 adb server),影响所有运行中的任务。连接失败就返回 False,由调用方处理。
### 6.4 为什么状态查询带缓存
状态源 = 本地 SQLite 设备池 + adb + 内存 worker 状态。5 秒缓存避免每次 `/api/status` 都查库/adb 阻塞 Flask;Worker 实时状态读内存,不受缓存影响。
### 6.5 为什么 DeviceOfflineError 不重试
设备掉线后短时间内不会自愈,重试只会浪费配额并阻塞调度器。让设备进入冷却,可依赖设备自动发现(device_discovery 对正式池断联设备每轮 adb 重连)恢复后再启用。
| 决策 | 为什么 | 代价 / 注意 |
|------|--------|------------|
| **SQLite + WAL** 而非 MySQL/PG | 单机部署、零运维;WAL 支持多线程读写 | 写并发有限;必须设 `busy_timeout`;`data/` 不入 git |
| **threading 而非 asyncio** | uiautomator2 是同步阻塞库;每设备一线程模型直观 | 线程数随设备数增长;跨线程访问 DB 必须自推 app context |
| **状态放内存** 而非落库 | 状态每秒都变,落库纯属浪费 | 重启丢失运行中状态(进程重启 = 任务终止) |
| **蓝图按功能域拆包** | `web_server.py` 只做装配,路由就近维护 | 拆包易出现 import 遗漏 → 用 `scripts/regression_test.py` 兜底 |
| **单页应用 + 全局脚本** | 无构建步骤、无 npm 依赖,改完强刷即生效 | 11 个文件共享全局作用域,需手工维护加载顺序 |
| **任务类型注册表** | 新增任务类型不动调度器 | 删改类型要做兼容(未知类型必须显式报错,不能静默) |
| **自建设备池**(不用 STF) | 规避共享 adb transport 的历史坑,清单可控 | 需自己处理在线状态、型号、自动发现 |
| **备份"重启生效"** | Windows 无法替换被持有的 db;调度器/worker 持有内存态 | 导入后必须重启(在 `init_db` 之前消费恢复任务) |
| **uiautodev 独立子进程** | 元素抓取是重活,隔离崩溃、可单独重启 | 固定端口 20242;PID 文件防重复拉起(杀之前必须校验 cmdline,防误杀同容器进程) |
| **MCP 独立端口** | 外部 AI 用标准协议接入,不侵入 Web 会话 | MCP 端点自身无鉴权,靠网络隔离;写操作用开关门控 |
---
## 7. 线程模型
## 8. 技术红线与实现位置
```
主线程(Flask)
├── HTTP 请求处理(threaded=True,每请求一线程)
├── TaskManager.scheduler(APScheduler,cron 触发任务计划)
├── 经验巡检 BackgroundScheduler(03:47 Asia/Shanghai,web_server 装配)
├── 设备自动发现线程(device_discovery._discovery_loop,默认 60s 一轮)
├── 设备池型号采集后台线程(device_pool._refresh_models_bg,启动/手动触发)
├── 设备池预连接线程(web_server._preconnect_pool_devices,重启后加速恢复)
├── AI Agent 运行线程(agent_api._agent_thread,单实例 + SSE 推送)
├── 看门狗线程(_Watchdog,30s 间隔)
├── uiautodev 子进程(PID + cmdline 校验,防容器 PID 复用误杀)
└── Worker 线程(每台设备一个)
├── _run_with_retry 线程(重试循环)
└── BaseWorker 线程(设备生命周期 + run_task)
```
| 红线 | 为什么 | 代码里的体现 |
|------|--------|-------------|
| **绝不 `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)) |
> MCP server(127.0.0.1:8033)是**独立进程**(scripts/start.sh 拉起),不是 web_server 的线程。
---
**线程安全**:
- `_WORKERS_LOCK`:保护全局 worker 状态字典
- `_ADB_LOCK`:串行化所有 adb 调用
- `TaskManager._lock`:保护运行中任务字典
- `_ForegroundScanner._cache_lock`:保护前台 App 缓存
## 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) |
| 加一个 MCP 工具 | `mcp_server/mcp_server.py`(需要时加 `direct_ops.py`) | 写操作要挂门控三连;更新 [MCP.md](MCP.md) |
| 加一个配置键 | `config.py`(程序级)或 `.env`(密钥类) | 更新 `.env.example` + [DEVELOPMENT.md](DEVELOPMENT.md) 配置速查 |