管理页新增:允许/禁止新设备接入 + 修改设备配对码

== 功能一:允许新设备接入开关 ==

关掉之后只有已经登记过的电脑能继续上线,没见过的新设备一律拒绝。
状态存在 relay_state.json 里,重启不丢。

判断新设备用的是 deviceId,不是配对码 ——
这一点很关键:管理员给老设备改过码之后,
那台设备必须仍然能进来;按码判断的话它会被自己的开关挡在外面。
测试里专门有一条 A3b 守这个行为。

被拒时回 blocked=True,让客户端能区分被管理员挡了
和配对码冲突,而不是去匹配中文错误文案(文案一改就坏)。

== 功能二:修改任意设备的配对码 ==

管理页每台设备一个「改码」按钮,填 6 位数字即生效。
要点:
  * 新旧码冲突检查
  * 旧码映射必须删掉,否则变成孤儿条目,
    管理页配对码占用的数字会虚高
  * 改码踢掉该设备当前会话(会话绑在码上)
  * 立刻持久化

== 顺手修掉两个真 bug ==

1. 改码功能一上线就 500:Session 用了 __slots__,
   根本没有 session_id 属性(会话是以 token 为 key 存在 dict 里的),
   写 s.session_id 直接 AttributeError。
   改成遍历 sessions.items() 拿 key。

2. snapshot() 里有同样的错(清理超时设备那段):
   平时 stale 列表是空的所以从没炸过,一旦真有设备超过 6 小时
   没上报就会 500。这次一起修了。

== 管理页 UI ==
  * 设备小节加了「允许新设备接入」勾选框
  * 每行加「改码」按钮(走 prompt,不引入弹窗层)
  * 改码按钮用 data-recode 而不是 data-device ——
    现有单台删除按 button.danger[data-device] 绑,
    带上 data-device 会被当成删除按钮,点改码变删设备。
    测试 C6 专门断言这一点。

