- doc/API.md:补方法/路径标题,权限分层修正,新增 AI 控制台(/api/agent/*)、系统备份(/api/system/backup/*)、设备自动发现(/api/devices/discovery/*)、tap_text/summary/health/devices-apps 等整节端点,去 STF 残留 - doc/TASK_DEV.md:STF 时代描述清理;新增 §2.10 generic_steps(18 节点与必填/嵌套/静默跳过语义)、§2.11 自定义动作与单步测试、/api/jobs 盲存校验语义、resolve_serials/抢占语义、模板构造函数签名修正 - doc/DEPLOY.md:数据备份改为推荐「系统→数据备份」功能并说明重启生效目录,端口表 STF7100→MCP8033,补 start.sh 生产链路与 MCP_PLATFORM_PASS 同步,故障排查去 STF - doc/MCP.md:加「现状边界」(平台级任务 CRUD 未 MCP 化,规划见 AI_TASK_GEN §9),busy/平台会话说明,MCP_ALLOWED_SERIALS 语义纠正 - doc/MCP_DESIGN.md:加实现现状对照、错误码、独立容器改演进备选、里程碑状态、API 映射表按实现重写 - doc/ARCHITECTURE.md:Tab/子分栏/线程模型/数据表/蓝图表去 STF,补 device_discovery/agent/system_backup/经验巡检等 - doc/DEVELOPMENT.md:新增 §5.6「改动必须同步文档」红线、§2.3 配置键速查、蓝图化新增 API 流程、文档索引补登记 - doc/STF_REMOVAL.md:加历史记录状态横幅 - doc/AI_TASK_GEN.md:新增 AI 建任务设计稿(含 §9 需转 MCP 工具分层)
20 KiB
架构详解
本文面向想深入理解 auto_control 内部设计的开发者。如果你只想使用,看 README.md 即可。
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/ |
实用脚本 |
分层原则
- 任务自包含:每个任务的参数、Worker、操作都放在
tasks/<app>/下,不污染全局 - 核心不依赖任务:
core/不 importtasks/,任务通过注册机制接入 - 配置最小化:
config.py只放基础设施配置,任务参数在各自task.py顶部
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)
└──────────────┘
任务执行流程
- 前端创建 TaskJob(HTTP POST
/api/jobs) TaskManager保存到 SQLite,如启用 cron 则注册到 APScheduler- 手动执行或 cron 触发时,
_run_job解析目标设备列表 - 每台设备起一个线程
_run_with_retry,含重试循环 - 线程内
task.create_worker()创建 Worker,worker.start()启动 BaseWorker.run()执行设备生命周期:占用 → 连接 → setup → run_task → teardown → 释放- Worker 通过
_update_status()实时上报状态到全局_WORKERS字典 - 前端轮询
/api/status(5 秒缓存)获取设备 + Worker 状态
3. 核心模块详解
3.1 DevicePool(core/device_pool.py)
设备池(已摘除 OpenSTF):SQLite devices 表 = 设备清单,本机 adb = 在线状态。
关键设计:
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 红线)
3.2 STFDevice + BaseWorker(core/device_worker.py)
STFDevice — 单设备生命周期管理:
acquire():IP:5555 直连 adb connect;USB(serial 无冒号)校验 220 远程 adb server 可见性release():无操作(不 disconnect,红线)- 互斥由 TaskManager
_running保证,设备池负责在线判断
BaseWorker — 通用 Worker 基类(继承 threading.Thread):
run() 主循环(不要重写):
1. acquire 设备
2. u2.connect(30s 超时保护)
3. setup(d) ← 子类可选钩子
4. run_task(d) ← 子类必须实现
5. teardown(d) ← 子类可选钩子
6. finally: release 设备
超时保护:
u2.connect()用ThreadPoolExecutor + 30s 超时,防止 atx-agent 无响应永久 hangd.info用ThreadPoolExecutor + 10s 超时
心跳看门狗(_Watchdog):
- 后台线程,每 30 秒扫描一次
- Worker 超过 120 秒无心跳 → 标记
error - 防止设备被占用却不干活
全局状态注册表(_WORKERS):
serial -> status dict,线程安全(_WORKERS_LOCK)- 供
web_server读取实时状态,前端通过/api/status展示
3.3 TaskManager(core/task_manager.py)
统一管理:任务类型注册、设备分组、任务计划、定时调度、重试、持久化。
核心组成:
| 组件 | 说明 |
|---|---|
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 | 自动发现「待连接池」(扫描发现、用户确认后才入正式池) |
表名默认取类名小写(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)。
数据库初始化(init_db):
- 创建所有表
- 首次启动创建默认管理员
admin/admin123 - 自动迁移旧
groups.json/jobs.json到 SQLite(迁移后归档为.migrated) - 版本化 schema 迁移(
SCHEMA_MIGRATIONS,当前到 v4,见core/models.py):结构变更必须追加迁移条目,create_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,权限强制在后端;新增路由时按"写操作必须带权限装饰器"的约定
3.7 APK 管理(core/apk_manager.py)
APK 上传/解析/批量安装。
设备连接策略(直连):
- 直接
adb connect serial(serial 是 IP:5555) - 不经过占用/释放(单实例互斥由调度器内存锁保证)
- 安装后不主动 disconnect(共享 adb transport 红线)
安装流程:
- 上传 APK → 保存到
data/apks/→ pyaxmlparser 解析包名/版本 → 入库 - 批量安装 → 后台线程 → 每台设备直连 adb install → 验证包名
- 跳过 worker 运行中的设备(避免打断任务)
3.8 元素抓取(core/uiauto_helper.py)
封装 uiautodev 本地服务(端口 20242)的客户端。
| 函数 | 说明 |
|---|---|
is_running() |
探测 uiauto2 服务是否运行 |
list_devices() |
获取 uiauto2 已连接的设备列表 |
get_screenshot(serial) |
获取设备截图(JPEG) |
get_elements(serial) |
获取设备 UI 元素树(扁平化列表) |
元素树解析:递归提取每个节点的 resource-id/text/content-desc/class/bounds 等属性,并推荐最佳选择器(优先 xpath)。
3.9 屏幕 OCR(core/ocr.py)
条件判断的 ocr 选择器实现:截屏 → RapidOCR(ONNX 推理,中英文模型随包内置)→ 关键词匹配 → 返回文字中心像素坐标(与 u2 d.click 一致)。
- 跨平台(Windows/Linux/macOS),依赖
rapidocr_onnxruntime;服务器无显示器环境建议将 opencv-python 换成 opencv-python-headless - 引擎懒加载单例 + 并发加锁(识别约 0.2-0.5s/次)
- 返回坐标约定:像素、原点左上
3.10 Web 层蓝图包(web/)
路由按功能域拆分(模块化开发底线,便于定位问题):
| 模块 | 职责 |
|---|---|
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、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 子进程启停与设备池预连接线程;启动。
4. 任务系统设计
4.1 注册机制
tasks/__init__.py
├── from .base import BaseTask, register_task, list_task_types, get_task_class
├── from .douyin import task # 触发 @register_task
└── from .generic import task # 触发 @register_task
@register_task 装饰器将 Task 类注册到全局 _TASK_TYPES 字典,key 为 task_type 字符串。
4.2 参数深合并
Job 下发时只传需要覆盖的字段,调度器做三层合并:
- 顶层字段:Job params 覆盖 DEFAULT_PARAMS
- actions 字段:参数级深合并
- 前端没传的 action → 用默认
- 前端传了 →
enabled和params分别合并 params再深合并一层(保留前端没传的子参数)
示例:只想改点赞概率,Job params 只需:
{"actions": {"like": {"params": {"rate": 0.5}}}}
4.3 Action 系统
每个 App 有独立的 Action 注册表(create_action_registry()),互不污染。
core/actions/base.py — BaseAction 全局基类 + should_trigger + register_action
tasks/douyin/actions/base.py — ACTIONS = create_action_registry() + list/get 函数
tasks/douyin/actions/like.py — @register_action(ACTIONS) LikeAction
循环导入坑:actions/__init__.py 必须先 from .base import ACTIONS,再 from . import like。
5. 前端设计
5.1 单页应用
templates/admin/monitor.html 是纯 HTML+CSS+JS 单页应用,无框架依赖。
- Tab 切换:7 个顶级 Tab(监控/任务/日志/用户/工具/AI 控制台/系统),均在 monitor.html 内
.tab-panel切换(纯 DOM 操作);独立页面仅/login、/wall。原"分组"已无顶层入口(移到工具页子分栏) - 数据获取:
fetch()调 JSON API,5 秒轮询/api/status - 状态渲染:设备表格、任务卡片、进度条、徽章,纯 DOM 操作
5.2 步骤编辑器
generic_steps 任务的步骤编辑器(任务弹窗已加宽到 1000px):
- 左侧操作库(拖拽源,按 交互操作/屏幕与App/流程控制 分组)
- 中间画布(步骤卡片列表,HTML5 Drag API 排序 + 跨层级嵌套:循环套循环、动作组)
- 每个步骤卡片可展开参数表单
- 选择器字段旁有"抓取元素"按钮(独立模态框)
- 容器步骤(loop/group/if_el):卡片内嵌子步骤容器接收拖入;if_el 有"✅找到时/❌未找到时"两个独立分支容器,分支可嵌套任意步骤
- 条件判断(if_el):选择器支持 xpath 等 UI 树选择器或 OCR识别(
core/ocr.py,截屏匹配图片/画布文字,命中可自动点击) - 所有递归操作(选择打包、存自定义动作、校验、防循环自套)统一遍历 children/then/else 三个子数组
5.3 页内子分栏
任务/工具/系统 Tab 用通用 showSubTab(tabId, name) 实现页内子分栏:每个子分栏一个 .sub-panel,
_activeSubs 记住各 Tab 上次选中的子分栏。现状子分栏:
- 任务:任务计划 / 自定义动作
- 工具:剪贴板注入 / adb 远程终端 / Tailscale 管理 / 应用管理 / 应用版本管理 / 设备已装应用 / 设备池管理 / 设备分组
- 系统:数据备份 / 导入恢复
(原"分组"顶级 Tab 与"维护/STF 服务"子分栏已不存在——分组已移入工具页子分栏,STF 已摘除。)
5.4 元素抓取模态框
独立的第二层模态框(el-picker-overlay,z-index 1100),不影响任务编辑窗口:
- 选择设备 → 2. 加载截图 + 元素树 → 3. 点击元素/边界框 → 4. 回填选择器
6. 关键设计决策
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 重连)恢复后再启用。
7. 线程模型
主线程(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)
MCP server(127.0.0.1:8033)是独立进程(scripts/start.sh 拉起),不是 web_server 的线程。
线程安全:
_WORKERS_LOCK:保护全局 worker 状态字典_ADB_LOCK:串行化所有 adb 调用TaskManager._lock:保护运行中任务字典_ForegroundScanner._cache_lock:保护前台 App 缓存