Files
butubb a800d051ae feat(发布计划): 标题行两种写法都认 + 行首 # 不再无条件当注释
一、标题行解析(core/video_plan.parse_title_line)
- 原来只认「标题内容_手机号_日期_编号」(右锚定)。现在**两种都认**:
   ① 标题内容_手机号_日期_编号        (推荐,右锚定 —— 标题里带下划线也不会错配)
   ② 手机号_日期_编号_标题            (左锚定,跟视频文件名同序 = "文件名去掉扩展名 + 标题")
  两种里都**先试带编号的**,保证同一行永远只有一种解释;编号都可省略。
- 新拒收一条:`手机号_日期_1`(只有编号、没标题)**直接拒** ——
  不能把它当标题"1"(静默生成一条标题是"1"的文案,比拒收危险得多);
  标题真是纯数字的用写法①。

二、`#` 开头的行(parse_titles_text)
- 原来是"以 `#` 开头就整行忽略" → **`#中秋快乐_...` 这种正常标题会被静默丢掉**。
- 改成**先按标题行解析,解析得出就当标题;解析不出且以 `#` 开头才算注释**。
  `# 这是注释` 照样忽略,`#话题` 开头的标题照收(`#` 保留在标题里)。

三、其它
- 文案同步:上传标题面板/帮助文案、doc/API.md 的 upload_titles 语义(两种写法、`#` 规则、拒收条件)
- 测试:新增 8 条(两种写法 × 带/不带编号 × 标题含下划线与 #话题、只有编号要拒收、整段注释与报错)
2026-09-29 09:00:43 +08:00

1010 lines
62 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# HTTP 接口文档(API)
> 适用读者:前端开发、外部接入方、排接口问题的运维。
> 相关文档:[ARCHITECTURE.md](ARCHITECTURE.md)(分层与装配)、[DATA_MODEL.md](DATA_MODEL.md)(数据)、[MCP.md](MCP.md)(给 AI 的工具层,不是 HTTP)。
> 代码位置:全部路由在 `web/` 包下,**10 个蓝图,全部 `url_prefix` 为空**(路径即代码里写的路径)。
---
## 目录
- [1. 通用约定](#1-通用约定)
- [2. 接口总索引](#2-接口总索引)
- [3. 认证与页面](#3-认证与页面)
- [4. 设备状态与运行控制](#4-设备状态与运行控制)
- [5. 任务类型 / 任务计划 / 分组 / 自定义动作](#5-任务类型--任务计划--分组--自定义动作)
- [6. 设备池与自动发现](#6-设备池与自动发现)
- [7. 远程看屏与设备操作](#7-远程看屏与设备操作)
- [8. 元素抓取与步骤测试](#8-元素抓取与步骤测试)
- [9. 应用管理(APK)](#9-应用管理apk)
- [10. 用户与日志](#10-用户与日志)
- [11. 运维工具(adb / 剪贴板 / 应用版本 / Tailscale)](#11-运维工具adb--剪贴板--应用版本--tailscale)
- [12. AI 控制台](#12-ai-控制台)
- [13. 系统备份](#13-系统备份)
- [14. 设备端应用商店](#14-设备端应用商店)
- [15. 非 JSON 响应汇总](#15-非-json-响应汇总)
- [16. 错误分支速查](#16-错误分支速查)
- [17. 已知问题](#17-已知问题)
---
## 1. 通用约定
### 1.1 认证
- 基于 **Flask-Login session**(Cookie)。
- 未登录访问受保护接口 → **302 重定向到 `/login?next=<原路径>`**(不是 401 JSON)。前端 `apiGet/apiPost/...` 收到 401/302 会跳登录页。
- 登录:`POST /login`(表单 `username` / `password`)。失败返回 **HTTP 200 + HTML 登录页**(带错误文案,不区分"用户不存在/密码错",防枚举)。
### 1.2 权限
| 装饰器 | 语义 | 未通过时 |
|--------|------|---------|
| `@login_required` | 登录即可 | 302 → `/login` |
| `@perm_required(PERM_X)` | 需权限位 `tasks` / `devices` / `apks` / `logs` | **403** `{"ok":false,"error":"无权限执行此操作(需要权限: …)"}` |
| `@admin_required` | 仅管理员 | **403** `{"ok":false,"error":"仅管理员可执行此操作"}` |
管理员 `is_admin=true` 恒通过权限位检查。前端用 `data-perm` / `_can(perm)` 隐藏入口,**只是体验优化,安全依赖后端**。
### 1.3 CSRF
- `GET /api/csrf` 取 token(存在 session);前端对所有非 GET 请求带 `X-CSRF-Token`。
- ⚠️ **当前服务端并未强制校验**(`web_server.py` 只 import 了 `_csrf_protect`,没有注册 `before_request`)。即 token 已签发、前端已携带,但**伪造请求不会被拦**。见 [§17 已知问题](#17-已知问题)。
### 1.4 响应约定
- 绝大多数接口返回 **JSON**:成功 `{"ok": true, ...}`,失败 `{"ok": false, "error": "中文原因"}`(部分接口额外带 `msg`)。
- **HTTP 状态码与 `ok` 并存**:参数错误用 400,权限 403,不存在 404,冲突 409,依赖不可用 502/503。少数接口用 `{"ok":false}` + HTTP 200(如 `POST /api/jobs/<id>/run` 的"任务类型不存在")。以本文每节标注为准。
- 时间统一字符串:`"YYYY-MM-DD HH:MM"`(展示)或 `"YYYY-MM-DD HH:MM:SS"`(cron 相关)。
- 非 JSON 接口(HTML / 图片 / SSE / MJPEG / zip)见 [§15](#15-非-json-响应汇总)。
---
## 2. 接口总索引
> 本表列出全部业务路由(页面路由除外;同一路径的不同方法合并成一行)。
> `鉴权` 列:`—` 无、`L` 登录、`T/D/A/G` = tasks/devices/apks/logs 权限位、`Admin` 仅管理员。
> 想看实际注册了多少条:`python -c "from web_server import app; print(len([r for r in app.url_map.iter_rules() if r.rule.startswith('/api')]))"`。
### 2.1 auth(`web/auth.py`)
| 方法 | 路径 | 鉴权 | 功能 |
|------|------|------|------|
| GET | `/` | L | 单页应用首页(HTML) |
| GET | `/wall` | L | 监控大屏页面(HTML) |
| GET | `/login` | — | 登录页(HTML) |
| POST | `/login` | — | 登录(表单;成功 302,失败 200 + HTML) |
| GET | `/logout` | L | 登出 → 302 `/login` |
| GET | `/api/csrf` | L | 取/生成 CSRF token |
| GET | `/api/me` | L | 当前用户信息(id/username/is_admin/perms) |
### 2.2 monitor(`web/monitor.py`)
| 方法 | 路径 | 鉴权 | 功能 |
|------|------|------|------|
| GET | `/api/status` | L | 设备全量状态 + 服务器时间 + 前台扫描状态 |
| GET | `/api/summary` | L | 状态计数 + 异常设备列表(最多 50) |
| GET | `/api/health` | — | 健康检查(免登录探活) |
| POST | `/api/scan_foreground` | D | 触发前台 App 扫描 |
| GET | `/api/devices` | L | 在线设备列表:`devices`(serial 列表) + `items`([{serial,name,model}],界面显示设备名用) |
| GET | `/api/devices/<serial>/apps` | L | 指定设备已安装应用列表 |
| GET | `/api/device/screenshot` | L | 单张设备截图(PNG) |
| GET | `/api/screen/stream` | D | 远程看屏 MJPEG 流 |
| GET | `/api/screen/thumb` | D | 大屏缩略图(JPEG,带头 `X-Screen-State`) |
| GET | `/api/screen/size` | D | 屏幕原生分辨率 |
| POST | `/api/screen/tap` | D | 远程点击(可 `snap=1` 吸附元素) |
| POST | `/api/screen/swipe` | D | 远程滑动 |
| POST | `/api/screen/key` | D | 远程按键(白名单) |
| POST | `/api/screen/text` | D | 远程输入文字 |
| POST | `/api/screen/tap_text` | D | 按屏幕文字点击(UI 树 → OCR 兜底) |
| POST | `/api/stop_device` | D | 停止单台设备任务 |
| POST | `/api/stop_all` | D | 停止全部任务 |
| POST | `/api/device/clear_error` | D | 清除单台设备异常状态 |
| POST | `/api/device/clear_all_errors` | D | 清除全部异常(跳过运行中) |
| POST | `/api/device/screen_all` | D | 批量亮屏/息屏 |
| POST | `/api/device/locate` | D | 点亮屏幕 + 在设备上打开定位大字页 |
| POST | `/api/device/locate/stop` | D | 结束定位 |
| GET | `/locate` | — | 设备端定位大字页(HTML,免登录) |
### 2.3 tasks(`web/tasks_api.py`)
| 方法 | 路径 | 鉴权 | 功能 |
|------|------|------|------|
| GET | `/api/task_types` | L | 全部已注册任务类型 |
| GET | `/api/actions` | L | 指定 task_type 支持的专属操作 |
| GET | `/api/jobs` | L | 任务计划列表(含 `next_run`、`coverage`) |
| POST | `/api/jobs` | T | 新建任务 |
| PUT | `/api/jobs/<job_id>` | T | 更新任务(白名单字段) |
| DELETE | `/api/jobs/<job_id>` | T | 删除任务 |
| POST | `/api/jobs/<job_id>/run` | T | 立即执行(异步) |
| POST | `/api/jobs/<job_id>/toggle` | T | 启用/停用 |
| GET | `/api/groups` | L | 分组列表 |
| POST | `/api/groups` | T | 新建分组 |
| PUT | `/api/groups/<name>` | T | 更新分组 |
| DELETE | `/api/groups/<name>` | T | 删除分组 |
| GET | `/api/custom_actions` | L | 自定义动作列表 |
| POST | `/api/custom_actions` | T | 新建自定义动作 |
| PUT | `/api/custom_actions/<action_id>` | T | 更新自定义动作 |
| DELETE | `/api/custom_actions/<action_id>` | T | 删除自定义动作 |
| GET | `/api/step_defaults` | L | 步骤默认值(生效值 + 出厂值 + 字段规格,供「动作配置」页画表单) |
| POST | `/api/step_defaults` | T | 保存步骤默认值(只落与出厂值不同的字段) |
| POST | `/api/gesture/record/start` | D | 开始录制手势(手机端 getevent,返回 token) |
| POST | `/api/gesture/record/stop` | D | 停止录制并取回轨迹 `gestures:[[[x,y,t_ms],…]]` |
| GET | `/api/uiauto/status` | L | uiautodev 服务是否在跑 |
| GET | `/api/uiauto/devices` | D | 抓元素可选设备 |
| GET | `/api/uiauto/elements` | D | 设备 UI 元素树 |
| GET | `/api/uiauto/snapshot` | D | **一次取齐**截图 + 元素树(原生 u2,抓取弹窗用) |
| GET | `/api/uiauto/screenshot` | D | uiautodev 截图(JPEG) |
| POST | `/api/steps/test` | D | 真机试执行单个步骤 |
| GET | `/api/done_marks?job=&limit=` | L | 去重记录列表 + 统计(`{marks, stats{total,devices,identities,today_devices,today_identities}}`) |
| POST | `/api/done_marks/delete` | T | 删一条去重记录(`{id}`)→ 该设备/身份下次会重新执行 |
| POST | `/api/done_marks/clear` | T | 清空某任务的全部去重记录(`{job}`)→ 整批重跑 |
| GET | `/api/ledger?device=&q=&limit=` | D | 账号台账列表 + 统计(`{accounts, stats{total,devices,can_post_video,shown}, device_names}`) |
| GET | `/api/ledger/by_device` | D | 按设备分组(`{devices:{设备号:{count,accounts[]}}, counts:{设备号:N}}`) |
| POST | `/api/ledger` | D | 新增账号(抖音号重复 → 400) |
| PUT | `/api/ledger/<acc_id>` | D | 改一条账号 |
| DELETE | `/api/ledger/<acc_id>` | D | 删一条账号 |
| POST | `/api/ledger/import` | D | 从表格粘贴文本批量导入(`dry_run=true` 只预览不写库) |
| POST | `/api/video_plan/upload_video` | D | 上传单个视频素材(文件名 `手机号_日期_编号`)→ 解析 + 配对 + 入计划 |
| POST | `/api/video_plan/upload_titles` | D | 上传标题(txt 文本或文件)→ 配到已有计划行 |
| GET | `/api/video_plan/timeline` | D | 发布计划时间线(按日期分组的卡片 + 统计 + 磁盘) |
| GET | `/api/video_plan/stats` | D | 今天要发/已发/待标题/过期未发 + 素材占用与磁盘余量 |
| GET/PUT/DELETE | `/api/video_plan/<id>` | D | 单条计划:查看 / 改标题 / 删除(连素材文件) |
| GET | `/api/video_plan/<id>/video` | D | 预览平台上的素材(文件清理后 → 410) |
| POST | `/api/video_plan/<id>/push` | D | **把素材推到手机**(只推送、不发布;后台线程) |
| POST | `/api/video_plan/push_all` | D | **一键推送**:某天(默认今天)所有待发布/失败的计划 → 各自手机(每台设备一个线程、设备内串行) |
| POST | `/api/video_plan/<id>/mark` | D | 人工标记结果(`done`/`failed`/`unknown`,推完之后用) |
| POST | `/api/video_plan/<id>/skip\|retry\|resolve` | D | 跳过 / 重试 / 裁决「结果未知」 |
| GET | `/api/video_plan/links` | D | 已发布作品的分享链接(`format=csv` 导出,给铺评论用) |
| GET | `/api/video_plan/tasks` | T | 可编辑的任务列表(全部通用步骤任务 + `is_release` 标记:状态/调度/下次运行 + 完整步骤) |
| POST | `/api/video_plan/tasks` | T | 一键新建标准发布任务(骨架 15 步:亮屏 → 打开抖音 → 校验账号 → 推送 → 抖音点击 → 填标题 → 标记) |
| GET/PUT | `/api/video_plan/tasks/<job_id>` | T | **发布任务的就地编辑**(就在「发布计划」页改名字/目标/时间/启停/步骤) |
| POST | `/api/video_plan/tasks/<job_id>/adopt` | T | 给已有任务插上平台两步(推送放最前 / 标记放最后,自己的步骤不动) |
### 2.4 admin(`web/admin_api.py`)
| 方法 | 路径 | 鉴权 | 功能 |
|------|------|------|------|
| GET | `/api/users` | Admin | 用户列表 |
| POST | `/api/users` | Admin | 新建用户 |
| PUT | `/api/users/<int:uid>` | Admin | 更新用户(改密/权限/管理员) |
| DELETE | `/api/users/<int:uid>` | Admin | 删除用户 |
| GET | `/api/logs` | G | 读日志(关键字/级别/时间过滤,返回结构化行) |
| GET | `/api/logs/download` | G | 下载日志(同样支持过滤;无过滤即整个文件) |
| GET | `/api/step_logs` | G | 任务步骤明细(按设备/任务/结果/时间/关键字过滤,分页) |
| GET | `/api/step_logs/runs` | G | 按 `run_id` 归组的一次运行概览(几步、失败几步) |
| GET | `/api/step_logs/filters` | G | 步骤明细的筛选项(明细里出现过的设备/任务 + 结果枚举) |
| GET | `/api/step_logs/download` | G | 导出步骤明细 CSV(带 UTF-8 BOM,Excel 直接打开) |
### 2.5 devices(`web/devices_api.py`)
| 方法 | 路径 | 鉴权 | 功能 |
|------|------|------|------|
| GET | `/api/devices/pool` | D | 设备池清单(附实时在线状态 + 设备指纹) |
| POST | `/api/devices/pool/add` | D | 添加/更新设备(**名称必填唯一**,按指纹自动认领换 IP 的设备) |
| POST | `/api/devices/pool/rename` | D | 重命名设备(名称唯一) |
| POST | `/api/devices/pool/relocate` | D | **人工认领**:把记录迁到新地址并同步分组/任务引用 |
| POST | `/api/devices/pool/remove` | D | 从池中删除 |
| POST | `/api/devices/pool/toggle` | D | 启用/停用(停用不参与调度) |
| POST | `/api/devices/pool/reconnect` | D | 一键重连全部池内网络设备 |
| GET | `/api/devices/qrcode?serial=` | D | 生成该设备的**配置二维码**(设备端 Agent 扫码即配好) |
| POST | `/api/devices/pool/refresh_models` | D | 批量采集型号 |
| GET | `/api/devices/discovery` | D | 发现状态 + 待连接 + 断联列表 |
| POST | `/api/devices/discovery/scan` | D | 手动触发一轮扫描 |
| POST | `/api/devices/discovery/confirm` | D | 确认连接(待连接 → 设备池) |
| POST | `/api/devices/discovery/ignore` | D | 忽略待连接设备 |
| POST | `/api/devices/discovery/reconnect` | D | 立即重连指定断联设备 |
| POST | `/api/devices/discovery/settings` | D | 保存发现配置 |
| GET | `/api/devices/battery` | D | 电量采集器状态 + 生效配置(`{settings, scanning, last_scan:[时间,成功,目标数], cached}`) |
| POST | `/api/devices/battery/settings` | D | 保存电量监控配置(越界钳制,返回钳制后的生效值) |
| POST | `/api/devices/battery/scan` | D | 立即采集一次(后台执行;上一轮没跑完返回 409) |
### 2.6 apks(`web/apks_api.py`)
| 方法 | 路径 | 鉴权 | 功能 |
|------|------|------|------|
| GET | `/api/apks` | L | APK 列表 |
| POST | `/api/apks/upload` | A | 上传 APK(multipart,字段 `file`) |
| DELETE | `/api/apks/<apk_id>` | A | 删除 APK |
| POST | `/api/apks/install` | A | 批量安装到指定设备 |
| GET | `/api/apks/install/devices` | A | 可安装设备列表 |
| GET | `/api/apks/install/status` | L | 安装进度 |
### 2.7 tools(`web/tools_api.py`)
| 方法 | 路径 | 鉴权 | 功能 |
|------|------|------|------|
| GET | `/api/adb/devices` | Admin | 维护终端设备列表(本机 adb + 设备池) |
| POST | `/api/adb/cmd` | Admin | 执行 adb 命令(20s 超时;拦截 `kill-server`/`disconnect`) |
| POST | `/api/tools/clipboard/set` | Admin | 剪贴板注入到多台设备 |
| POST | `/api/tools/appver` | Admin | 查指定包名在所有在线设备的版本 |
### 2.8 tailscale(`web/tailscale_api.py`,全部 Admin)
| 方法 | 路径 | 功能 |
|------|------|------|
| GET | `/api/tailscale/status` | 配置状态(key/tailnet 是否就绪) |
| GET | `/api/tailscale/devices` | tailnet 设备列表 |
| POST | `/api/tailscale/devices/<device_id>` | 更新设备(按字段分发:改名 / 授权 / 密钥不过期) |
| POST | `/api/tailscale/devices/<device_id>/ip` | 设置设备 IPv4(**会断开 tailscale 会话**) |
| DELETE | `/api/tailscale/devices/<device_id>` | 从 tailnet 移除 |
| POST | `/api/tailscale/authkey` | 生成设备接入 auth key |
### 2.9 agent(`web/agent_api.py`,全部 Admin)
| 方法 | 路径 | 功能 |
|------|------|------|
| GET/POST | `/api/agent/config` | 读写 AI 配置(key 打码回显) |
| GET | `/api/agent/devices` | AI 可用设备(含名称与 busy 标记) |
| POST | `/api/agent/run` | 启动一轮 AI 会话(`mode:"chat"`=聊天 / `"designer"`=AI 建任务) |
| GET | `/api/agent/run` | 运行状态(刷新恢复用;含 `mode`/`draft`) |
| GET | `/api/agent/stream` | **SSE 事件流** |
| POST | `/api/agent/stop` | 中断当前运行 |
| POST | `/api/agent/clear` | 清空对话历史 |
| GET/POST | `/api/agent/conversations` | 会话列表 / 新建会话 |
| GET/DELETE | `/api/agent/conversations/<conv_id>` | 会话详情 / 删除 |
| POST | `/api/agent/conversations/<conv_id>/rename` | 重命名会话 |
| GET | `/api/agent/experience` | 经验库列表 + 巡检状态 |
| POST | `/api/agent/experience/audit` | 手动触发巡检 |
| POST | `/api/agent/experience/delete` | 删除经验(人工确认) |
| POST | `/api/agent/experience/keep` | 保留经验(撤销建议删除) |
| GET | `/api/agent/actions` | 动作库列表 |
| POST | `/api/agent/actions/save` | 新增/编辑动作(禁坐标) |
| POST | `/api/agent/actions/delete` | 删除动作 |
| GET | `/api/agent/task_draft` | **AI 建任务**:读回最近一份草稿(`{saved, running, mode, run_id, draft, draft_error}`) |
| POST | `/api/agent/task_draft` | 回存草稿并**重新校验**(不通过返回 400 + `errors`) |
| POST | `/api/agent/task_draft/create` | **直接创建**:取暂存草稿 → 再校验 → `add_job` → 清草稿(body 可带 `{serial, overrides}`;草稿体不接受客户端提交) |
| POST | `/api/agent/task_draft/clear` | 丢弃草稿 |
**AI 建任务(designer 模式)要点**(详见 [AI_TASK_GEN.md](AI_TASK_GEN.md)):
- `POST /api/agent/run` 额外接受 `mode:"designer"` 与 `settings`(页面上的任务设置,作为草稿
的 overrides:`name`/`target_mode`/`group_name`/`serial`/`schedule`/`max_duration`)。
designer 跑**不绑会话**(单轮,不吃聊天历史、也不污染会话)。
- designer 模式下 Agent 多一个**平台级本地工具** `submit_task(draft)`(**不在 MCP 层**):
服务端用 `core/task_draft.validate_draft` 校验,失败把 `errors` 回灌给模型让它改;
通过也只**暂存**(运行态 + `app_meta.agent_task_draft`),**不落库**——入库仍要用户在
步骤编辑器里确认后走 `POST /api/jobs`。
- `done` 事件在 designer 下多带 `{mode, draft, warnings, draft_error}`。
- 设备忙 409、单实例运行、权限同聊天模式。
### 2.10 system(`web/system_api.py`,全部 Admin)
| 方法 | 路径 | 功能 |
|------|------|------|
| POST | `/api/system/backup/export` | 导出 zip(**JSON body**) |
| POST | `/api/system/backup/preview` | 上传备份校验预览(multipart,字段 `file`) |
| POST | `/api/system/backup/apply` | 应用恢复(重启生效) |
### 2.11 notify(`web/notify_api.py`,全部 Admin)
通知 / Webhook 配置与发送记录(事件目录、推送格式、限流语义见 [NOTIFY.md](NOTIFY.md)):
| 方法 | 路径 | 功能 |
|------|------|------|
| GET | `/api/notify/webhooks` | 全部 webhook(`url` 打码、`secret` 只回 `secret_set`)+ 格式清单 |
| POST | `/api/notify/webhooks` | 新建(body 见 NOTIFY.md §4) |
| PUT | `/api/notify/webhooks/<id>` | 更新(`url`/`secret` 省略或为打码值 → **保持原值**) |
| DELETE | `/api/notify/webhooks/<id>` | 删除 |
| POST | `/api/notify/webhooks/<id>/test` | **同步**发一条测试消息(不占业务令牌桶,单独限 10 次/分) |
| POST | `/api/notify/preview` | 预览真实请求体 + UTF-8 字节数 + 是否截断 |
| GET | `/api/notify/events` | 事件目录(含字段清单) |
| GET | `/api/notify/logs?limit=` | 最近发送记录(内存 200 条,重启清空) |
| POST | `/api/notify/settings` | 全局开关与默认聚合/限流 |
### 2.12 device_agent(`web/device_agent_api.py`)
**设备端专用**(无登录会话,靠 `X-Device-Token` 鉴权;平台未启用时统一 404):
| 方法 | 路径 | 功能 |
|------|------|------|
| GET | `/api/device/agent/bootstrap` | 设备拉自己的身份 + 可安装应用清单 |
| GET | `/api/device/agent/apk/<apk_id>` | 下载 APK 文件 |
| POST | `/api/device/agent/report` | 上报下载/安装结果 |
**管理端**(登录 + 应用管理权限):
| 方法 | 路径 | 功能 |
|------|------|------|
| GET | `/api/agent-store/config` | 读配置(含设备令牌) |
| POST | `/api/agent-store/config` | 启用/停用、重置令牌 |
| GET | `/api/agent-store/logs` | 设备端安装记录 |
| POST | `/api/agent/show-info` | 让设备上的 Agent 显示/关闭**身份大字页**(监控页「打开设备端 Agent」按钮)|
> 完整协议、鉴权与版本约定见 [DEVICE_AGENT.md](DEVICE_AGENT.md)。
---
## 3. 认证与页面
### POST /login
表单 `username` / `password`。
- 成功:`login_user` + 写 session 的 CSRF token → **302** 到 `?next=` 或 `/`
- 失败:**200** + 登录页 HTML(含"用户名或密码错误")
### GET /logout · GET /api/csrf · GET /api/me
```json
// GET /api/csrf
{"ok": true, "token": "…"}
// GET /api/me
{"ok": true, "user": {"id": 1, "username": "admin", "is_admin": true,
"perms": ["tasks","devices","apks","logs"]}}
```
管理员返回全部权限位。页面:`GET /`(单页应用)、`GET /wall`(大屏)、`GET /locate`(设备端定位页,免登录,只显示 serial 文本)。
---
## 4. 设备状态与运行控制
### GET /api/status
监控页 5s 轮询的主接口。
```json
{"ok": true, "server_time": 1700000000.0, "fg_scanning": false, "fg_last_scan": 0,
"devices": [{
"serial": "192.168.20.206:5555", "model": "22120RN86C", "device_name": "", "present": true, "ready": true,
"worker_status": "running", "task_job": "测试抖音评论", "attempt": 1, "max_attempts": 1,
"current_action": "点击搜索", "progress": {"done": 5, "total": 0, "unit": "操作", "elapsed": 42},
"foreground_app": "抖音", "last_error": "", "last_warning": "", "end_time": 0,
"battery": {"level": 85, "charging": true, "at": 1700000000.0, "tier": 0}
}]}
```
`worker_status`:`idle` / `connecting` / `running` / `done` / `error` / `released` / `failed`。
`battery`:**后台线程采的缓存值**(默认 60s 一轮,`dumpsys battery` 只读),没采到时为 `null`。
`tier` 是**告警档位**(`0` 正常 / `1` 低电量 / `2` 严重),由后端按配置阈值算好——
前端只按 `tier` 上色,**不在 JS 里重算阈值**(阈值只在「工具 → 设备发现 → 电量监控」一处定义)。
设备离线时保留最后一次读数(`at` 是采集时刻),页面置灰显示。详见 [NOTIFY.md](NOTIFY.md) §3 的
`device.battery.low`。
### GET /api/summary
```json
{"ok": true, "counts": {"running":1,"done":2,"error":0,"idle":2,"failed":0},
"errors": [{"serial":"…","task":"…","last_error":"…","attempt":3,"updated":1700000000.0}]}
```
### GET /api/health
免登录探活:`{"ok":true,"status":"up","time":…,"device_total":3,"device_online":3,"device_running":0,"device_error":0,"jobs":2}`(组件异常时 `status:"down"` + 500)。
### 运行控制
| 接口 | 请求 | 说明 |
|------|------|------|
| `POST /api/stop_device` | `{"serial":"…"}` | 无运行中任务 → 400 |
| `POST /api/stop_all` | — | 停止全部 |
| `POST /api/device/clear_error` | `{"serial":"…"}` | 运行中/重试等待中拒绝清理 |
| `POST /api/device/clear_all_errors` | — | 跳过 running/connecting |
| `POST /api/device/screen_all` | `{"mode":"on"\|"off", "serials":[…]}` | 不传 serials 则对全部在线设备。**熄屏是单向的**(`KEYCODE_SLEEP`),不会把已息屏的设备唤醒;亮屏用 `KEYCODE_WAKEUP` + `dismiss-keyguard` |
| `POST /api/scan_foreground` | — | 已扫描中时返回 `{"ok":false}`(**HTTP 200**) |
| `POST /api/device/locate` | `{"serial":"…","show":true}` | `show=true` 时在设备上打开 `/locate` |
| `POST /api/device/locate/stop` | `{"serial":"…"}` | 结束定位 |
| `GET /api/devices` | — | `{"ok":true,"devices":["100.100.10.x:5555", …]}` |
| `GET /api/devices/<serial>/apps` | — | 该设备已安装应用(包名/版本/路径) |
> ⚠️ `/api/device/locate` 的 `show=true` 分支当前会失败(`urllib` 未导入 → 502);`GET /locate` 本身返回 500。见 [§16](#16-已知问题)。
---
## 5. 任务类型 / 任务计划 / 分组 / 自定义动作
### GET /api/task_types
```json
{"ok": true, "task_types": [
{"task_type": "generic_steps", "name": "通用步骤", "description": "…",
"default_params": {"max_duration": 0}}]}
```
> 当前平台**只有 `generic_steps` 一种类型**。`default_params` **不含 `steps`**——步骤只能由编辑器产出。
### GET /api/actions?task_type=…
返回该任务类型支持的"专属操作"列表(`generic_steps` 返回步骤类型清单 `STEP_TYPES`)。
### GET /api/jobs
```json
{"ok": true, "jobs": [{
"id": "abc123", "name": "刷视频-上午", "task_type": "generic_steps", "enabled": true,
"target": {"mode": "all"}, "params": {"max_duration": 0, "steps": [ … ]},
"schedule": {"mode": "once"}, "retry": {"max_attempts": 1, "delay": 60},
"next_run": "2026-09-11 09:00",
"coverage": {"mode": "all", "total": 3, "serials": ["192.168.20.206:5555", "…"]}
}], "task_types": [ … ]}
```
- `next_run`:下次真正执行时间(已按运行窗口跳过窗口外触发点);手动/停用为 `null`
- **`coverage`**:任务**覆盖的设备**(监控页「任务运行概况」展示用,每次请求现算)——按 `target` 定义解析,**不因设备当前是否空闲而变**,也不做离线过滤、不写调度日志:
| `target.mode` | `coverage.serials` |
|------|------|
| `all` | 设备池全部启用设备 |
| `group` | 分组的 serial ∩ 设备池启用设备(池外的手填 IP 不计入) |
| `serial` | 仅该 serial(即使不在池中也照实返回) |
> 与**调度**口径的差异:真正跑的时候走 `TaskJob.resolve_serials()`(`all` 取"池内 ∩ 在线",`serial`/`group` 默认跳过离线设备)。`coverage` 是"定义层覆盖面",供人看"这台任务管哪些设备"。
### POST /api/jobs
```json
{"name": "刷视频-上午", "task_type": "generic_steps",
"target": {"mode": "all"},
"params": {"max_duration": 0, "steps": [{"id":"step_1","type":"open_app","label":"打开抖音",
"params":{"package":"com.ss.android.ugc.aweme"}}]},
"schedule": {"mode": "cron", "cron": "0 9 * * *"},
"retry": {"max_attempts": 3, "delay": 60}, "enabled": true}
```
响应:`{"ok": true, "msg": "任务已创建", "job": {…含 coverage…}}`
**`schedule` 字段**:
| 字段 | 说明 |
|------|------|
| `mode` | `once` 手动 / `cron` 定时启动 / `cron_stop` 定时启动+停止 |
| `cron` | 标准 5 段 `分 时 日 月 周`(周 `0`/`7` = 周日),如 `0 */2 * * *` |
| `stop_cron` | `cron_stop` 必填,到点停止本任务 |
| `window` | 可选运行窗口 `{"start":"21:00","end":"09:00"}`(支持跨午夜)。**窗口外定时与手动执行都不启动** |
> `params.steps` 要一起传:后端**不提供默认步骤**、也不做参数校验(编辑器是参数正确性的唯一关卡)。不传 steps 也能建成功,但**执行时会立即报错**"通用步骤任务没有可执行步骤"。
### PUT /api/jobs/<job_id> · DELETE /api/jobs/<job_id>
- PUT:只更新请求里出现的字段(`name`/`task_type`/`target`/`params`/`schedule`/`retry`/`enabled`);`task_type` 必须已注册,否则 400
- DELETE:`{"ok":true,"msg":"任务已删除"}`;不存在 404
### POST /api/jobs/<job_id>/run
立即执行(起后台线程,不阻塞)。**HTTP 恒 200**,成功与否看 `ok`:
```json
{"ok": true, "msg": "任务 刷视频-上午 已触发"}
{"ok": false, "error": "任务类型 douyin_nurture 已不存在(该类型已被删除),请删除此任务或改用现有类型"}
```
### POST /api/jobs/<job_id>/toggle
`{"enabled": true|false}` → `{"ok":true,"msg":"任务已启用"}`;不存在 404。
### 分组
| 接口 | 请求 | 响应 |
|------|------|------|
| `GET /api/groups` | — | `{"ok":true,"groups":[{"name":"A组","serials":[…],"description":""}]}` |
| `POST /api/groups` | `{"name","serials":[],"description"}` | 名字空/重名 → 400 |
| `PUT /api/groups/<name>` | `{"serials":[],"description"}` | 不存在 → 404 |
| `DELETE /api/groups/<name>` | — | 不存在 → 404 |
### 自定义动作
| 接口 | 请求 | 说明 |
|------|------|------|
| `GET /api/custom_actions` | — | 列表(按创建时间倒序) |
| `POST /api/custom_actions` | `{"name","icon","steps":[…]}` | name/steps 空 → 400 |
| `PUT /api/custom_actions/<id>` | 同上(部分更新) | 不存在 → 404 |
| `DELETE /api/custom_actions/<id>` | — | 不存在 → 404 |
`steps` 的 schema 与 `generic_steps` 的 `params.steps` 完全一致,见 [TASK_DEV.md](TASK_DEV.md)。
### 去重记录(「任务 → 去重记录」页)
| 接口 | 请求 | 响应要点 |
|------|------|---------|
| `GET /api/done_marks` | `?job=<任务ID>&limit=200` | `{ok, marks:[{id,scope_key,kind,job_id,job_name,serial,device_name,identity,created_at}], stats:{total,devices,identities,today_devices,today_identities}}`。`job` 省略 = 全部任务 |
| `POST /api/done_marks/delete` | `{"id"}` | 删一条 → 该设备/身份下次会重新执行;不存在 → 404;缺 id → 400 |
| `POST /api/done_marks/clear` | `{"job"}` | 清空该任务的全部记录(整批重跑)→ `{ok,msg,deleted}`;缺 job → 400 |
账本表与语义见 [DATA_MODEL.md](DATA_MODEL.md) §2.9;任务侧怎么用见
[TASK_DEV.md](TASK_DEV.md) §4.6。
### 账号台账(「账号」页)
数据在 `device_account` 表([DATA_MODEL.md](DATA_MODEL.md) §2.10),服务层 `core/ledger.py`,
权限 `devices`。
| 接口 | 请求 | 响应要点 |
|------|------|---------|
| `GET /api/ledger` | `?device=<设备号>&q=<搜索>&limit=500` | `{ok, accounts:[{id,device_name,serial,phone,nickname,douyin_id,registered_at,sim_in_device,can_post_video,bio,note,created_at,updated_at}], stats:{total,devices,can_post_video,shown}, device_names:["A01",…]}`。`q` 匹配抖音号/手机号/账号名 |
| `GET /api/ledger/by_device` | — | `{ok, devices:{A01:{count,accounts:[…]}}, counts:{A01:4}}`(设备池"账号 N"列 + 点开看明细都用它,省一次请求) |
| `POST /api/ledger` | 9 个字段(`device_name`/`douyin_id` 必填) | `{ok,msg,account}`;缺必填或抖音号重复 → **400** |
| `PUT /api/ledger/<id>` | 局部字段 | `{ok,msg,account}`;不存在 → 404;抖音号撞车 → 400 |
| `DELETE /api/ledger/<id>` | — | `{ok,msg}`;不存在 → 404 |
| `POST /api/ledger/import` | `{"text":"…","delimiter":"\\t","mode":"skip\|overwrite","dry_run":true}` | `{ok, counts:{total,added,updated,skipped,failed}, rows:[{line,action,device_name,douyin_id,nickname,error,warns}], errors:[{line,reason}], mode, dry_run}` |
**导入语义**(解析规则见 `core/ledger.parse_paste`):
- 分隔符默认 **Tab**(Excel 直接粘贴就是 Tab);也支持 `|` 与 `,`。**不做"多个空格当分隔"**——
账号名里本来就有双空格(实测 `有牛奶面包 你吃吗?`)。
- **带表头**时按列名映射(顺序随意、缺列留空、认不出的列忽略);**不带表头**则按
设备号/手机号/账号名称/抖音号/注册时间/卡在机内/可发视频/简介/备注 的固定顺序。
- 「卡在机内 / 可发视频」认 `是/有/1/true` → True,`否/空` → False;认不出的值按"否"处理
并在该行 `warns` 里留痕。
- 抖音号非纯数字也会 `warns` 留痕(不拦)——它会让任务比对匹配不上。
- `mode=skip`:库里有同号 → 跳过;`mode=overwrite`:**整行替换**(空单元格会覆盖掉原值)。
- `dry_run=true` 走同一条解析+判重路径但**一个字节都不写**(界面默认先预览再导入)。
- 缺设备号或抖音号的行会被跳过,并在 `errors` 里带行号说明。
> ⚠ 台账里的抖音号是**纯号**,只当"比对用的候选值";**不要**拿它填「去重」的身份元素
> (身份是元素原文逐字算 key,格式不同会让去重**静默失效**)。
### 视频发布计划(「账号 → 发布计划」页)
表与状态机见 [DATA_MODEL.md](DATA_MODEL.md) §2.11,任务侧怎么自动发布见
[TASK_DEV.md](TASK_DEV.md) §4.7;权限 `devices`。
| 接口 | 请求 | 响应要点 |
|------|------|---------|
| `POST /api/video_plan/upload_video` | multipart `file`(单文件)+ `replace` | `{ok,name,action:add\|skip\|replace,plan,msg}`;解析/配对不通过 → **400 + `error`(人话原因)**。前端**逐文件串行**发(才能给每个文件一条进度与结果) |
| `POST /api/video_plan/upload_titles` | `{text, dry_run}` 或 multipart `file`(txt) | `{ok, counts:{total,attached,skipped,rejected}, rows:[{line,phone,date,seq,title,action,error}], msg}`;**每行两种写法都认**:`标题内容_手机号_日期_编号`(推荐,右锚定,标题里可带下划线)或 `手机号_日期_编号_标题`(左锚定,跟视频文件名同序),**编号都可省略**;标题里可以有 `#话题`;空行忽略,`#` 开头的行**只有认不出手机号/日期时**才当注释;拒收条件:手机号不在台账/命中多个、日期不合法、标题为空或 >100 字、该槽位已有标题、没有对应视频 |
| `GET /api/video_plan/timeline` | `?range=today\|week\|all&from=&to=&status=&account_id=&q=&limit=` | `{ok, days:[{date,weekday,total,done,plans:[…]}], stats, disk, today}` |
| `GET /api/video_plan/stats` | — | `{ok, stats, disk, today}`;`stats` 里**三个口径都给**:`today_ready`(今天要发几个号,按发布日期)/ `today_done`(今天完成)/ `published_today`(今天实际发的,按 `published_at`,补发也算今天) |
| `GET /api/video_plan/<id>` · `PUT` · `DELETE` | `PUT {"title"}` | `PUT` 只改标题(改完 `pending→ready`);`DELETE` **先删行再删素材文件** |
| `GET /api/video_plan/<id>/video` | — | 素材预览(`send_file`,支持 Range);文件已被清理 → **410** |
| `POST /api/video_plan/<id>/push` | — | **把素材推到手机**(只推送、不发布):推文件 → 触发相册刷新 → 标题写进剪贴板;已推送/结果未知 → 409。发布动作由你写在任务里(见 [TASK_DEV.md](TASK_DEV.md) §4.7) |
| `POST /api/video_plan/push_all` | `{date?, retry_failed?}` | **一键推送**:某天(默认今天)`ready`/`failed` 且未超尝试上限的计划,**按设备分组**推(每台设备一个线程、设备内串行;设备间并行)。返回 `{ok,count,devices,no_device,msg}`;没绑上设备地址的条数单独在 `no_device` 里报出来。全部设备都解析不到 → 400 |
| `POST /api/video_plan/<id>/mark` | `{status: done\|failed\|unknown, why}` | 人工标记结果(推完之后用):`done` 补 `published_at`;`failed`/`unknown` 把 `why` 记进 `last_error` |
| `POST /api/video_plan/<id>/skip` | `{reason}` | 人工跳过 |
| `POST /api/video_plan/<id>/retry` | — | `failed`→`ready`,`attempts` 清零 |
| `POST /api/video_plan/<id>/resolve` | `{published, note}` | **`unknown` 的人工裁决**:确认真发出去了 → `done`;确认没发 → `failed`(可重试) |
| `GET /api/video_plan/links` | `?from=&to=&device=&phone=&format=csv` | 已发布作品的分享链接;CSV 带 BOM(Excel 打开中文不乱码) |
| `GET /api/video_plan/tasks` | — | `{ok, tasks:[{id,name,enabled,mode,cron,next_run,target,steps,step_count,schedule,retry,is_release,has_push}], release_count, other_count}`:**全部通用步骤任务**(不止发布任务 —— 只列发布任务的话,手写的抖音发布流程会一条都不显示,人会以为"这页没有能改的地方"),界面按 `is_release`(有 `push_release` **或** `mark_release`,含嵌套)分两组。带完整 `steps` 是为**就地编辑**(抓选择器);`step_count` 数的是**含嵌套**的总步数。权限 `T` |
| `POST /api/video_plan/tasks` | `{"name","target":{"mode":"all\|group\|serial","group_name","serial"},"time":"10:00","enabled"}` | 一键新建:骨架 **15 步**(亮屏 → 打开抖音(等首页) → 点「我」→ `if_el(cmp_source=release)` 校验账号 → then: `push_release` → 「+」(预填 `descriptionContains=拍摄`) → 相册/第一个视频/下一步/输入框/发布(**空选择器占位**)→ `input_text(text_source=release_title)` → `mark_release`;else: `notify` 跳过);**走 TaskManager 建**(内存+库一起更新,直接写库调度器不认)。⚠ `target` 分组键名是 **`group_name`**(写成 `group` 会静默解析出 0 台设备);分组/单设备没给具体目标 → 400。权限 `T` |
| `GET/PUT /api/video_plan/tasks/<job_id>` | `PUT {name?, time?, target?, steps?, enabled?}` | **发布任务的就地编辑**(「账号 → 发布计划」页里改,不用跳任务页)。只放开这几个字段,**没传的字段原样保留**;`steps` 整块替换但 `params` 里的通用参数(去重有效期、抢占…)**深合并保留**。校验:空名字/坏时间/`steps` 不是数组/什么都不传 → 400,未知任务 → 404。权限 `T`。底层仍是 `TaskManager.update_job()`(内存+库一起改) |
| `POST /api/video_plan/tasks/<job_id>/adopt` | — | 给**已有任务**插上平台那两步:`push_release` 放最前、`mark_release` 放最后(**中间你自己的步骤一步不动**;位置理由见 `core.video_plan.insert_release_steps`)。只对「通用步骤」任务;没有步骤 → 400,已经有这两步 → 409,未知任务 → 404。权限 `T` |
**逐文件上传的幂等**:同一 `(手机号,日期,编号)` 已有行时 —— 文件内容相同 → `action=skip`(不重复落盘);
内容不同 → **拒收**,前端给「覆盖」按钮(二次确认后带 `replace=1` 重传)。
标题同理:同槽位已有别的标题 → 拒收(不静默覆盖,文案是发出去就改不了的东西)。
> ⚠ 上传体积:`MAX_CONTENT_LENGTH`(默认 2GiB,`.env` 的 `MAX_UPLOAD_MB` 可调)**必须大于
> 「应用管理」最大的 APK**(实测 336MB),否则 APK 上传会被一起卡死;超限返回 **JSON 413**。
> 上反代时 nginx 那侧还要 `client_max_body_size`(见 [DEPLOY.md](DEPLOY.md) §6)。
---
## 6. 设备池与自动发现
### 6.1 设备身份:名称 + 指纹
| 概念 | 说明 |
|------|------|
| **名称 `name`** | **必填且唯一**,设备在平台里的人可读标识(分组/任务/日志都按它认设备) |
| **serial** | 设备的**当前连接地址**(`IP:5555` 或 USB 序列号)——**可变** |
| **指纹 `fingerprint`** | `ro.serialno`,识别"同一台物理设备"的稳定标识(只对网络设备采集;USB 的 serial 本身已稳定) |
**为什么需要**:设备池原先拿 serial(IP)当身份,设备一换 IP 就变成"陌生新设备",
旧记录永远连不上,分组与 serial 模式的任务还吊着死地址。现在:
- 添加/确认设备时读取指纹 → 若命中池中已有设备(**同一台换了地址**)→ **自动认领**
- 认领 = 迁移原记录(名称/型号/备注/启用状态/添加时间全保留)+ 把 `device_group.serials`
与 `task_job.target.serial` 里的旧地址**同步换成新地址**(库里与内存一起改)
- 旧地址已断联、指纹也没采过时(自动认领无从匹配)→ 用 `pool/relocate` **人工认领**
> 指纹在设备在线时自动采集(添加/确认/启动刷新/「采集型号」按钮都会补);
> 设备列表的「指纹」列:🔑 = 已采集,— = 尚未采集(离线设备采不到)。
### 6.2 设备池
| 接口 | 请求 | 说明 |
|------|------|------|
| `GET /api/devices/pool` | — | 池内设备 + 实时 `online` + `fingerprint` |
| `POST /api/devices/pool/add` | `{"serial","name","note"?}` | **name 必填**(空 → 400)、**唯一**(重名 → 400)。IP:5555 会轻量 `adb connect` 并读指纹,命中则**自动认领**(响应 `claimed=true` + `old_serial`) |
| `POST /api/devices/pool/rename` | `{"serial","name"}` | 改名(唯一校验;不存在 → 404) |
| `POST /api/devices/pool/relocate` | `{"old_serial","new_serial"}` | 人工认领:迁移记录 + 同步引用;旧地址不在池 → 400,新地址已在池 → 400 |
| `POST /api/devices/pool/remove` | `{"serial"}` | 不存在 → 404 |
| `POST /api/devices/pool/toggle` | `{"serial","enabled"}` | 停用则不参与调度 |
| `POST /api/devices/pool/reconnect` | — | 后台并发重连全部网络设备 |
| `POST /api/devices/pool/refresh_models` | — | 后台批量采型号 **+ 补齐缺失指纹** |
### 6.2.1 名称在界面上的呈现
凡"选择设备 / 展示设备"的地方都以**名称**为主、地址为辅:
| 位置 | 呈现 |
|------|------|
| 监控页设备表 | 名称加粗为主行,`serial` 作副行(未命名显示橙色"未命名"提醒) |
| 任务运行概况的覆盖设备 chip | 名称(无名才退地址),tooltip 里带完整 `serial` |
| AI 控制台「目标设备」「观看设备」下拉 | `名称 · serial · 型号` |
| 任务编辑器「指定设备」下拉 | `名称 · serial` |
| 分组编辑的设备勾选列表 | `名称 · serial` |
| 设备池 / 待连接池 / 断联设备表 | 名称列 + 指纹列 |
| 剪贴板注入的设备勾选列表 | `名称 · serial`(无名只显示地址) |
| 应用管理的安装弹窗设备列表 | `名称 · serial` |
| 应用版本管理表格的「设备」列 | `名称 · serial` |
| 应用安装进度表的「设备」列 | 名称(取自设备池;无名退地址) |
| MCP `de_list_devices` | 返回 `name` 字段,提示 AI 汇报时用名称 |
> 这几个接口的字段来源:`/api/adb/devices`、`/api/apks/install/devices` 走
> `web/common.py:_merged_device_list()`(带 `name`);`/api/tools/appver` 把
> `name` 塞进每个设备的结果里。
### 6.2.2 设备配置二维码(扫码配置)
| 接口 | 鉴权 | 请求 | 说明 |
|------|------|------|------|
| `GET /api/devices/qrcode?serial=<地址>` | 设备权限 | — | 返回 `{png(base64 data-url), payload, warn}` |
给设备端 Agent 扫的配置二维码,一次写入**平台地址 + 设备令牌 + 该设备指纹**:
- `payload` 是二维码里的原始 JSON:`{"v":1,"server":…,"token":…,"fingerprint":…,"name":…}`
- `warn` **非空时必须显示**给操作者,最常见两种:
- "你是用 localhost 打开平台的" → 二维码里的地址手机会连不上,要改用局域网地址重开本页
- "这台设备还没采集到指纹" → 扫码后平台认不出它是哪一台
- 前置条件:先在「应用管理 → 设备端应用商店」**启用**(令牌从那儿来),否则 400
- 设备端如何解析见 [DEVICE_AGENT.md](DEVICE_AGENT.md) §5.3
### 6.3 自动发现
| 接口 | 请求 | 说明 |
|------|------|------|
| `GET /api/devices/discovery` | — | 发现状态 + 待连接 + 池内断联(前端 10s 轮询)。**待连接项带 `fingerprint` 与 `match`**(`{"serial","name"}` = 指纹命中的池内设备) |
| `POST /api/devices/discovery/scan` | — | 后台扫描一轮;已有扫描 → **409** |
| `POST /api/devices/discovery/confirm` | `{"serial","name"?}` | 确认入池。**新设备必须有名称**(空 → 400);**指纹命中已有设备时不需要名称**——保留原记录与原名,直接认领到新地址 |
| `POST /api/devices/discovery/ignore` | `{"serial"}` | 从待连接删除 |
| `POST /api/devices/discovery/reconnect` | `{"serial"}` | 只接受池内设备,否则 404 |
| `POST /api/devices/discovery/settings` | `{"enabled","subnets","interval","port","auto_claim"}` | 部分更新,存 `app_meta`。`auto_claim`=**指纹匹配时自动认领**(默认 **false**:认领会改写分组/任务引用,默认交人工确认;打开后扫描到"同一台设备换了地址"自动迁移) |
> 扫描只做 socket 探测 + 只读校验,**不会把设备直接拉进设备池**(必须人工确认)。
> 扫描时会顺带读一遍候选设备的指纹(写入待连接池),用于提示"这台是已有设备换了地址"。
## 7. 远程看屏与设备操作
| 接口 | 请求 | 说明 |
|------|------|------|
| `GET /api/screen/stream?serial=&q=&fps=` | — | MJPEG 流(`multipart/x-mixed-replace`) |
| `GET /api/screen/thumb?serial=` | — | 360px 宽 JPEG 缩略图,响应头 `X-Screen-State: on/off/unknown` |
| `GET /api/screen/size?serial=` | — | 原生分辨率 `{"ok":true,"width":…,"height":…}` |
| `POST /api/screen/tap` | `{"serial","x","y","snap":1?}` | `snap=1` 先吸附到最小可点击元素中心 |
| `POST /api/screen/swipe` | `{"serial","x1","y1","x2","y2","duration"?}` | |
| `POST /api/screen/key` | `{"serial","key"}` | 白名单:back/home/recent/menu/power/volume_up/volume_down… |
| `POST /api/screen/text` | `{"serial","text"}` | u2 `send_keys`(需焦点在输入框) |
| `POST /api/screen/tap_text` | `{"serial","text"}` | 先 UI 树子串匹配,未命中转 OCR;文字 ≤100 字符 |
坐标均为**设备原生像素**(`/api/screen/size` 给基准)。u2 调用异常统一 503,并清理 60s 连接缓存。
---
## 8. 元素抓取与步骤测试
| 接口 | 请求 | 响应要点 |
|------|------|---------|
| `GET /api/uiauto/status` | — | uiautodev(:20242)是否在跑,前端据此禁/启用"抓取元素" |
| `GET /api/uiauto/devices` | — | 可选设备列表(uiautodev 设备 + 池内在线补全)。每项 `{serial, name, model, product, status}`;**`name` 是平台给设备起的名字**(`cs1`/`A08`),池外设备退回 `serial`——uiautodev 自带的 name 是设备 codename(实测一柜子全是 `earth`),不能用来认设备 |
| `GET /api/uiauto/elements?serial=` | — | 扁平元素列表(走 uiautodev),每项带 `suggested`、`bounds`、`depth` |
| `GET /api/uiauto/snapshot?serial=` | — | **一次取齐**:截图 + 元素树(原生 u2 同一连接背靠背)→ `{image,width,height,elements,unstable,screen_state,cost_ms}`;抓取弹窗与 MCP 的 `de_snapshot` 都用它,失败返回 **502** |
| `GET /api/uiauto/screenshot?serial=` | — | JPEG |
| `POST /api/steps/test` | `{"serial","step":{…}}` | 真机试执行单个步骤 → `命中 / 未找到 / 已执行` |
**`suggested`(推荐选择器)字段**:`{type, value, semantic?, via?, indexed?, occ?, total?, broad?, invalid?, reason?}`
| 字段 | 含义 |
|------|------|
| `semantic` + `via` | **语义选择器**:`//*[@id="x" and @text="y"]`,`via` 是用于限定的第二属性(`text`/`content-desc`/`class` 或 `a+b`)——不依赖同类元素个数,换设备/换版本仍命中 |
| `indexed` + `occ`/`total` | 退化形式 `(//*[@id="x"])[k]`:同 id 且同文字分不开时才用,**界面一变即失配**,前端黄标提醒 |
| `broad` | 结构路径兜底(`//hierarchy/*[i]/*[j]`),最脆 |
| `invalid` + `reason` | 无任何可用属性,前端禁止回填 |
`GET /api/uiauto/elements` 在 uiautodev 不可用时返回 **503**。设备 dump 慢时可能超时(见 [backlog](backlog/TODO.md))。
---
## 9. 应用管理(APK)
| 接口 | 请求 | 说明 |
|------|------|------|
| `GET /api/apks` | — | 已上传 APK 列表(含解析出的包名/版本/大小) |
| `POST /api/apks/upload` | multipart `file` | 未选文件 → 400;解析失败 → 500 |
| `DELETE /api/apks/<apk_id>` | — | 删文件 + 记录 |
| `POST /api/apks/install` | `{"apk_id","serials":[…]}` | 后台并发 5 台安装;已在装 → 失败提示 |
| `GET /api/apks/install/devices` | — | 可安装设备(pool / usb / adb 三个来源) |
| `GET /api/apks/install/status` | — | 安装进度(前端 1.5s 轮询) |
> 每台设备的状态机:`pending`(等待)→ `installing`(**带文字的实时进度**)→ `success` / `failed` / `skipped`。
> 安装分两段,`items[serial].msg` 会跟着更新:
> 1. **推送到设备**:`推送中 62%(211.0/336.1 MB · 2.6 MB/s · 剩约 48s)` —— 用分块写
> (`adb exec-in`)拿字节级进度;不用 `adb push` 是因为它只在结束时吐一行汇总,
> 大包(抖音 336MB≈2 分钟)整个传输过程界面上只能干等
> ⚠️ **`adb exec-in` 返回 ≠ 设备上写完**:数据还在设备侧(adbd → shell → `cat >`)缓冲里
> 继续落盘。所以收尾必须**轮询文件大小直到长齐**(`_wait_remote_size`,最长 20s),
> 量一次就判"推送不完整"会把成功的传输误报成失败(2026-09-14 的真实案例:同一份
> 5.9MB 的 APK 在多台设备上报了 8 次假失败,实际文件最终都是完整的)。真不齐才退回
> `adb push` 重传。
> 2. **设备上安装**:`正在设备上安装...(大包要 1-2 分钟,无进度可读)` —— `pm install`
> 在设备端解包,系统没给进度接口
>
> 参考耗时(336MB 抖音 / 单台):推送 ~134s + 安装 ~65s ≈ 3.3 分钟;多台并发(上限 5)时
> 带宽共享,整体时间取决于 WiFi。
---
## 10. 用户与日志
| 接口 | 鉴权 | 请求 | 错误 |
|------|------|------|------|
| `GET /api/users` | Admin | — | — |
| `POST /api/users` | Admin | `{"username","password","is_admin"?,"perms"?}` | 用户名/密码空、重名 → 400 |
| `PUT /api/users/<int:uid>` | Admin | `{"password"?,"is_admin"?,"perms"?}` | 取消最后一个管理员 → 400;不存在 404 |
| `DELETE /api/users/<int:uid>` | Admin | — | 删 `admin`/删自己/删最后一个管理员 → 400 |
| `GET /api/logs?file=core.log&lines=300` | G | `q` 关键字、`level` 最低级别、`since`/`until` 时间 | 见下 §10.1 |
| `GET /api/logs/download?file=core.log` | G | 同上(参数一致) | 以附件返回 `.txt`,文件名带时间戳 |
#### 10.1 日志查询(`GET /api/logs`)
参数:
| 参数 | 说明 |
|------|------|
| `file` | 日志文件名,**白名单校验**(只能取接口返回的 `files[].name`,含 `.1/.2` 滚动历史);非法值返回 `ok:false` |
| `lines` | 返回**最近**多少条命中(默认 300,上限 5000) |
| `q` | 关键字,大小写不敏感,匹配整行原文 |
| `level` | 最低级别:`ERROR` / `WARNING` / `INFO` / `DEBUG`(含以上);留空为全部 |
| `since` / `until` | 时间范围,接受 `YYYY-MM-DDTHH:MM`(datetime-local)或 `YYYY-MM-DD`(按整天) |
返回:
```json
{"ok": true, "file": "core.log",
"files": [{"name": "core.log", "label": "核心(adb/调度) · core.log",
"size": 1175910, "mtime": 1789519242, "rotated": false}],
"rows": [{"ts": "2026-09-16 08:26:50", "level": "INFO",
"module": "core.disc", "msg": "发现: …", "cont": false}],
"matched": 11423, "scanned": 11423, "truncated": true}
```
- `rows` 保持文件原顺序(旧→新),即"最近 N 条命中"按时间正序排列;
- `cont=true` 是**续行**(traceback 的缩进行等):它本身没有时间/级别前缀,
过滤时**继承上一条带前缀的行**,所以按 ERROR 筛选不会把堆栈拆散;
- `matched` 是命中总数,`truncated=true` 表示更早的命中没返回(应缩小时间范围或加关键字);
- `scanned` 是实际扫描行数(上限 50 万行)。
#### 10.2 任务步骤明细(`/api/step_logs*`)
数据源是 `task_step_log` 表(**结构化**,与上面的文本日志不是一回事):每一次步骤
执行一条,能按设备/任务/结果/时间过滤、能按运行归组、能导出 CSV。写入侧见
[DATA_MODEL.md](DATA_MODEL.md) §2.8 与 `core/step_log.py`。
| 接口 | 参数 | 返回 |
|------|------|------|
| `GET /api/step_logs` | `serial` `job_id` `result` `run_id` `q` `since` `until` `limit`(≤2000,默认 200) `offset` | `rows`(**最近的在前**)、`total`、`stats`(本次进程的 queued/written/dropped/failed)、`keep_days`、`max_rows_per_run` |
| `GET /api/step_logs/runs` | `serial` `job_id` `since` `until` `limit`(≤500) | `runs[]`:`run_id` `started_at` `ended_at` `steps` `failures` |
| `GET /api/step_logs/filters` | — | `devices[]` / `jobs[]`(**只列明细里真的出现过的**,不依赖设备池与任务表)、`results[]` |
| `GET /api/step_logs/download` | 同列表接口 | 附件 `.csv`(UTF-8 **带 BOM**),最多 2 万行,按时间正序 |
`result` 取值:`ok` / `miss`(handler 返回 False,如元素没找到)/ `error`(抛异常)/
`unknown`(未知步骤类型)/ `skip`(概率未触发)/ `cap`(本次运行已达记录上限)。
`run_id` 是"设备 × 任务 × 第几次尝试",同一行里能拿到 `step_path`(如 `2.1.3`)
还原嵌套结构——**步骤明细面板的「最近运行概览 → 查看」就是按它过滤**。
> 稳定性:记录走异步队列(`record()` 零阻塞),**队列满会丢弃**(计数在 `stats.dropped`,
> 页面提示栏会显示),所以明细允许缺条 —— 权威结论仍看任务状态与 [NOTIFY.md](NOTIFY.md) 的事件。
---
## 11. 运维工具(adb / 剪贴板 / 应用版本 / Tailscale)
### adb
| 接口 | 请求 | 说明 |
|------|------|------|
| `GET /api/adb/devices` | — | 维护终端看到的设备(本机 adb + 设备池合并) |
| `POST /api/adb/cmd` | `{"cmd":"…"}` | 执行 adb 命令 |
`/api/adb/cmd` 的安全约束:**命中 `kill-server` / `disconnect` 直接拒绝**(红线);空命令 400;超 20s 返回"命令执行超时(20s)";与 worker 共用 `_ADB_LOCK`。
### 其它
| 接口 | 请求 | 说明 |
|------|------|------|
| `POST /api/tools/clipboard/set` | `{"serials":[…],"text":"…"}` | 设备端 Agent 通道写入并读回校验(旧版独立 ClipInject 自动兜底);serials 空或非列表 → 400 |
| `POST /api/tools/appver` | `{"package":"com.xxx"}` | 并发查所有在线设备(≤10 并发);包名须匹配 `^[A-Za-z0-9_.]+$` |
### Tailscale(全部 Admin)
| 接口 | 请求 | 说明 |
|------|------|------|
| `GET /api/tailscale/status` | — | 是否已配置 `TAILSCALE_API_KEY`/`TAILSCALE_TAILNET` |
| `GET /api/tailscale/devices` | — | 上游 API 失败 → **502** |
| `POST /api/tailscale/devices/<id>` | `{"name"?}` / `{"authorized"?}` / `{"key_expiry_disabled"?}` | 按字段分发 |
| `POST /api/tailscale/devices/<id>/ip` | `{"ipv4"}` | **会断开该设备的 tailscale 会话**;设备池 serial 就是 tailnet IP,改完需同步设备池 |
| `DELETE /api/tailscale/devices/<id>` | — | |
| `POST /api/tailscale/authkey` | `{"description"}` | description 必须 ASCII;key 只显示一次 |
---
## 12. AI 控制台
配置存在 `app_meta`(`agent_*`)。
### 配置
```json
// GET /api/agent/config
{"ok": true, "api_base": "https://api.deepseek.com", "model": "deepseek-v4-flash-vision-exp",
"api_key": "(原文)", "api_key_masked": "sk-***abcd", "default_serial": "", "max_steps": "40"}
// POST /api/agent/config (部分更新)
{"api_base": "…", "model": "…", "api_key": "…", "default_serial": "…", "max_steps": 40}
```
### 运行
| 接口 | 说明 |
|------|------|
| `POST /api/agent/run` | `{"prompt","serial"?,"conversation_id"?}` → `{"ok":true,"run_id":"8f3a2c9d"}`。**校验**:prompt 空/未配 Key/未配模型/未选设备且无默认 → 400;设备不在池/离线 → 400;设备 busy 或已有 Agent 运行中 → **409** |
| `GET /api/agent/run` | `{"ok":true,"state":"idle\|running\|done","run_id","prompt","serial","started","answer","error","usage":{…},"history":[…]}` |
| `GET /api/agent/stream?run_id=` | **SSE**,事件见下 |
| `POST /api/agent/stop` | 下一个检查点生效;无运行中任务 → 400 |
| `POST /api/agent/clear` | 清空运行态历史 |
**SSE 事件**:
| event | payload | 说明 |
|-------|---------|------|
| `delta` | `{"text","kind":"content"\|"reasoning"}` | 流式文本增量(正文 / 推理链) |
| `step` | `{"tool","args","image"?}` | 工具调用完成;`image` 为缩略截图。伪卡片:`tool="🧠 经验记忆"`(命中/写入经验)、`tool="🧠 动作经验"`(命中/沉淀动作) |
| `usage` | `{"prompt_tokens","completion_tokens","total_tokens","calls"}` | **本轮累计** token,每完成一次模型调用推一次 |
| `done` | `{"answer","usage"}` | 完成 |
| `error` | `{"message"}` | 失败(MCP 不可达时给出明确文案) |
空闲时每 15s 发 `: keepalive`;`done`/`error` 后关流。
### 会话
| 接口 | 说明 |
|------|------|
| `GET /api/agent/conversations` | `{"conversations":[{"id","title","updated_at","count"}]}` |
| `POST /api/agent/conversations` | 新建空会话 → `{"ok":true,"id":"…"}` |
| `GET /api/agent/conversations/<conv_id>` | 消息列表(assistant 消息可带 `usage`、`reasoning`);不存在 404 |
| `DELETE /api/agent/conversations/<conv_id>` | 删除会话及其消息 |
| `POST /api/agent/conversations/<conv_id>/rename` | `{"title"}`;空标题 400 |
### 经验库 / 动作库
| 接口 | 说明 |
|------|------|
| `GET /api/agent/experience` | 经验列表 + 最近巡检结论 + 巡检运行状态 |
| `POST /api/agent/experience/audit` | 手动触发巡检;进行中 → **409** |
| `POST /api/agent/experience/delete` | `{"id"}` 人工删除(**巡检永远不会自动删**) |
| `POST /api/agent/experience/keep` | `{"id"}` 撤销"建议删除" |
| `GET /api/agent/actions` | 动作库列表(命名动作 + 元素定位步骤) |
| `POST /api/agent/actions/save` | `{"id"?,"name","app","aliases","params","steps"}`;含坐标的步骤被拒 → 400 |
| `POST /api/agent/actions/delete` | `{"id"}` |
机制详见 [AI_CONSOLE.md](AI_CONSOLE.md)。
---
## 13. 系统备份
### POST /api/system/backup/export
**JSON body**(不是表单):`{"include_apk": true}`(默认 true)。响应为 **zip 附件**:
```
users.db # 当前库(MySQL 或回退 SQLite)整库一致快照成的 SQLite 归档
apks/*.apk # include_apk=true 时
manifest.json # {format:"auto_control_backup", version:2, created_at, schema_version,
# db_backend, deployment_env, source_db_id, include_apk,
# tables:[{table,label,rows}], apks:[…], coverage_missing?}
```
> SQLite 在这里是**备份交换格式**而非运行时库:MySQL 下导出时把连接提到 REPEATABLE READ,
> 保证 12 张表读的是同一时刻。
### POST /api/system/backup/preview
multipart 上传 `.zip` 或 `.db`(字段 `file`)→ 校验并暂存:
```json
{"ok": true, "token": "e172dc75e2f7",
"preview": {"integrity": "ok", "schema_version": 6, "current_schema_version": 6,
"source_env": "dev", "current_env": "dev", "source_backend": "mysql",
"source_db_id": "704fbbb0…",
"tables": [{"table":"agent_action","label":"动作库","rows":5}],
"missing_optional": [], "extra_tables": [],
"warnings": ["备份为全量数据,含用户口令哈希、AI 配置里的 API Key 等敏感信息…"]}}
```
- 完整性 `PRAGMA integrity_check` 必须 `ok`;缺必需表(`app_meta`/`user`/`task_job`/`device_group`)直接拒绝
- `extra_tables` 非空 = 备份含**未登记的表**(提示去登记)
- `source_env` ≠ `current_env` 时 `warnings` 会多一条跨环境告警
- 暂存 **TTL 30 分钟**,过期自动清理
- 文件非法 / 未选文件 → 400
### POST /api/system/backup/apply
`{"token":"…", "force_env_mismatch": false}` → 先自动把当前库导出成
`data/backups/pre_restore_<ts>.zip`(安全网,可直接再导入回来),再把暂存归档落到
`data/restore_pending/`。
- **必须重启服务才生效**:`web_server.py` 在 `init_db` 之后、`TaskManager` 之前消费该目录
- 重启时**单事务整库替换**(DELETE 全表 + 分块 INSERT),失败自动回滚,当前数据不受影响
- 备份来源环境与当前库不符时**默认拒绝**(400),要跨环境须显式传 `force_env_mismatch: true`
- token 无效/暂存缺失 → 400
---
## 14. 设备端应用商店
**设备侧**三个接口(`bootstrap` / `apk` / `report`)的完整协议、鉴权、错误码与版本约定
见 [DEVICE_AGENT.md](DEVICE_AGENT.md)——那份文档同时是设备端 APK 仓库的对接契约。
**管理侧**(登录 + 应用管理权限):
### GET /api/agent-store/config
```json
{"ok": true, "enabled": false, "token": "…", "token_created_at": "2026-09-13 14:38:11",
"api_version": 1, "base_url_hint": "http://192.168.20.220:18050"}
```
`base_url_hint` 由请求的 Host 回填,直接可作为设备端要填的平台地址。
### POST /api/agent-store/config
`{"enabled": true}` 启用(并自动生成令牌);`{"regenerate_token": true}` 重置令牌
(旧令牌立即失效)。返回 `{"ok": true, "enabled": …, "token": …}`。
### GET /api/agent-store/logs?limit=50
```json
{"ok": true, "logs": [{"id": 1, "device_name": "A08", "serial": "192.168.20.100:5555",
"fingerprint": "…", "apk_id": "56b114aa", "package_name": "com.example.clipinject",
"version_name": "1.0", "action": "install_fail", "message": "MIUI 拦截未确认",
"created_at": "2026-09-13 14:40:00"}]}
```
`action` ∈ `download` / `install_ok` / `install_fail`;记录表最多保留 500 条(自动裁旧)。
---
## 15. 非 JSON 响应汇总
| 方法 | 路径 | 响应类型 | 说明 |
|------|------|---------|------|
| GET | `/` `/wall` `/login` `/locate` | `text/html` | 四个页面(`/locate` 免登录;**已被设备端 Agent 的身份页取代**,仅作历史保留)|
| GET | `/api/device/screenshot` | `image/png` | 原始截图(`Cache-Control: no-store`) |
| GET | `/api/screen/thumb` | `image/jpeg` | 360px 缩略图 + `X-Screen-State` |
| GET | `/api/screen/stream` | `multipart/x-mixed-replace` | MJPEG 实时流 |
| GET | `/api/uiauto/screenshot` | `image/jpeg` | 抓元素时的画面 |
| GET | `/api/agent/stream` | `text/event-stream` | SSE |
| POST | `/api/system/backup/export` | `application/zip` + `Content-Disposition` | 附件下载 |
---
## 16. 错误分支速查
| 码 | 典型触发 |
|----|---------|
| **400** | 参数缺失/非法:任务名空、未知 task_type、分组名空/重名、用户名为空/重名、actions steps 空、备份 token 无效、包名不合法、文字超 100 字符、缺 `serial`/`x`/`y` 等 |
| **403** | 权限位不足 / 非管理员 |
| **404** | 资源不存在:任务 / 分组 / 用户 / 动作 / 待连接记录 / 会话 / 设备不在池 |
| **405** | 方法不匹配(如对只支持 PUT/DELETE 的路径发 GET) |
| **408** | `POST /api/adb/cmd` 超 20s(返回文案"命令执行超时(20s)") |
| **409** | 冲突:设备忙(AI 不与任务抢设备)、已有 Agent 运行中、巡检进行中、已有发现扫描在跑 |
| **500** | 未预期异常(截图层失败、APK 上传解析失败、备份 IO 失败…) |
| **502** | 上游依赖失败:Tailscale API、设备定位(`/api/device/locate`) |
| **503** | 依赖不可用:`get_status` 异常、uiautodev 未启动/抓取失败、u2 连接异常 |
---
## 17. 已知问题
| 问题 | 影响 | 位置 |
|------|------|------|
| `GET /locate` 返回 **500** | 设备定位大字页打不开(NameError:`render_template_string` / `_esc` 未导入) | `web/monitor.py` |
| `POST /api/device/locate` 的 `show=true` 分支失败 | 无法在设备上打开定位页(`urllib` 未导入 → 502) | `web/monitor.py` |
| **CSRF 未强制校验** | `/api/csrf` 会发 token、前端会带 `X-CSRF-Token`,但服务端没有注册校验钩子 → 伪造请求不会被拦 | `web_server.py` |
| `POST /api/agent/run` 的设备校验可能被跳过 | 校验包在 `try/except: pass` 里,状态服务异常时直接放行(MCP busy 锁兜底) | `web/agent_api.py` |
这些均已登记在 [backlog/TODO.md](backlog/TODO.md)。