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
+34
View File
@@ -449,6 +449,40 @@ class TaskManager:
self._save_groups()
return g
def sync_device_serial(self, old_serial, new_serial):
"""设备换地址(认领/迁址)后,把分组与任务里的引用改到新地址。
由 device_pool 的迁址回调触发(见 device_pool.set_move_hook)。
**必须同时改内存与库**:调度用的是内存里的 groups/jobs,只改库不重启不生效
—— 表现就是"分组里少了一台、指定设备的任务仍跑向不存在的地址"。
返回被更新的引用条数。
"""
if not old_serial or not new_serial or old_serial == new_serial:
return 0
n = 0
for name, g in list(self.groups.items()):
try:
serials = list(g.serials or [])
except Exception:
continue
if old_serial in serials:
self.update_group(name, serials=[new_serial if s == old_serial else s
for s in serials])
n += 1
_log.info(f"设备换地址: 分组『{name}』的 {old_serial} → {new_serial}")
for job_id, job in list(self.jobs.items()):
try:
target = dict(job.target or {})
except Exception:
continue
if target.get("mode") == "serial" and target.get("serial") == old_serial:
target["serial"] = new_serial
self.update_job(job_id, target=target)
n += 1
_log.info(f"设备换地址: 任务『{job.name}』目标 → {new_serial}")
return n
def delete_group(self, name):
if name in self.groups:
del self.groups[name]