feat: 设备身份改为「名称 + 指纹」——更换 IP 自动认领,分组/任务引用自动同步

背景:设备池原先拿 serial(IP)当身份。设备一换 IP(DHCP 重新分配)旧记录就成了连不上的
僵尸条目(表现为"断联·自动重连中"但设备并没关机),分组与 serial 模式的任务还吊着死地址。
2026-09-11 实际发生:.70 变成 .71、.72 消失,平台两个条目永远连不上。

实现
- **设备名称必填且唯一**:加入设备必须填名称;库层面用部分唯一索引兜底
  (ux_device_name / ux_device_fingerprint,WHERE 非空 → 兼容历史空值),管理页可改名
- **设备指纹**(ro.serialno):网络设备添加/确认/扫描/采集型号时自动读取;
  身份三层拆分——名称(人可读,稳定)、指纹(机器识别,稳定)、serial(当前地址,可变)
- **自动认领**:添加或确认设备时指纹命中池中已有记录 → 迁移原记录到新地址
  (名称/型号/备注/启用状态/添加时间全保留),不新增条目
- **人工认领** `POST /api/devices/pool/relocate`:旧地址已断联、指纹没采过时的兜底——
  人工指认"这条就是那台,现在在 X",迁移并同步引用
- **引用同步**:认领/迁址时把 device_group.serials 与 task_job.target.serial 的旧地址
  换成新地址。⚠️ 必须同时改**内存**:分组/任务在 TaskManager 里另有内存副本且调度用内存对象,
  只改库不重启不生效 → device_pool 迁址后回调 TaskManager.sync_device_serial
  (装配层用 set_move_hook 注册;device_pool 不能反向 import task_manager,会循环依赖)
- **前端**:待连接池新增「识别」列(指纹命中时提示"≈ 名称(原 IP)",按钮变「认领为 X」);
  设备池新增「名称/指纹」列与「改名/换地址」操作;断联设备表也加「换地址」(用户看到断联就在这里)
- 添加设备接口改用 adb_connect_light(单次短超时),避免不可达 IP 让请求卡 30s+;重名校验提前到 adb 之前

文档:API.md §6 重写(设备身份/认领/新接口)、DATA_MODEL.md(新列 + v5/v6 迁移 + 唯一索引)、
ARCHITECTURE.md §4.0(身份三层与引用同步的内存坑)、DEPLOY.md 排查表加"断联但没关机"条目

