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
+75 -12
View File
@@ -63,10 +63,11 @@ function loadDiscovery(){
}else{status.textContent='尚未扫描';}
// 待连接表格:只显示在线设备(离线候选不可确认连接,隐藏;恢复在线后自动出现)
const rows=(r.pending||[]).filter(x=>x.online);
_discoveryRows=rows; // 供确认时判断"是不是已有设备换了地址"
const tb=document.getElementById('tb-discovery');
if(!tb)return;
if(!rows.length){
tb.innerHTML='<tr><td colspan="6" class="empty">待连接池为空(扫描到的设备会出现在这里)</td></tr>';
tb.innerHTML='<tr><td colspan="7" class="empty">待连接池为空(扫描到的设备会出现在这里)</td></tr>';
}else{
tb.innerHTML=rows.map(x=>{
const src=x.source==='tailscale'
@@ -75,9 +76,17 @@ function loadDiscovery(){
const onl=x.online
?'<span class="label label-success">在线</span>'
:'<span class="label label-warning">离线</span>';
// 指纹命中池中已有设备 → 提示"这台是 X 换了地址",确认即认领(不需要起新名)
const m=x.match;
const ident=m
?'<span class="label label-info" title="指纹一致:同一台设备换了地址;确认后会迁移原记录并同步分组/任务">≈ '
+esc(m.name||m.serial)+'(原 '+esc(m.serial)+')</span>'
:'<span class="text-muted">新设备</span>';
const btnTxt=m?'认领为『'+esc(m.name||'原设备')+'』':'确认添加';
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'
+'<td>'+ident+'</td>'
+'<td>'+src+'</td><td>'+esc(x.first_seen)+'</td><td>'+esc(x.last_seen)+'</td><td>'+onl+'</td>'
+'<td><button class="btn btn-primary btn-xs" data-serial="'+esc(x.serial)+'" onclick="confirmDiscoveryDev(this.dataset.serial)">确认添加</button> '
+'<td><button class="btn btn-primary btn-xs" data-serial="'+esc(x.serial)+'" onclick="confirmDiscoveryDev(this.dataset.serial)">'+btnTxt+'</button> '
+'<button class="btn btn-xs" data-serial="'+esc(x.serial)+'" onclick="ignoreDiscoveryDev(this.dataset.serial)">忽略</button></td></tr>';
}).join('');
}
@@ -90,9 +99,15 @@ function loadDiscovery(){
}else{
offTb.innerHTML=off.map(x=>{
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'
+'<td>'+esc(x.model||'-')+'</td><td>'+esc(x.name||'')+'</td>'
+'<td>'+(x.name ? esc(x.name) :
'<span class="label label-warning">未命名</span>')+'</td>'
+'<td>'+esc(x.model||'-')+'</td>'
+'<td><span class="label label-danger">断联·自动重连中</span></td>'
+'<td><button class="btn btn-xs" data-serial="'+esc(x.serial)+'" onclick="reconnectPoolDev(this.dataset.serial)">立即重连</button></td></tr>';
+'<td style="white-space:nowrap">'
+'<button class="btn btn-xs" data-serial="'+esc(x.serial)+'" onclick="reconnectPoolDev(this.dataset.serial)">立即重连</button> '
// 设备换了 IP 时旧地址永远连不上——给一条"换地址"的出路(迁移记录 + 同步引用)
+'<button class="btn btn-xs" title="设备换了 IP、这个旧地址已经连不上时:把记录迁到新地址(名称与分组/任务引用自动保留)" onclick="relocatePoolDev(this.dataset.serial)">换地址</button>'
+'</td></tr>';
}).join('');
}
}
@@ -128,11 +143,23 @@ function runDiscoveryScan(){
});
}
function confirmDiscoveryDev(serial){
const name=prompt('备注名(可选,留空直接添加)','');
if(name===null)return;
const row=(_discoveryRows||[]).find(x=>x.serial===serial)||{};
const m=row.match;
let name='';
if(m){
// 指纹命中:这是池中某台设备换了地址 → 认领(保留原名与引用),无需再起名
if(!confirm('这台就是『'+(m.name||m.serial)+'』(原地址 '+m.serial+')。\n\n'
+'确认认领到 '+serial+' 吗?\n'
+'名称、型号、备注都会保留,分组与任务里的旧地址会自动同步到新地址。'))return;
}else{
const input=prompt('给这台设备起个名称(必填,平台内唯一):','');
if(input===null)return;
name=(input||'').trim();
if(!name){showToast('必须填写设备名称','error');return;}
}
apiPost('/api/devices/discovery/confirm',{serial,name}).then(r=>{
if(r&&r.ok){
showToast('已加入设备池'+(r.is_new?'':'(已存在,已更新)'),'success');
showToast(r.msg||'已加入设备池','success');
loadDevPool();loadDiscovery();
}else showToast('确认失败: '+((r&&r.error)||''),'error');
});
@@ -411,19 +438,22 @@ function copyTsKey(){
// ================== Tab: 工具 - 设备池管理(本地 SQLite 清单) ==================
let _devPoolRows=[]; // 设备池最近一次结果(改名时取当前名称)
let _discoveryRows=[]; // 待连接池最近一次结果(确认时判断是否为已有设备换了地址)
async function loadDevPool(){
const tb=document.getElementById('tb-devpool');
const status=document.getElementById('devpool-status');
if(!tb)return;
tb.innerHTML='<tr><td colspan="7" class="empty">加载中...</td></tr>';
tb.innerHTML='<tr><td colspan="8" class="empty">加载中...</td></tr>';
const r=await apiGet('/api/devices/pool');
if(!r||!r.ok){tb.innerHTML='<tr><td colspan="7" class="empty">加载失败</td></tr>';return;}
if(!r||!r.ok){tb.innerHTML='<tr><td colspan="8" class="empty">加载失败</td></tr>';return;}
let rows=r.devices||[];
// 在线优先排序;「只看在线」勾选时过滤掉离线(IP 换了/长期离线的旧条目不再碍眼)
const onlyOnline=document.getElementById('devpool-filter-online')
? document.getElementById('devpool-filter-online').checked : false;
rows.sort((a,b)=>(b.online?1:0)-(a.online?1:0));
if(onlyOnline)rows=rows.filter(x=>x.online);
_devPoolRows=rows;
const onlineCnt=rows.filter(x=>x.online).length;
status.textContent=onlyOnline
? '在线 '+rows.length+' 台(共 '+(r.devices||[]).length+' 台配置)'
@@ -435,24 +465,36 @@ async function loadDevPool(){
const online= d.online
? '<span class="label label-success">在线</span>'
: '<span class="label label-warning">离线</span>';
// 名称是设备身份:老数据可能有空的 → 标出来提示补
const nameCell = d.name
? esc(d.name)
: '<span class="label label-warning" title="设备名称必填且唯一(分组/任务/日志按名称认设备);点右侧「改名」补上">未命名</span>';
const fp = d.fingerprint
? '<span title="设备指纹 '+esc(d.fingerprint)+'(ro.serialno):换 IP 后靠它自动认领">🔑</span>'
: '<span class="text-muted" title="尚未采集到指纹:设备离线时采不到;采集到之后换 IP 才能自动认领">—</span>';
return '<tr>'+
'<td style="font-family:monospace">'+esc(d.serial)+'</td>'+
'<td>'+esc(d.name||'')+'</td>'+
'<td>'+esc(d.model||'<span style="color:var(--text-light)">-</span>')+'</td>'+
'<td>'+nameCell+'</td>'+
'<td>'+esc(d.model||'-')+'</td>'+
'<td>'+online+'</td>'+
'<td>'+st+'</td>'+
'<td style="text-align:center">'+fp+'</td>'+
'<td style="color:var(--text-light)">'+esc(d.created_at||'')+'</td>'+
'<td style="white-space:nowrap">'+
'<button class="btn btn-xs" onclick="renamePoolDev(\''+esc(d.serial)+'\')">改名</button> '+
(d.online ? '' :
'<button class="btn btn-xs" title="设备换了 IP、旧地址连不上时:把这条记录迁到新地址(名称与分组/任务引用自动保留)" onclick="relocatePoolDev(\''+esc(d.serial)+'\')">换地址</button> ') +
'<button class="btn btn-xs" onclick="togglePoolDev(\''+esc(d.serial)+'\','+(!d.enabled)+')">'+(d.enabled?'停用':'启用')+'</button> '+
'<button class="btn btn-xs btn-danger" onclick="removePoolDev(\''+esc(d.serial)+'\')">删除</button>'+
'</td></tr>';
}).join('')||'<tr><td colspan="7" class="empty">设备池为空,添加第一台设备开始</td></tr>';
}).join('')||'<tr><td colspan="8" class="empty">设备池为空,添加第一台设备开始</td></tr>';
}
async function addPoolDev(){
const serial=document.getElementById('devpool-serial').value.trim();
const name=document.getElementById('devpool-name').value.trim();
if(!serial){showToast('请输入 serial','error');return;}
if(!name){showToast('请填写设备名称(平台内唯一,用于分组/任务/日志认设备)','error');return;}
const r=await apiPost('/api/devices/pool/add',{serial,name});
if(!r)return;
if(r.ok){
@@ -463,6 +505,27 @@ async function addPoolDev(){
}else showToast(r.error,'error');
}
async function renamePoolDev(serial){
const cur=(((_devPoolRows||[]).find(x=>x.serial===serial))||{}).name||'';
const nw=(prompt('设备名称(必填,平台内唯一):', cur)||'').trim();
if(!nw || nw===cur)return;
const r=await apiPost('/api/devices/pool/rename',{serial,name:nw});
if(!r)return;
if(r.ok){showToast(r.msg,'success');loadDevPool();}
else showToast(r.error,'error');
}
// 换地址(人工认领):设备换了 IP、旧地址已连不上时用;名称与分组/任务引用都保留
async function relocatePoolDev(serial){
const nw=(prompt('把这条记录迁到新地址(设备换 IP、旧地址连不上时用):\n\n'
+'名称、型号、备注都会保留,分组与任务里的旧地址会自动同步。','')||'').trim();
if(!nw)return;
const r=await apiPost('/api/devices/pool/relocate',{old_serial:serial,new_serial:nw});
if(!r)return;
if(r.ok){showToast(r.msg,'success');loadDevPool();loadDiscovery();}
else showToast(r.error,'error');
}
async function removePoolDev(serial){
if(!confirm('从设备池删除 '+serial+'?\n\n删除后不再参与任务调度(不影响设备本身与其他功能)。'))return;
const r=await apiPost('/api/devices/pool/remove',{serial});