一、账号台账(新表 device_account,schema v8) - core/ledger.py:CRUD、Excel 粘贴解析(Tab 分隔 / 表头乱序缺列 / 是·否布尔 / 逐行留痕)、 按范围取号(本机台账 / 全部 / 按设备分组)、设备端账号块、dry_run 预览 - 新「账号」顶级 Tab(权限 devices):列表 + 搜索 + 设备筛选 + 增删改 + 粘贴导入(默认先预览再写,逐行显示 新增/覆盖/跳过/失败 + 留痕) - 设备池加「账号 N」列(0 个显示"未登记"),点开看该设备账号明细 - 任务「条件判断」新增 cmp_source / cmp_group:候选值可直接来自台账, 与手填值**合并(OR)**;取不到号时回落手填值,并把原因写进步骤明细与日志 (否则"永远走 else 分支"而任务照样显示成功,最难查) - 设备端契约:身份页多推 accounts_b64(base64 JSON;默认不含手机号; 超预算按整条丢且绝不算字节切),见 doc/DEVICE_AGENT.md §5.2.1 - 备份/文档红线:TABLE_LABELS 加「账号台账」;DATA_MODEL/API/ARCHITECTURE/DEPLOY/ TASK_DEV/DEVICE_AGENT/DEVELOPMENT/README 同步;顺手补上 DATA_MODEL 漏列的 done_mark ⚠ 台账里的抖音号是**纯号**,只当"比对用的候选值":绝不能拿去填「去重」的身份元素 (身份是元素原文逐字算 key,格式不同会让去重静默失效)。代码与文档都写明了。 二、剪贴板注入通道(修:平台还在调早期的独立 APK) - 改为按顺序尝试:设备端 Agent(com.example.deviceagent/.ClipActivity)→ 旧版独立 ClipInject 兜底;两个都没有时报"需要设备端 Agent"(不再只说 ClipInject) - 读回验证改成**轮询到 3 秒**:透明 Activity 要等窗口拿到焦点才写, 原来只睡 0.5s 会读到上一次的内容 → 误报"写入可能被拒"(实测内容已写入却回失败)
14 KiB
设备端 Agent 接口契约(DEVICE_AGENT)
适用读者:写设备端 Agent APK(另一个仓库)的人,以及改平台侧这组接口的人。 相关:API.md(管理端接口)、DEPLOY.md(部署)、ARCHITECTURE.md。
这份文档是两个仓库之间的契约。 平台侧实现见 web/device_agent_api.py;
设备端实现见另一个仓库。改协议必须同时改两边(见 §6 版本约定)。
1. 为什么有这组接口
平台通过 adb 驱动设备(点击/输入/装应用……),但有三件事 adb 干不好,需要设备上装一个 App:
| 需求 | adb 的困境 | Agent 的做法 |
|---|---|---|
| 写系统剪贴板 | Android 10+ 禁止后台/shell 写剪贴板;u2 的 set_clipboard 会"成功但内容被丢弃" |
启动一个透明 Activity(有焦点才能写),写完立即退出 |
| 安装 APK | MIUI 会拦 adb install(USER_RESTRICTED),要人工点「继续安装」 |
App 自己调系统安装器 → 走普通应用安装流程,不触发那道 USB 拦截 |
| 显示设备身份 | 现在要把一个 HTML 大字页推给设备浏览器打开,依赖浏览器 | 全屏大字显示:设备名/IP/序列号/平台地址 |
其中「装应用」是设备主动来取(拉清单 → 选 → 下载 → 装),所以平台开了一组 设备专用接口;「写剪贴板」「显示身份」是平台推指令,仍走 adb(§5)。
2. 接入流程(设备端视角)
① 一次性:平台在「工具 → 应用管理 → 📱 设备端应用商店」启用,拿到
接入地址 base_url(如 http://192.168.20.220:18050)和设备令牌 token
—— 这两个值可以通过 adb 一次性推给设备(见 §5.3),不必在每台手机上手输
② 开机/定时:GET {base_url}/api/device/agent/bootstrap
带上 X-Device-Token / X-Device-Fingerprint
→ 拿到「我是谁」(平台分配的设备名)+「有哪些应用可以装」
③ 用户在手机上选一个应用:
GET {base_url}/api/device/agent/apk/{id} → 下载 APK 到应用私有目录
④ 调系统安装器安装(PackageInstaller / ACTION_VIEW + FileProvider)
—— 会弹系统安装确认框,用户在屏幕上点一下即可
⑤ 回报结果:POST {base_url}/api/device/agent/report
(download / install_ok / install_fail + 失败原因)
平台在「设备端应用商店」面板能看到记录
Agent 需要
INTERNET与REQUEST_INSTALL_PACKAGES权限;还需要用户在系统设置里 给"安装未知应用"授权(这是 App 自装的唯一前置条件,比 MIUI 的 USB 拦截好过得多)。
3. 接口规格
所有接口:
- 不需要登录会话,靠
X-Device-Token鉴权 - 请求/响应均为 UTF-8 JSON(下载接口除外)
- 功能默认关闭:平台侧未启用时,全部返回
404 {"ok":false,"error":"not found"}(不暴露接口是否存在)
3.1 通用请求头
| 头 | 必填 | 说明 |
|---|---|---|
X-Device-Token |
✅ | 平台生成的设备令牌 |
X-Device-Fingerprint |
建议 | ro.serialno。平台靠它认出"你是哪台设备"(换 IP 也能认回原名) |
X-Device-Serial |
可选 | 当前 adb 地址(IP:5555),指纹认不出时的兜底 |
错误码:
| 状态 | 含义 | 设备端该怎么办 |
|---|---|---|
| 404 | 平台未启用设备端商店(或接口不存在) | 提示管理员去平台开启;不要重试轰炸 |
| 401 | 令牌不对/缺失 | 提示重新配置令牌(管理员重置后要在设备上更新) |
| 500 | 平台内部错误 | 退避重试 |
3.2 GET /api/device/agent/bootstrap
拉设备身份 + 可安装应用清单。建议开机后拉一次 + 每次打开"商店"界面时拉一次。
响应
{
"ok": true,
"agent_api_version": 1,
"server_time": "2026-09-13 14:38:11",
"platform": { "url": "http://192.168.20.220:18050" },
"device": {
"fingerprint": "woijo7v4sgnrhqb6",
"serial": "192.168.20.100:5555",
"name": "A08",
"known": true
},
"apks": [
{
"id": "56b114aa",
"display_name": "ClipInject",
"package_name": "com.example.clipinject",
"version_name": "1.0",
"version_code": 1,
"size": 11615,
"upload_time": "2026-09-09 13:49:06",
"download_path": "/api/device/agent/apk/56b114aa"
}
]
}
device.name:平台给这台设备起的名字(如A08)—— 身份显示界面直接用它device.known=false:这台设备还没登记进平台设备池(指纹没匹配上),name为空; 设备端应显示"未登记",不要假装自己有名字platform.url:按设备实际请求到的地址回填,可直接用于拼下载地址apks[].download_path是相对路径,拼platform.url即完整 URL
3.3 GET /api/device/agent/apk/{apk_id}
下载 APK 文件本体(application/vnd.android.package-archive)。
- 成功:
200+ 文件流 404:id 不存在或平台侧文件已删(注意与"未启用"的 404 区分:看响应体, 未启用时是 JSON{"ok":false,...},文件缺失时也是 JSON —— 设备端拿到非application/...的响应就视为失败)
3.4 POST /api/device/agent/install(可选:让平台帮你静默装)
背景:设备上的 App 自己调系统安装器,MIUI 上要过「继续 → 勾选未经安全检测 → 继续更新」甚至 ICP 备案检查好几步;而平台用 adb 装是静默的(实测升级 2.7s、 全新安装 7.9s、336MB 大包都没弹过一次框)。
所以"安装/更新"按钮建议走这个接口:用户在手机上选(商店体验),平台用 adb 落地(静默)。
请求体:{"apk_id": "56b114aa"}
响应:
{"ok": true, "msg": "开始安装 X 到 1 台设备", "serial": "192.168.20.100:5555"}
- 受理后平台异步安装,设备端应轮询本机该包的 versionCode 变化来判断结果
(装上后按 §3.5 上报
install_ok) - 平台装不了(比如 MIUI 拦了 adb 安装、设备不在池里)→
ok=false+error, 设备端应退回本机安装(ACTION_VIEW+ FileProvider,会弹系统确认框,但至少能装) 409= 这台设备还没登记进平台设备池(平台不认识它,不能替它装)
3.5 POST /api/device/agent/report
回报下载/安装结果,平台侧留档(面板可见)。
请求体
{
"apk_id": "56b114aa",
"action": "download | install_ok | install_fail",
"package_name": "com.example.clipinject",
"version_name": "1.0",
"message": "失败原因(install_fail 时必填,越具体越好)"
}
响应:{"ok": true};action 非法 → 400。
失败原因请写人话:平台面板直接显示
message。 例如「MIUI 拦截:用户未确认」「安装包解析失败」「存储空间不足」。
4. 平台侧配置(管理端)
「工具 → 应用管理 → 📱 设备端应用商店」:
| 操作 | 接口 |
|---|---|
| 读配置(含令牌) | GET /api/agent-store/config |
| 启用/停用 | POST /api/agent-store/config {"enabled": true} |
| 重置令牌 | POST /api/agent-store/config {"regenerate_token": true} |
| 安装记录 | GET /api/agent-store/logs?limit=50 |
- 三个接口都需要登录 + 应用管理权限
- 停用不影响已装的 App,只是设备端拿不到清单(返回 404)
- 重置令牌后旧令牌立即失效,所有设备要在 Agent 里更新
5. 平台 → 设备的指令(走 adb,不走网络)
这些没有 HTTP 接口,由平台用 adb shell am start 拉起设备上的 Activity。
5.1 剪贴板注入(已在用,务必保持兼容)
adb -s <serial> shell am start -n <pkg>/.ClipActivity --es text_b64 <base64(UTF-8)>
- 必须 base64:adb shell 直传中文/特殊字符会变形
- 必须用透明 Activity(有焦点才能写剪贴板),写完立即退出
- 平台判定失败的方式:
am start输出里有unable to resolve Intent/does not exist/Error type 3→ 视为"这台设备没有这个通道"
平台按顺序试两个包(core/clipboard_helper.py 的 _CLIP_TARGETS):
| 顺序 | 包 | 说明 |
|---|---|---|
| 1 | com.example.deviceagent(本 Agent) |
现在的标准通道;装了就只调它 |
| 2 | com.example.clipinject(早期独立 APK) |
老设备上可能还残留 → 兜底;走这条会在日志里提示"建议装设备端 Agent" |
两个都没有时报"设备未安装剪贴板注入通道:需要设备端 Agent"(不再只说 ClipInject, 否则用户会去装那个已经不再分发的旧 APK)。设备端无需为这条改动做任何事。
5.2 设备身份显示(设备端已实现)
adb -s <serial> shell am start -n <pkg>/.InfoActivity \
--es name "A08" --es ip "192.168.20.100" --es serial "192.168.20.100:5555" \
--es server "http://192.168.20.220:18050"
# 关闭:--es close 1
要求:全屏大字、再次调用可覆盖内容、close 可关闭、不驻留前台。
5.2.1 本机账号台账(accounts_b64,可选 extras — 需要设备端配合实现)
平台在打开身份页时会顺带把这台设备在「账号」页登记的抖音账号推过去
(--es close 1 时不带)。中文必须 base64(同 §5.1:adb shell 直传中文会变形):
adb -s <serial> shell am start -n <pkg>/.InfoActivity \
--es name "A03" --es serial "192.168.20.63:5555" --es server "http://192.168.20.220:18050" \
--es ip "192.168.20.63" \
--es accounts_b64 "<base64(UTF-8 JSON)>"
解码:标准 base64(不是 URL-safe)+ UTF-8。JSON 结构(字段名的设备端解析按名取,多余字段忽略):
{"v":1,"total":4,"shown":4,"truncated":false,
"note":"设备『A03』登记 4 个号",
"accounts":[
{"name":"AA建材王总","douyin_id":"66057500463","device_name":"A03","device_no":"A03",
"sim_in_device":true,"can_post_video":true}]}
| 字段 | 说明 |
|---|---|
v |
本块格式版本(当前 1)。解不出来时用它区分"格式变了"与"平台没给" |
total / shown / truncated |
该设备在台账里的总数 / 本次带了几条 / 是否被截断 |
accounts[].name |
账号名称(台账的「账号名称」列) |
accounts[].douyin_id |
抖音号(纯号),如 35377983067 |
accounts[].sim_in_device / can_post_video |
卡在机内 / 可发视频(布尔) |
accounts[].phone |
默认不推(大字页是机器旁的公开屏幕,手机号不宜默认上屏)。需要时平台请求体传 {"include_phone":true} |
设备端要做的(当前版本忽略该 extra,行为与旧版逐字一致):
- 读
accounts_b64→ base64 解码 →JSONObject - 在身份页上按
name/douyin_id渲染一个列表(建议 1~3 行简短列表,最多显示shown条) truncated=true时提示"还有更多,见平台账号页"
约定与边界(平台侧实现见 web/device_agent_api.py 的 _encode_accounts):
- 没有这个 extra = 这台设备在台账里没有账号(不是"平台忘了给")——设备端保持原样显示即可, 不要显示空列表
- 超预算时平台按整条丢(
shown变小 +truncated=true),绝不按字节切(切了就是坏 JSON); 台账过大时可能整个 extra 不发(平台响应体里warn会说明原因) - 台账读取失败时同样不发这个 extra —— 台账故障不影响"显示身份"这个动作
5.3 扫码配置(设备端已实现)
平台「设备池」里点某台设备的「二维码」→ 生成一张二维码 → 手机上的 Agent 点 「扫码配置」扫一下,就把 平台地址 + 设备令牌 + 这台设备的指纹 一次写入。
二维码内容(JSON 文本,设备端按字段名解析,多余的字段忽略):
{"v":1,"server":"http://192.168.20.250:18050","token":"<设备令牌>",
"fingerprint":"s8o7nrt8pbzhfadq","name":"A07"}
v= 格式版本(当前 1);fingerprint可能为空串(平台还没采到指纹)- 设备端扫到不是本平台的二维码时应提示并继续扫,不要静默失败
- 平台侧生成接口:
GET /api/devices/qrcode?serial=<设备地址>(需登录 + 设备权限), 返回{png( base64 data-url), payload, warn};warn非空时要显示出来 (最常见的是"你现在用 localhost 打开平台,二维码里的地址手机会连不上")
与 §5.4 的 adb 下发等价、互为补位:设备已经连上平台时用 adb 批量下发(零操作), 人在机器旁或设备还没接进来时用扫码(不用线、不用打字)。
5.4 一次性配置下发(adb,建议设备端实现)
adb -s <serial> shell am start -n <pkg>/.ConfigActivity \
--es server "http://192.168.20.220:18050" --es token "<设备令牌>"
有了它,8 台设备不用一台台手输地址和令牌。
6. 版本与兼容
- §5.2.1 的
accounts_b64是"加字段"= 小版本:agent_api_version仍是 1, 设备端忽略它时行为必须与旧版逐字一致(只是大字页少一块内容)。也不走bootstrap:身份页由平台用 adb 推,加进 bootstrap 没人用还会让契约多一处要同步。 agent_api_version(当前 1)在每次bootstrap响应里返回- 设备端:启动时对比自己实现的版本,不一致要在日志里明确记下来(不要静默)
- 平台端:
- 加字段 / 加接口 = 小版本,设备端忽略未知字段即可
- 改已有字段语义 / 删字段 / 改鉴权方式 = 大版本,两边必须同时改
- 改了协议,必须同时改这份文档的两个仓库版本(本文件是平台侧那一份)
7. 安全须知
- 令牌是凭据:等于"能读平台的应用清单和 APK 文件"。别写进日志、别外传、
别提交到 git(平台侧存在
app_meta.agent_device_token,会随备份一起走) - 平台侧这组接口只读:只能读清单和下载 APK,不能触发任务、不能改设备池、 不能读配置或密钥
- 生产环境建议:令牌泄露时立即在面板点「重置令牌」
- 设备端不需要也不应该拿 admin 账号密码