377 lines
17 KiB
Markdown
377 lines
17 KiB
Markdown
# 架构详解
|
||
|
||
本文面向想深入理解 `platform-tools` 内部设计的开发者。如果你只想使用,看 [README.md](file:///d:/platform-tools/README.md) 即可。
|
||
|
||
---
|
||
|
||
## 1. 分层设计
|
||
|
||
平台按"配置 / 核心 / 任务 / 前端 / 数据 / 日志 / 工具"分层,职责清晰、互不交叉:
|
||
|
||
| 层 | 路径 | 职责 |
|
||
|----|------|------|
|
||
| 配置层 | `config.py` | 项目根配置:STF 地址、adb 路径、web 端口等基础设施。**不放任务参数** |
|
||
| 核心层 | `core/` | 框架运行时:日志、STF 客户端、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/` 不 import `tasks/`,任务通过注册机制接入
|
||
- **配置最小化**:`config.py` 只放基础设施配置,任务参数在各自 `task.py` 顶部
|
||
|
||
---
|
||
|
||
## 2. 数据流
|
||
|
||
```
|
||
┌──────────────┐ 创建/编辑任务 ┌─────────────┐ 分发 worker ┌──────────────┐
|
||
│ 单页应用前端 │ ───────────────► │ TaskManager │ ─────────────► │ Worker(设备) │
|
||
│ (monitor.html│ └─────────────┘ └──────────────┘
|
||
│ fetch+DOM) │ ▲ │
|
||
└──────────────┘ │ 状态/心跳 │ u2 操作
|
||
│ │ ▼
|
||
│ JSON API │ ┌─────────────────┐
|
||
▼ ┌──────────────┐ │ 设备 adb │
|
||
┌──────────────┐ │ 看门狗监控 │ │ (IP:5555 直连 │
|
||
│ web_server │ └──────────────┘ │ / USB 远程) │
|
||
│ (Flask API) │ └─────────────────┘
|
||
└──────────────┘
|
||
│
|
||
▼
|
||
┌──────────────┐
|
||
│ data/users.db│ SQLite 持久化(用户/分组/任务/设备池/自定义动作/APK记录)
|
||
└──────────────┘
|
||
```
|
||
|
||
### 任务执行流程
|
||
|
||
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 状态
|
||
|
||
---
|
||
|
||
## 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 无响应永久 hang
|
||
- `d.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 秒缓存,STF 请求慢时不阻塞前端。
|
||
|
||
### 3.4 前台 App 扫描器(`_ForegroundScanner`)
|
||
|
||
**设计原则:不打扰设备**,扫描不会让设备退出当前 App。
|
||
|
||
| 设备状态 | 处理方式 | 是否打扰 |
|
||
|---------|---------|---------|
|
||
| worker 运行中 | 复用已有 ADB 连接查询 | 否 |
|
||
| 自己占用无 worker | STF remoteConnect 隧道查询 | 否 |
|
||
| 完全空闲 | 返回"空闲"(不 adb connect,避免 STF 误判离线) | 否 |
|
||
| 他人占用 | 标记"(他人占用)" | 否 |
|
||
|
||
> **为什么不扫描空闲设备的前台 App**:STF provider 内部通过 IP:5555 维持 adb 连接。外部 adb connect/disconnect 会让 adb server 断开该 transport,连带 STF provider 的连接断开,STF 误判设备 offline 并触发重连。
|
||
|
||
### 3.5 ADB 操作(`core/adb_helper.py`)
|
||
|
||
**全局锁串行化**:`_ADB_LOCK` 确保所有 adb 调用串行执行,避免多线程竞争 adb server。
|
||
|
||
**铁律:绝不 kill-server**:
|
||
- `adb kill-server` 会断开所有设备的 adb transport
|
||
- 导致 STF provider 对全部设备误判离线并触发重连
|
||
- 影响所有运行中的任务
|
||
|
||
| 函数 | 说明 |
|
||
|------|------|
|
||
| `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 认证,SHA256 密码;`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 文件元信息 |
|
||
|
||
**数据库初始化**(`init_db`):
|
||
- 创建所有表
|
||
- 首次启动创建默认管理员 `admin/admin123`
|
||
- 自动迁移旧 `groups.json` / `jobs.json` 到 SQLite(迁移后归档为 `.migrated`)
|
||
- 版本化 schema 迁移(`SCHEMA_MIGRATIONS`):结构变更必须追加迁移条目,`create_all` 只建新表不加列
|
||
|
||
### 权限模型
|
||
|
||
- `User.perms` 存业务权限位 JSON 数组(`tasks`/`devices`/`apks`/`logs`),`is_admin=true` 拥有全部权限(`has_perm` 短路)
|
||
- 后端统一用 `@perm_required(PERM_X)` / `@admin_required` 装饰器拦截(web_server.py),无权限返回 403;
|
||
查看类 GET 接口只要求登录;用户管理、维护接口(STF 重启 `/api/stf/restart`、adb 终端 `/api/adb/cmd`)强制 `admin_required`
|
||
- adb 终端安全红线:拒绝 `kill-server` / `disconnect`(STF provider 共享 adb transport,断开会误判全设备离线)
|
||
- 前端 `loadMe()` 拉取 `/api/me`,用 `data-perm` 属性隐藏无权限的 tab/按钮,行内按钮用 `_can(perm)` 判断
|
||
- 安全兜底:**前端隐藏只是 UX,权限强制在后端**;新增路由时按"写操作必须带权限装饰器"的约定
|
||
|
||
### 3.7 APK 管理(`core/apk_manager.py`)
|
||
|
||
APK 上传/解析/批量安装。
|
||
|
||
**设备连接策略(直连,绕过 STF)**:
|
||
- 直接 `adb connect serial`(serial 是 IP:5555)
|
||
- 不经过 STF occupy/release,避免 STF release 触发 agent 清理卸载 app
|
||
- 安装后不主动 disconnect(STF provider 共享该 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 元素树(扁平化列表) |
|
||
|
||
元素树解析:递归提取每个节点的 `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 STF 设备管理(`core/stf_device_mgmt.py`)
|
||
|
||
工具页"STF 设备管理":维护 220 上 OpenSTF 设备池。
|
||
|
||
背景:220 的 adb 跑在 Docker 容器(`STF_ADB_CONTAINER`,默认 `adb`)里,设备池由
|
||
`/mnt/data/openstf/connect_devices.sh`(`STF_SCRIPT_PATH`)的 `DEVICES` 数组维护,cron 每 5 分钟补连。
|
||
|
||
| 函数 | 说明 |
|
||
|------|------|
|
||
| `status()` | 脚本配置 IP + 220 adb 实际连接状态 |
|
||
| `add_device(ip)` | 用 220 的 python3 精确改脚本 DEVICES 块 + `docker exec adb adb connect` |
|
||
| `remove_device(ip)` | 脚本删条目 + `adb disconnect` |
|
||
|
||
关键点:
|
||
- 脚本编辑用 python3 行级增删(sed 处理多行数组易误伤)
|
||
- `adb connect` 到不可达 IP 会挂 40s+,220 侧用 `timeout 15` 兜底,超时保留在脚本由 cron 重试
|
||
- 断开的是 220(STF provider 侧)的 adb 连接,与本机任务直连的 adb 相互独立
|
||
|
||
---
|
||
|
||
## 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 下发时只传需要覆盖的字段,调度器做三层合并:
|
||
|
||
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/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 切换**:5 个 Tab(监控/任务/分组/日志/用户),纯 DOM 操作
|
||
- **数据获取**:`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 上次选中的子分栏。工具 Tab 集中了全部管理工具(剪贴板注入/adb 终端/
|
||
Tailscale/应用管理/应用版本/已装应用/STF 设备管理),维护 Tab 仅保留 STF 服务。
|
||
|
||
### 5.4 元素抓取模态框
|
||
|
||
独立的第二层模态框(`el-picker-overlay`,z-index 1100),不影响任务编辑窗口:
|
||
1. 选择设备 → 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 transport,导致 STF provider 误判全部设备离线并触发重连。连接失败就返回 False,由调用方处理。
|
||
|
||
### 6.4 为什么状态查询带缓存
|
||
|
||
STF API 响应慢(设备多时 2-5 秒),每次 `/api/status` 都打 STF 会阻塞 Flask。带 5 秒缓存,Worker 状态实时读内存(无 IO)。
|
||
|
||
### 6.5 为什么 DeviceOfflineError 不重试
|
||
|
||
设备掉线后短时间内不会自愈,重试只会浪费配额并阻塞调度器。让设备进入冷却,由运维/STF 恢复后再启用。
|
||
|
||
---
|
||
|
||
## 7. 线程模型
|
||
|
||
```
|
||
主线程(Flask)
|
||
├── HTTP 请求处理(threaded=True,每请求一线程)
|
||
├── APScheduler 线程(cron 触发)
|
||
├── 看门狗线程(_Watchdog,30s 间隔)
|
||
├── uiautodev 子进程
|
||
└── Worker 线程(每台设备一个)
|
||
├── _run_with_retry 线程(重试循环)
|
||
└── BaseWorker 线程(设备生命周期 + run_task)
|
||
```
|
||
|
||
**线程安全**:
|
||
- `_WORKERS_LOCK`:保护全局 worker 状态字典
|
||
- `_ADB_LOCK`:串行化所有 adb 调用
|
||
- `TaskManager._lock`:保护运行中任务字典
|
||
- `_ForegroundScanner._cache_lock`:保护前台 App 缓存
|