一、平台侧(账号 → 发布计划页) - 新表 video_plan(schema v9→v10):账号×发布日期×编号 → 素材 + 标题 + 发布状态 + 分享链接; 状态机 pending/ready/pushing/publishing/done/failed/unknown/skipped(**failed 与 unknown 必须分开**: 推送阶段的失败可安全重试;碰过抖音之后的岔子只能算"结果未知",绝不自动重发) - 素材上传:文件名 `手机号_日期_编号`(编号可省)解析配对;标题 txt `标题内容_手机号_日期_编号`; 内容寻址落盘 data/videos/YYYY-MM/(sha1 分块算,同名不存两份),**不进整库备份**但进 manifest 反查 - 新蓝图 web/video_plan_api.py:上传/时间线/统计/单条增删改/推送到手机/标记结果/裁决/链接导出 CSV/ 任务列表与一键新建、**就地编辑**(GET/PUT /tasks/<id>)、**一键推送**(POST /push_all,按设备分组、设备内串行) - 账号页拆子分栏(台账 / 发布计划)+ static/admin/release.js;清理 job(04:41 僵尸回收+过期行、04:47 素材文件) - 上传体积:MAX_CONTENT_LENGTH(默认 2GiB)+ 413 JSON + nginx client_max_body_size(修现有 APK 上传隐患) 二、任务侧(平台推素材,抖音流程你自己写) - 新步骤 push_release「推送发布视频」:原子占位 → adb push → **touch 改成"现在"** → 清旧目录同名副本 → 触发扫描并**按路径**校验相册索引 → 标题写进剪贴板;默认目录 /sdcard/DCIM/Camera - 新步骤 mark_release「标记发布结果」:回写 done/failed/unknown,成功时抓作品分享链接、删手机素材 - input_text 支持 text_source=release_title(自动取计划标题 + 回读校验); if_el 的候选值来源新增 release(**本机当前发布计划**的抖音号/昵称,发布前校验"登的是不是要发的号") - build_release_steps 骨架 15 步:⓪ 亮屏 → ① 打开抖音(等首页) → ② 点「我」→ ③ 等抖音号出现 → ④ 条件判断(账号) → then ⑤ 推送 ⑥⑦⑧⑨⑩⑪⑫ 抖音点击/填标题 → ⑬ 标记 / else 发通知跳过 三、修(推送这一路的检测机制) - **uiautomator2 3.x 的 d.shell() 返回 ShellResponse(tuple 子类)不是 str**:`'x' in resp` 恒 False、 `.strip()` 不存在 → "推上去的文件大小不对"每次都判失败(文件其实推上去了)、相册校验永远报没进、 删除确认永远判没删掉。新增 publish_flow._sh() 统一取 .output;大小改成解析 ls -l 的大小列 - **adb push 保留本地 mtime** → 推 3 天前上传的素材在按时间排序的相册里排不到最前, "点第一个 = 刚推的那个"不成立 → 推完 touch - 相册校验**按路径**比(MediaStore 的 _data 会把目录小写、/storage/emulated/0 ≡ /sdcard), 只比文件名会被老目录的同名残留骗过去 - 屏幕没亮就启动抖音会永远停在启动页(UI 树为空)→ 后面"点我/等抖音号"必然 miss, 最后报成误导人的"账号不符" → 骨架第一步固定加「亮屏」,open_app 等「首页」出现 四、其它 - core/ledger.serial_of():设备名 → 当前地址(设备换 IP 后快照是错的) - 通知事件 task.video.published / task.video.failed;备份清单加 video_plan 与素材统计 - 文档同步:DATA_MODEL §2.11 + schema v10、API(新接口与语义)、TASK_DEV §4.7 专章、 ARCHITECTURE(账号页子分栏/release.js/两个 job)、DEPLOY(表数/nginx)、NOTIFY、DEVELOPMENT、README
33 KiB
架构详解(ARCHITECTURE)
适用读者:要改后端 / 前端 / 任务引擎的开发者。 相关文档:DATA_MODEL.md(表结构)、API.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 §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 桶)、hour=4, minute=41 挂发布计划清理(_purge_video_plan:僵尸占位回收 + 过期行)、hour=4, minute=47 挂素材文件清理(_purge_video_files)——时间刻意错开(都是删数据的大操作),各任务自建 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不能反向 importtask_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>+ 8 个 Tab 面板 + 10 个模态框容器 + 17 个<script src> - 独立页面:
login.html(登录)、wall.html(监控大屏,完全自包含,自带 CSS/JS,不加载static/admin/*.js) - 服务端内联页:
GET /locate(设备端定位大字页,免登录) - 响应头强制
no-store,避免后台改版后浏览器拿旧页面
6.2 Tab 与子分栏
| 顶级 Tab | data-tab |
权限 | 子分栏 |
|---|---|---|---|
| 监控 | monitor |
登录即可 | — |
| 账号 | account |
devices |
ledger 账号台账(列表/增删改/粘贴导入)/ plan 发布计划(上传视频与标题、时间线、推送到手机、导出链接 + 发布任务就地编辑,见 DATA_MODEL.md §2.10 §2.11) |
| 任务 | 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 不重置页码/搜索/排序(避免轮询刷新打断用户) |
ledger.js |
账号台账页(列表 / 单条增删改 / 从表格粘贴导入 + 逐行结果 / 设备维度弹窗)+ ledgerCell() 供设备池那一列 |
服务层 core/ledger.py;导入默认先预览再写(dry_run) |
release.js |
发布计划页(时间线按日期分组 / 多选视频串行上传 + 逐文件进度与结果 / 标题 txt 上传 / 推送到手机 + 一键推送(当天全部)、相册校验徽章与裁决 / 链接复制与导出 CSV / 发布任务的就地编辑) | 服务层 core/video_plan.py;上传前把队列按文件名排序并让用户确认解析结果(顺序 = "无编号按顺序配"的依据)。就地编辑只放常用字段(名字/目标/时间/启停/逐步参数 + 抓取元素),更复杂的编排走「打开通用编辑器」→ openTaskModal;步骤路径里 then/else/children 在步骤的 params 下(_rpWalk),走错这一层会把分支当数组下标用 |
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 §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;都是全局脚本(非 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 §6) |
| 文档同步 | 文档落后会误导开发与运维 | 功能/配置/接口改动的同一个 commit 里更新 doc/(索引见 doc/README.md) |
9. 已知问题与"坑"
9.1 代码缺陷(截至 2026-09-10,详见 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 |
| 加一个专属任务类型 | tasks/<app>/(照 tasks/generic/ 结构)+ tasks/__init__.py 注册 |
重启生效;更新 TASK_DEV.md |
| 加一个 HTTP 接口 | 对应功能域的 web/xxx_api.py |
加鉴权装饰器;更新 API.md |
| 加一个蓝图 | 新建 web/xxx_api.py + 在 web/__init__.py 注册 |
更新 API.md 与本文 §1 |
| 加一张表 | core/models.py 模型 + SCHEMA_MIGRATIONS(老库) |
登记进备份覆盖清单 + DATA_MODEL.md(红线) |
| 加一个常驻线程 | 参考 device_discovery 的 init_app / shutdown 模式 |
更新本文 §3 与 DEVELOPMENT.md |
| 加一个前端模块 | static/admin/<name>.js + 在 monitor.html 按序引入 |
更新本文 §6 与 DEVELOPMENT.md |
| 加一个通知事件 | core/notify_events.py 的 EVENTS 加一条 + 触发点调 notifier.notify(key, **fields) |
事件目录要做全、默认不启用;notify 必须放在锁外(见 NOTIFY.md §7) |
| 加一种通知格式 | core/notifier.py 的 ADAPTERS 注册一个 BaseAdapter 子类 |
补字节上限/默认限流;更新 NOTIFY.md §5 |
| 加一个 MCP 工具 | mcp_server/mcp_server.py(需要时加 direct_ops.py) |
写操作要挂门控三连;更新 MCP.md |
| 加一个配置键 | config.py(程序级)或 .env(密钥类) |
更新 .env.example + DEVELOPMENT.md 配置速查 |