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:
+46
-7
@@ -223,16 +223,20 @@ class Device(db.Model):
|
||||
model 为在线时自动采集的型号(如 Redmi 12C),供管理页/监控页区分设备。
|
||||
"""
|
||||
serial = db.Column(db.String(120), primary_key=True)
|
||||
name = db.Column(db.String(80), default="") # 备注名(可选)
|
||||
model = db.Column(db.String(120), default="") # 型号(自动采集)
|
||||
enabled = db.Column(db.Boolean, default=True) # 是否参与调度
|
||||
note = db.Column(db.Text, default="") # 备注
|
||||
created_at = db.Column(db.String(20), default="") # 添加时间
|
||||
name = db.Column(db.String(80), default="") # 设备名(唯一,人可读标识)
|
||||
model = db.Column(db.String(120), default="") # 型号(自动采集)
|
||||
enabled = db.Column(db.Boolean, default=True) # 是否参与调度
|
||||
note = db.Column(db.Text, default="") # 备注
|
||||
created_at = db.Column(db.String(20), default="") # 添加时间
|
||||
# 设备指纹(ro.serialno):识别"同一台物理设备"的稳定标识。
|
||||
# 网络设备(serial=IP:5555)换 IP 后靠它认领回原记录,名称/分组/任务引用都不丢。
|
||||
fingerprint = db.Column(db.String(120), default="")
|
||||
|
||||
def to_dict(self):
|
||||
return {"serial": self.serial, "name": self.name or "",
|
||||
"model": self.model or "", "enabled": bool(self.enabled),
|
||||
"note": self.note or "", "created_at": self.created_at or ""}
|
||||
"note": self.note or "", "created_at": self.created_at or "",
|
||||
"fingerprint": self.fingerprint or ""}
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Device {self.serial}>"
|
||||
@@ -247,10 +251,14 @@ class PendingDevice(db.Model):
|
||||
source = db.Column(db.String(20), default="") # lan / tailscale
|
||||
first_seen = db.Column(db.String(20), default="") # 首次发现时间
|
||||
last_seen = db.Column(db.String(20), default="") # 最近一次扫描仍可见的时间
|
||||
# 扫描时顺带读取的设备指纹:与设备池中已有记录比对,用于提示
|
||||
# 「这台其实就是 <名称>(原 IP 变了)」而不是让用户在一堆陌生 IP 里猜
|
||||
fingerprint = db.Column(db.String(120), default="")
|
||||
|
||||
def to_dict(self):
|
||||
return {"serial": self.serial, "source": self.source or "",
|
||||
"first_seen": self.first_seen or "", "last_seen": self.last_seen or ""}
|
||||
"first_seen": self.first_seen or "", "last_seen": self.last_seen or "",
|
||||
"fingerprint": self.fingerprint or ""}
|
||||
|
||||
|
||||
# 版本化 schema 迁移:新增结构变更时在此追加 (版本号, 说明, SQL)
|
||||
@@ -273,8 +281,22 @@ SCHEMA_MIGRATIONS = [
|
||||
"source VARCHAR(20) DEFAULT '',"
|
||||
"first_seen VARCHAR(20) DEFAULT '',"
|
||||
"last_seen VARCHAR(20) DEFAULT '')"),
|
||||
(5, "设备池:device 表新增 fingerprint 列(设备指纹 ro.serialno,换 IP 后认领回原记录)",
|
||||
"ALTER TABLE device ADD COLUMN fingerprint VARCHAR(120) DEFAULT ''"),
|
||||
(6, "自动发现:pending_device 表新增 fingerprint 列(扫描时读取,用于提示是已有设备换了 IP)",
|
||||
"ALTER TABLE pending_device ADD COLUMN fingerprint VARCHAR(120) DEFAULT ''"),
|
||||
]
|
||||
|
||||
# 唯一索引(部分索引:空值不参与唯一约束,兼容历史未命名/未采指纹的老数据)
|
||||
# 名称唯一 = 设备的人可读标识;指纹唯一 = 一台物理设备在池中只能有一条记录
|
||||
_UNIQUE_INDEXES = (
|
||||
("ux_device_name", "CREATE UNIQUE INDEX IF NOT EXISTS ux_device_name "
|
||||
"ON device(name) WHERE name IS NOT NULL AND name <> ''"),
|
||||
("ux_device_fingerprint", "CREATE UNIQUE INDEX IF NOT EXISTS ux_device_fingerprint "
|
||||
"ON device(fingerprint) WHERE fingerprint IS NOT NULL "
|
||||
"AND fingerprint <> ''"),
|
||||
)
|
||||
|
||||
|
||||
def init_db(app):
|
||||
"""在 Flask app context 里初始化数据库 + 创建默认管理员。
|
||||
@@ -322,10 +344,27 @@ def _migrate_schema():
|
||||
{"v": str(version)})
|
||||
db.session.commit()
|
||||
_log.info(f"schema 迁移到版本 {version}: {desc}")
|
||||
_ensure_unique_indexes()
|
||||
except Exception as e:
|
||||
_log.error(f"schema 迁移失败(不阻塞启动): {e}")
|
||||
|
||||
|
||||
def _ensure_unique_indexes():
|
||||
"""建设备池的唯一索引(幂等)。
|
||||
|
||||
单独抽出来是因为索引不属于某个版本迁移:老库升级后也要补建。
|
||||
历史数据若存在重复(名称/指纹撞车),建索引会失败——只告警不回滚、
|
||||
不阻塞启动,由管理页提示用户改名(唯一约束从此刻起对新数据生效)。
|
||||
"""
|
||||
for name, sql in _UNIQUE_INDEXES:
|
||||
try:
|
||||
db.session.execute(text(sql))
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
_log.warning(f"唯一索引 {name} 创建失败(历史数据可能有重复): {e}")
|
||||
|
||||
|
||||
def _ensure_default_admin():
|
||||
"""首次启动创建默认管理员 admin/admin123。"""
|
||||
if not User.query.filter_by(username="admin").first():
|
||||
|
||||
Reference in New Issue
Block a user