== 测试 ==
新增 _tests/test_admin_device_control.py(23 项),
已注册进 _tests/verify_all.ps1 第 3e 阶段。
This commit is contained in:
xmz7891
2026-10-07 17:01:14 +08:00
parent 25e7780272
commit d4f335b8f9
21 changed files with 1520 additions and 7 deletions
+114 -6
View File
@@ -391,6 +391,8 @@ class Relay:
# 留着是为了能明确告诉旧客户端"你是被顶掉的",
# 而不是含糊的 401 —— 否则它会以为自己只是过期了,然后抢回来。
self.retired: dict = {}
# 是否允许新设备接入。False 时只有已登记过的 deviceId 能上线。
self.allow_new = True
self.started = time.time()
self.total_requests = 0
self.state_file = DATA_DIR / "relay_state.json"
@@ -403,6 +405,8 @@ class Relay:
raw = json.loads(self.state_file.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
return
# 是否允许新设备接入(重启不丢)
self.allow_new = bool(raw.get("allowNew", True))
for item in raw.get("codes", []):
device_id = str(item.get("deviceId", ""))
code = str(item.get("code", ""))
@@ -413,10 +417,13 @@ class Relay:
def _save(self) -> None:
try:
DATA_DIR.mkdir(parents=True, exist_ok=True)
payload = {"codes": [
{"deviceId": did, "code": code}
for code, did in sorted(self.codes.items())
]}
payload = {
"allowNew": bool(self.allow_new),
"codes": [
{"deviceId": did, "code": code}
for code, did in sorted(self.codes.items())
],
}
tmp = self.state_file.with_suffix(".tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8")
@@ -463,6 +470,19 @@ class Relay:
return {"ok": False, "error": "配对码必须是 6 位数字"}
with self.lock:
# 管理员关掉了"允许新设备接入"时,
# 只放行**已经登记过**的设备。
#
# 注意判断依据是 deviceId(装机时生成、重装也不变),
# **不是配对码** —— 否则管理员给老设备改了码,
# 那台设备就被自己的开关挡在外面了。
known_device = device_id in self.devices
if not known_device and not self.allow_new:
log(f"新设备接入已被关闭,拒绝 {name} ({device_id[:12]}…)")
return {"ok": False,
"blocked": True,
"error": "中转站当前不接受新设备接入"}
known = self.codes.get(code)
if known and known != device_id:
# codeTaken 让客户端能精确识别这种冲突,
@@ -507,6 +527,70 @@ class Relay:
return {"ok": True, "token": device.token, "code": code,
"note": note, "pollSeconds": POLL_SECONDS}
# ---------------- 管理操作 ----------------
def set_allow_new(self, allow: bool) -> dict:
"""开关"允许新设备接入"。"""
with self.lock:
self.allow_new = bool(allow)
self._save()
log(f"管理员把「允许新设备接入」设为 {self.allow_new}")
return {"ok": True, "allowNew": self.allow_new}
def set_device_code(self, device_id: str, new_code: str) -> dict:
"""给一台设备换配对码。
要点:
* 新码必须是 6 位数字
* 不能已经被**别的**设备占用
* 旧码的映射必须删掉,否则会变成孤儿条目,
管理页上"配对码占用"的数字会虚高
* 该设备挂着的会话要踢掉 —— 会话是绑在码上的,
不踢的话手表还拿着旧会话以为连着
"""
device_id = str(device_id or "").strip()
new_code = str(new_code or "").strip()
if not CODE_RE.match(new_code):
return {"ok": False, "error": "配对码必须是 6 位数字"}
with self.lock:
device = self.devices.get(device_id)
if device is None:
return {"ok": False, "error": "没有这台设备(可能已下线)"}
owner = self.codes.get(new_code)
if owner and owner != device_id:
return {"ok": False,
"error": "这个配对码已经被别的设备占用了"}
old_code = device.code
if old_code == new_code:
return {"ok": True, "code": new_code, "unchanged": True}
# 删掉旧码映射(连同可能存在的别名)
for code, owner_id in list(self.codes.items()):
if owner_id == device_id:
self.codes.pop(code, None)
self.codes[new_code] = device_id
device.code = new_code
# 会话绑在码上,旧会话作废,让手表重新配对。
#
# ⚠ 会话是以 token 为 key 存在 self.sessions 里的,
# Session 用了 __slots__,**根本没有 session_id 属性**。
# 必须遍历 items() 拿 key;写 s.session_id 会 AttributeError,
# 而且会被上层兜底成"服务器内部错误"(500),很难定位。
kicked = 0
for sid in [t for t, s in self.sessions.items()
if s.device_id == device_id]:
self.sessions.pop(sid, None)
kicked += 1
self._save()
log(f"管理员把设备 {device.name} 的配对码 "
f"{old_code} 改成 {new_code}(踢掉 {kicked} 个会话)")
return {"ok": True, "code": new_code,
"oldCode": old_code, "kicked": kicked}
def device_by_token(self, token: str) -> Device | None:
if not token:
return None
@@ -682,8 +766,12 @@ class Relay:
if now - getattr(dev, "last_seen", now) > DEVICE_TTL]
for did in stale:
self.devices.pop(did, None)
# 它的会话也一起收掉,免得挂着一堆死会话
for sid in [s.session_id for s in self.sessions.values()
# 它的会话也一起收掉,免得挂着一堆死会话。
# ⚠ 同样要用 items() 拿 token 当 key ——
# Session 没有 session_id 属性(见 set_device_code 里的说明)。
# 这段以前写的是 s.session_id,只是因为平时 stale 是空的
# 才一直没炸;一旦真有设备超时就会 500。
for sid in [t for t, s in self.sessions.items()
if s.device_id == did]:
self.sessions.pop(sid, None)
@@ -726,6 +814,9 @@ class Relay:
"sessions": len(self.sessions),
"totalRequests": self.total_requests,
"memoryKB": _rss_kb(),
# 管理页上的"允许新设备接入"开关就显示这个值
"allowNew": bool(self.allow_new),
"codeCount": len(self.codes),
}
def kick(self, device_id: str) -> int:
@@ -1653,6 +1744,23 @@ class Handler(BaseHTTPRequestHandler):
self._json(200, {"ok": True, "forgotten": done})
return
# ---- 允许/禁止新设备接入 ----
if path == "/v1/admin/allow_new":
body = self._json_body()
allow = bool(body.get("allow", True))
result = RELAY.set_allow_new(allow)
self._json(200, result)
return
# ---- 改某台设备的配对码 ----
if path == "/v1/admin/set_code":
body = self._json_body()
result = RELAY.set_device_code(
str(body.get("deviceId") or ""),
str(body.get("code") or ""))
self._json(200 if result.get("ok") else 400, result)
return
self._json(404, {"ok": False, "error": "没有这个管理接口"})
return