Files
butubb b98e6deac1 feat(发布计划): 视频发布计划(批量上传配对 → 时间线 → 推送到手机 → 发布任务 → 分享链接)
一、平台侧(账号 → 发布计划页)
- 新表 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
2026-09-28 15:59:09 +08:00

33 KiB
Raw Permalink Blame History

架构详解(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 不能反向 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> + 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 配置速查