自测(全通过):名称必填/唯一/改名/重名拒绝(含库层面约束);指纹采集(真实读到 .71 的
gy7lskwkkvj7c6b6);自动认领(指纹命中→迁址+保留名称+带指纹+未命中不误判);人工认领
(迁址+名称保留+分组与任务引用同步);浏览器验证设备池/断联表/待连接池三个界面
This commit is contained in:
2026-09-11 11:03:38 +08:00
parent 24d57d3b96
commit 0a1b4d6122
12 changed files with 595 additions and 70 deletions
+41 -12
View File
@@ -143,8 +143,10 @@
| 方法 | 路径 | 鉴权 | 功能 |
|------|------|------|------|
| GET | `/api/devices/pool` | D | 设备池清单(附实时在线状态) |
| POST | `/api/devices/pool/add` | D | 添加/更新设备 |
| 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 | 一键重连全部池内网络设备 |
@@ -395,24 +397,51 @@
## 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` 状态 |
| `POST /api/devices/pool/add` | `{"serial","name?","note?"}` | upsert;`IP:5555` 立即尝试 connect;后台采型号 |
| `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` | — | 后台并发 10 线程重连全部网络设备 |
| `POST /api/devices/pool/refresh_models` | — | 后台批量采型号 |
| `GET /api/devices/discovery` | — | 发现状态 + 待连接 + 池内断联(前端 10s 轮询) |
| `POST /api/devices/discovery/scan` | — | 后台扫描一轮(约 5-30s);已有扫描 → **409** |
| `POST /api/devices/discovery/confirm` | `{"serial"}` | 待连接 → 设备池 + connect + 采型号 |
| `POST /api/devices/pool/reconnect` | — | 后台并发重连全部网络设备 |
| `POST /api/devices/pool/refresh_models` | — | 后台批量采型号 **+ 补齐缺失指纹** |
### 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"}` | 部分更新,存 `app_meta` |
> 自动发现**只做 socket 探测 + 只读校验**,不会把设备直接拉进设备池(必须人工确认)。
---
> 扫描只做 socket 探测 + 只读校验,**不会把设备直接拉进设备池**(必须人工确认)。
> 扫描时会顺带读一遍候选设备的指纹(写入待连接池),用于提示"这台是已有设备换了地址"。
## 7. 远程看屏与设备操作
+22
View File
@@ -135,6 +135,25 @@
## 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 入池(三条路径)
| 路径 | 入口 | 过程 |
@@ -143,6 +162,9 @@
| 自动发现确认 | `POST /api/devices/discovery/confirm` | 扫描写 `pending_device` → 确认后 `add_device` + 删 pending + connect + 采型号 |
| 启动预连接 | `_preconnect_pool_devices()` | 进程启动时并发 connect 池内网络设备(仅 `IP:5555`) |
> 任何一条入池路径在设备可连时都会读取**设备指纹**;指纹命中池中已有设备 = 同一台换了地址
> → 走**认领**(§4.0),不新增记录。
断联设备的自动重连由发现线程每轮执行(只重连 `IP:5555`)。
### 4.2 可用性判定
+18 -1
View File
@@ -105,6 +105,7 @@
| `enabled` | Boolean | `True` | 停用则不参与调度 |
| `note` | Text | `""` | |
| `created_at` | String(20) | `""` | |
| `fingerprint` | String(120) | `""` | **设备指纹**(`ro.serialno`,迁移 v5 追加):同一台物理设备换 IP 后据此认领回原记录 |
### 2.7 `pending_device` — 待确认的发现设备
@@ -113,6 +114,7 @@
| `serial` | String(120) | — | 主键 |
| `source` | String(20) | `""` | `lan` / `tailscale` |
| `first_seen` / `last_seen` | String(20) | `""` | 时间串 |
| `fingerprint` | String(120) | `""` | 扫描时读取的设备指纹(迁移 v6 追加),用于提示"这是已有设备换了地址" |
> ⚠️ SQLAlchemy 模型的 `default=` 是 **Python 侧默认值**,SQLite 建表语句里没有 `DEFAULT` 子句;只有原生建表的表才有真正的 SQL DEFAULT。
@@ -152,6 +154,8 @@
| 2 | 建 `device` 表(**无 `model` 列**) | 幂等(`IF NOT EXISTS`) |
| 3 | `device` 增加 `model` | `ALTER TABLE`(同上兜底) |
| 4 | 建 `pending_device` 表 | 幂等 |
| 5 | `device` 增加 `fingerprint`(设备指纹) | `ALTER TABLE`(靠 duplicate column 兜底) |
| 6 | `pending_device` 增加 `fingerprint` | 同上 |
执行器 `_migrate_schema()`:
@@ -161,7 +165,20 @@
4. 每条迁移后 `INSERT OR REPLACE app_meta('schema_version', v)` 并提交
5. 整体包 try:失败只记 error 日志,**不阻塞启动**
### 4.2 旧 JSON 迁移(一次性)
### 4.2 唯一索引(设备身份)
`_migrate_schema()` 末尾会补建两个**部分唯一索引**(幂等,与版本号无关,老库升级时也会补):
| 索引 | 作用 |
|------|------|
| `ux_device_name` | 设备**名称唯一**(`WHERE name <> ''`,兼容历史空名) |
| `ux_device_fingerprint` | **一台物理设备在池中只有一条记录**(`WHERE fingerprint <> ''`) |
> 用**部分索引**而不是表级约束:历史数据可能有空名称/空指纹,`<> ''` 让它们不参与唯一性判断。
> 若历史数据里已有重复(建索引失败),只告警不回滚、不阻塞启动——约束从此刻起对新数据生效,
> 老的重复行由管理页「改名」处理。
### 4.3 旧 JSON 迁移(一次性)
启动时若存在 `data/groups.json` / `data/jobs.json`:对应表为空则导入,随后把文件重命名为 `<name>.json.migrated` 归档。**库非空但 JSON 仍在 → 直接归档**,防止"用户删空数据后重启又复原"。
+1
View File
@@ -258,6 +258,7 @@ docker restart python-app # 容器场景
| 现象 | 处理 |
|------|------|
| 设备显示离线 | 「设备池管理 → 一键重连」;确认设备在线、网络互通(生产:同 tailnet) |
| **设备"断联"但其实没关机** | 多数是**换了 IP**(DHCP 重新分配):设备池管理点「**换地址**」把记录迁到新地址(名称与分组/任务引用自动保留);已采集指纹的设备会被扫描自动识别为"已有设备换了地址",确认即认领 |
| 加了设备但不被调度 | 只连上 adb 不够,必须在**设备池**中且 `enabled=true` |
| `u2.connect 超时` | atx-agent 无响应:重启设备或重新推送 atx-agent(基类有 30s 超时保护) |
| 大量设备同时连接时超时 | Windows 端口耗尽(`WinError 10048`):代码会打 `[transient]` 并退避 120s 重试;减少并发或调整系统 TIME_WAIT |