Files
auto_control/web/devices_api.py
T
butubb 7ce4ae9016 feat(账号): 账号台账(web 页 + 任务取号 + 设备端身份页);fix(剪贴板): 注入通道改走设备端 Agent
一、账号台账(新表 device_account,schema v8)
- core/ledger.py:CRUD、Excel 粘贴解析(Tab 分隔 / 表头乱序缺列 / 是·否布尔 / 逐行留痕)、
  按范围取号(本机台账 / 全部 / 按设备分组)、设备端账号块、dry_run 预览
- 新「账号」顶级 Tab(权限 devices):列表 + 搜索 + 设备筛选 + 增删改 +
  粘贴导入(默认先预览再写,逐行显示 新增/覆盖/跳过/失败 + 留痕)
- 设备池加「账号 N」列(0 个显示"未登记"),点开看该设备账号明细
- 任务「条件判断」新增 cmp_source / cmp_group:候选值可直接来自台账,
  与手填值**合并(OR)**;取不到号时回落手填值,并把原因写进步骤明细与日志
  (否则"永远走 else 分支"而任务照样显示成功,最难查)
- 设备端契约:身份页多推 accounts_b64(base64 JSON;默认不含手机号;
  超预算按整条丢且绝不算字节切),见 doc/DEVICE_AGENT.md §5.2.1
- 备份/文档红线:TABLE_LABELS 加「账号台账」;DATA_MODEL/API/ARCHITECTURE/DEPLOY/
  TASK_DEV/DEVICE_AGENT/DEVELOPMENT/README 同步;顺手补上 DATA_MODEL 漏列的 done_mark

⚠ 台账里的抖音号是**纯号**,只当"比对用的候选值":绝不能拿去填「去重」的身份元素
  (身份是元素原文逐字算 key,格式不同会让去重静默失效)。代码与文档都写明了。

二、剪贴板注入通道(修:平台还在调早期的独立 APK)
- 改为按顺序尝试:设备端 Agent(com.example.deviceagent/.ClipActivity)→
  旧版独立 ClipInject 兜底;两个都没有时报"需要设备端 Agent"(不再只说 ClipInject)
- 读回验证改成**轮询到 3 秒**:透明 Activity 要等窗口拿到焦点才写,
  原来只睡 0.5s 会读到上一次的内容 → 误报"写入可能被拒"(实测内容已写入却回失败)
2026-09-24 16:12:58 +08:00

555 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""设备池管理 API(本地 SQLite 清单)。"""
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from flask import Blueprint, jsonify, request
from core import device_battery, device_pool, device_discovery, ledger
from core.adb_helper import adb_connect, adb_connect_light
from core.logger import get_logger
from web.auth import perm_required, PERM_DEVICES
_log = get_logger("web")
bp = Blueprint("devices", __name__)
def _lan_ip():
"""挑一个手机连得上的本机地址:优先 192.168.x,其次 10.x。
不能用 gethostbyname(hostname) —— 这台机器上它可能返回 Tailscale 的
100.x(CGNAT 地址,手机不在 tailnet 里根本连不上),或 docker 的 172.17。
"""
import socket
candidates = []
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
ip = info[4][0]
if ip.startswith("127.") or ip.startswith("169.254."):
continue
candidates.append(ip)
except Exception:
pass
for ip in candidates:
if ip.startswith("192.168."):
return ip
for ip in candidates:
if ip.startswith("10."):
return ip
return candidates[0] if candidates else "本机IP"
@bp.route("/api/devices/qrcode")
@perm_required(PERM_DEVICES)
def api_devices_qrcode():
"""给某台设备生成「配置二维码」——设备端 Agent 扫一下就把自己配好。
二维码里是一段 JSON(平台地址 + 设备令牌 + 这台设备的指纹),扫码即完成:
填 server/token/指纹 三件事,不用在手机上打字,也不用连 adb。
⚠️ 两条使用前提(都会在返回里给提示,不静默失败):
1. 平台要先在「应用管理 → 设备端应用商店」启用(令牌是从那儿来的)
2. **要用局域网地址打开本页面**(二维码里的地址取自当前访问地址;
如果你是用 localhost 打开的,手机会连不上,这里会明确告诉你)
"""
import base64
import io
import json as _json
serial = (request.args.get("serial") or "").strip()
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
dev = None
for d in device_pool.list_devices():
if d.get("serial") == serial:
dev = d
break
if dev is None:
return jsonify({"ok": False, "error": "设备不在池里"}), 404
# 令牌必须先启用设备端商店才有
from core.db_config import meta_get
from web.device_agent_api import store_enabled, ensure_token
if not store_enabled():
return jsonify({"ok": False,
"error": "还没启用设备端商店:先到「应用管理 → 设备端应用商店」勾选启用"}), 400
token = ensure_token()
server = request.host_url.rstrip("/")
warn = ""
if "127.0.0.1" in server or "localhost" in server:
warn = ("你现在是用本机地址打开平台的,二维码里的地址手机会连不上 —— "
"请改用局域网地址(如 http://%s:18050)重新打开本页再生成。"
% _lan_ip())
fp = dev.get("fingerprint") or ""
if not fp:
warn = (warn + " " if warn else "") + \
"这台设备还没采集到指纹,扫码后平台认不出它是哪一台(设备端会显示「未登记」);" \
"建议先让它在线并刷新一次型号/指纹,再生成二维码。"
payload = {"v": 1, "server": server, "token": token,
"fingerprint": fp, "name": dev.get("name") or ""}
text = _json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
try:
import qrcode
img = qrcode.make(text)
buf = io.BytesIO()
img.save(buf, format="PNG")
png = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
except Exception as e:
_log.error(f"生成二维码失败: {e}")
return jsonify({"ok": False, "error": f"生成二维码失败: {e}"}), 500
_log.info(f"生成设备配置二维码: {dev.get('name') or serial}")
return jsonify({"ok": True, "serial": serial, "name": dev.get("name") or "",
"fingerprint": fp, "png": png, "payload": text, "warn": warn})
@bp.route("/api/devices/pool", methods=["GET"])
@perm_required(PERM_DEVICES)
def api_devices_pool_list():
"""设备池清单(SQLite,含实时在线状态)。"""
try:
online = set(device_pool.list_online())
except Exception:
online = set()
rows = device_pool.list_devices()
for r in rows:
r["online"] = r["serial"] in online
return jsonify({"ok": True, "devices": rows})
@bp.route("/api/devices/pool/add", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_pool_add():
"""添加/更新设备:{serial, name, note?}。
**名称必填且唯一**(设备在平台里的身份标识,分组/任务/日志都靠它认设备)。
IP:5555 设备添加后立即尝试 adb connect 并读取**设备指纹**:若指纹命中池中已有
设备(同一台设备换了 IP),自动**认领**——迁移旧记录(保留原名)并同步
分组与任务里的引用,而不是新增一条。
"""
data = request.json or {}
serial = (data.get("serial") or "").strip()
if not serial:
return jsonify({"ok": False, "error": "请输入设备 serial(如 100.100.10.20:5555)"}), 400
name = (data.get("name") or "").strip()
note = (data.get("note") or "").strip()
if not name:
return jsonify({"ok": False, "error":
"请填写设备名称——名称是设备在平台里的唯一标识"
"(分组、任务目标、日志都用它认设备)"}), 400
# 重名先查(放在 adb 之前:连不上的 IP 不该让用户白等)
if device_pool.name_taken(name, exclude_serial=serial):
return jsonify({"ok": False, "error": f"名称『{name}』已被其它设备占用,请换一个"}), 400
# 轻量 connect(单次、短超时,不用 5 次重试的 adb_connect,避免不可达设备把接口卡住)
if ":" in serial:
try:
adb_connect_light(serial)
except Exception:
pass
fp = device_pool.read_fingerprint(serial) if ":" in serial else ""
claimed_old, claimed_name = device_pool.claim_device(serial, fp) if fp else (None, "")
# 认领时保留设备**原有名称**(身份优先于本次输入),没有原名才用输入值
use_name = (claimed_name or name).strip()
if use_name != name and device_pool.name_taken(use_name, exclude_serial=serial):
return jsonify({"ok": False, "error": f"名称『{use_name}』已被其它设备占用,请换一个"}), 400
is_new = device_pool.add_device(serial, name=use_name, note=note, fingerprint=fp)
# 后台采集型号 + 指纹(不阻塞添加响应)
threading.Thread(target=device_pool.refresh_info, args=(serial,), daemon=True).start()
if claimed_old:
msg = (f"已认领:这是『{use_name}』(原地址 {claimed_old}),"
f"分组与任务的引用已自动同步")
else:
msg = "已添加" if is_new else "已更新"
_log.info(f"设备池管理: {msg} {serial} name={use_name}")
return jsonify({"ok": True, "msg": msg, "is_new": is_new,
"claimed": bool(claimed_old), "old_serial": claimed_old or ""})
@bp.route("/api/devices/pool/rename", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_pool_rename():
"""重命名设备:{serial, name}。名称必填且唯一。"""
data = request.json or {}
serial = (data.get("serial") or "").strip()
name = (data.get("name") or "").strip()
if not serial or not name:
return jsonify({"ok": False, "error": "缺少 serial 或 name"}), 400
if device_pool.name_taken(name, exclude_serial=serial):
return jsonify({"ok": False, "error": f"名称『{name}』已被其它设备占用,请换一个"}), 400
if not device_pool.rename_device(serial, name):
return jsonify({"ok": False, "error": "设备不存在"}), 404
return jsonify({"ok": True, "msg": f"已重命名为『{name}』"})
@bp.route("/api/devices/pool/relocate", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_pool_relocate():
"""人工认领(换地址兜底):{old_serial, new_serial}。
用于设备换了 IP 但**旧地址已断联、指纹也没采过**、自动认领无从匹配时:
由人工指认"这条记录就是那台设备,现在在新地址",迁移并同步分组/任务引用。
"""
data = request.json or {}
old_serial = (data.get("old_serial") or "").strip()
new_serial = (data.get("new_serial") or "").strip()
if not old_serial or not new_serial:
return jsonify({"ok": False, "error": "缺少 old_serial 或 new_serial"}), 400
if ":" in new_serial:
try:
adb_connect_light(new_serial)
except Exception:
pass
fp = device_pool.read_fingerprint(new_serial) if ":" in new_serial else ""
ok, name, msg = device_pool.relocate_device(old_serial, new_serial, fingerprint=fp)
if not ok:
return jsonify({"ok": False, "error": msg}), 400
# 新地址若在待连接池里,一并清掉(已经是池内设备了)
try:
device_discovery.ignore_pending(new_serial)
except Exception:
pass
threading.Thread(target=device_pool.refresh_info, args=(new_serial,), daemon=True).start()
_log.info(f"设备池管理: 人工认领 {old_serial} → {new_serial}({name})")
return jsonify({"ok": True, "msg": msg, "name": name})
@bp.route("/api/devices/pool/remove", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_pool_remove():
"""从设备池删除(不再参与调度;不影响其他系统)。"""
serial = (request.json or {}).get("serial", "").strip()
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
ok = device_pool.remove_device(serial)
if not ok:
return jsonify({"ok": False, "error": "设备不存在"}), 404
return jsonify({"ok": True, "msg": "已删除"})
@bp.route("/api/devices/pool/toggle", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_pool_toggle():
"""启用/停用设备(停用后不参与调度)。"""
data = request.json or {}
serial = (data.get("serial") or "").strip()
enabled = data.get("enabled")
if not serial or enabled is None:
return jsonify({"ok": False, "error": "缺少参数"}), 400
ok = device_pool.set_enabled(serial, bool(enabled))
if not ok:
return jsonify({"ok": False, "error": "设备不存在"}), 404
return jsonify({"ok": True, "msg": "已" + ("启用" if enabled else "停用")})
@bp.route("/api/devices/pool/reconnect", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_pool_reconnect():
"""一键重连:并发 adb connect 池内全部 IP:5555 设备(后台执行,不阻塞)。"""
def _run():
try:
serials = [s for s in device_pool.list_configured() if ":" in s]
except Exception:
return
if not serials:
return
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor(max_workers=10) as pool:
futures = {pool.submit(adb_connect, s): s for s in serials}
for _ in as_completed(futures):
pass
_log.info(f"设备池一键重连完成({len(serials)} 台)")
# 重连后顺手刷新型号
try:
device_pool.refresh_all_models()
except Exception:
pass
import threading
threading.Thread(target=_run, daemon=True).start()
return jsonify({"ok": True, "msg": "重连已启动(后台并发,约 10-20 秒)"})
@bp.route("/api/devices/pool/refresh_models", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_pool_refresh_models():
"""批量采集池内在线设备的型号(后台执行,不阻塞)。"""
import threading
threading.Thread(target=device_pool.refresh_all_models, daemon=True).start()
return jsonify({"ok": True, "msg": "型号采集已启动(后台并发,约 10 秒)"})
# ================== API:设备自动发现(扫描 → 待连接池 → 确认) ==================
# 扫描只把设备放进待连接池(pending),用户确认后才加入正式设备池并连接。
@bp.route("/api/devices/discovery", methods=["GET"])
@perm_required(PERM_DEVICES)
def api_devices_discovery():
"""发现状态 + 待连接列表 + 正式池断联设备(前端 10s 轮询一次请求)。"""
try:
status = device_discovery.get_status()
pending = device_discovery.list_pending()
pool_offline = device_discovery.list_pool_offline()
except Exception as e:
_log.warning(f"discovery 状态获取失败: {e}")
return jsonify({"ok": False, "error": str(e)[:120]}), 503
status["pending"] = pending
status["pool_offline"] = pool_offline
return jsonify({"ok": True, **status})
@bp.route("/api/devices/discovery/scan", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_discovery_scan():
"""手动触发一轮扫描(后台执行,约 5-30 秒)。"""
ok, result = device_discovery.scan_once(manual=True)
if not ok:
return jsonify({"ok": False, "error": result.get("error", "扫描失败")}), 409
if "error" in result:
return jsonify({"ok": False, "error": result["error"]}), 400
return jsonify({"ok": True, "msg": "扫描已启动(后台执行,约 5-30 秒)",
"result": result})
@bp.route("/api/devices/discovery/confirm", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_discovery_confirm():
"""确认连接:待连接 → 正式设备池 + 后台 adb connect + 采型号。
名称规则:**新设备必须填名称**;若指纹命中池中已有设备(同一台换了 IP),
则不需要名称——保留原记录与原名,并把分组/任务引用同步到新地址。
"""
data = request.json or {}
serial = (data.get("serial") or "").strip()
name = (data.get("name") or "").strip()
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
if ":" in serial:
try:
adb_connect_light(serial)
except Exception:
pass
fp = device_pool.read_fingerprint(serial) if ":" in serial else ""
matched = device_pool.find_by_fingerprint(fp) if fp else None
in_pool = serial in device_pool.list_configured()
if not in_pool and not matched and not name:
return jsonify({"ok": False, "error":
"请为该设备填写名称(名称是设备在平台里的唯一标识)"}), 400
if name and device_pool.name_taken(name, exclude_serial=serial if in_pool else ""):
return jsonify({"ok": False, "error": f"名称『{name}』已被其它设备占用,请换一个"}), 400
ok, msg, is_new = device_discovery.confirm_pending(serial, name=name, fingerprint=fp)
if not ok:
return jsonify({"ok": False, "error": msg}), 404
if ":" in serial:
def _connect():
try:
adb_connect(serial)
except Exception:
pass
try:
device_pool.refresh_info(serial)
except Exception:
pass
threading.Thread(target=_connect, daemon=True).start()
_log.info(f"设备池管理: 发现确认 {serial}")
return jsonify({"ok": True, "msg": msg, "is_new": is_new})
@bp.route("/api/devices/discovery/ignore", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_discovery_ignore():
"""忽略:从待连接池删除(下轮扫描可能再次发现)。"""
serial = (request.json or {}).get("serial", "").strip()
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
ok, msg = device_discovery.ignore_pending(serial)
if not ok:
return jsonify({"ok": False, "error": msg}), 404
return jsonify({"ok": True, "msg": msg})
@bp.route("/api/devices/discovery/reconnect", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_discovery_reconnect():
"""手动立即重连正式池断联设备(后台 adb connect + 采型号)。
日常无需手动——发现线程每轮(默认 60s)自动重连断联设备;
本端点用于不想等下一轮扫描时立即拉起。
"""
serial = (request.json or {}).get("serial", "").strip()
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
from core import device_pool
if serial not in device_pool.list_configured():
return jsonify({"ok": False, "error": "设备不在设备池中"}), 404
def _reconnect():
try:
if ":" in serial:
adb_connect(serial)
# 无论是否连上,采一次型号(模型失败静默)
try:
device_pool.refresh_model(serial)
except Exception:
pass
except Exception as e:
_log.warning(f"手动重连 {serial} 异常: {e}")
threading.Thread(target=_reconnect, daemon=True).start()
_log.info(f"设备池管理: 手动重连 {serial}")
return jsonify({"ok": True, "msg": "重连已启动(约 5-15 秒生效)"})
@bp.route("/api/devices/discovery/settings", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_discovery_settings():
"""保存发现配置(部分字段更新):{enabled?, subnets?, interval?, port?}。"""
data = request.json or {}
ok, msg = device_discovery.save_settings(
enabled=data.get("enabled"),
subnets=data.get("subnets"),
interval=data.get("interval"),
port=data.get("port"),
auto_claim=data.get("auto_claim"))
if not ok:
return jsonify({"ok": False, "error": msg}), 400
return jsonify({"ok": True, "msg": msg})
# ================== API:设备电量(采集/阈值/告警) ==================
# 电量本身通过 /api/status 随设备列表一起下发(大屏/监控同一份数据);
# 这里是它的配置与采集器状态。低电量告警走通知子系统(事件 device.battery.low)。
@bp.route("/api/devices/battery", methods=["GET"])
@perm_required(PERM_DEVICES)
def api_devices_battery():
"""电量采集器状态 + 当前配置(面板显示"上次采集 12:00 · 14/14 台")。"""
try:
return jsonify({"ok": True, **device_battery.get_status()})
except Exception as e:
_log.warning(f"电量状态获取失败: {e}")
return jsonify({"ok": False, "error": str(e)[:120]}), 503
@bp.route("/api/devices/battery/settings", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_battery_settings():
"""保存电量监控配置:{enabled?, low?, critical?, skip_charging?, interval?}。"""
data = request.json or {}
ok, msg, saved = device_battery.save_settings(data)
if not ok:
return jsonify({"ok": False, "error": msg}), 400
return jsonify({"ok": True, "msg": msg, "settings": saved})
@bp.route("/api/devices/battery/scan", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_battery_scan():
"""立即采集一次(后台执行,约 1-3 秒;看「上次采集」或大屏刷新确认)。"""
if not device_battery.scan_once():
return jsonify({"ok": False, "error": "上一轮采集还没跑完,稍等一下"}), 409
return jsonify({"ok": True, "msg": "采集已启动(约 1-3 秒)"})
# ================== API:账号台账(「账号」页) ==================
#
# 数据在 device_account 表(见 core/ledger.py);任务侧怎么用它取号见
# tasks/generic/task.py 的 `_resolve_cmp_value`(if_el 的 cmp_source)。
# ⚠ 台账的抖音号只做「条件判断」的比对值,**不做去重身份**(格式不同会静默失效)。
@bp.route("/api/ledger")
@perm_required(PERM_DEVICES)
def api_ledger_list():
"""账号列表 + 统计(?device=设备号 &q=搜索 &limit=)。"""
device_name = (request.args.get("device") or "").strip()
q = (request.args.get("q") or "").strip()
try:
limit = int(request.args.get("limit") or 500)
except ValueError:
limit = 500
try:
rows, stats = ledger.list_accounts(device_name, q, limit)
names = ledger.device_names()
except Exception as e:
_log.warning(f"读取账号台账失败: {e}")
return jsonify({"ok": False, "error": str(e)[:120]}), 503
return jsonify({"ok": True, "accounts": rows, "stats": stats, "device_names": names})
@bp.route("/api/ledger/by_device")
@perm_required(PERM_DEVICES)
def api_ledger_by_device():
"""按设备分组(设备池显示"这台几个号" + 点开看明细都走它,省一次请求)。"""
devices = ledger.by_device()
counts = {k: v["count"] for k, v in devices.items()}
return jsonify({"ok": True, "devices": devices, "counts": counts})
@bp.route("/api/ledger", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_ledger_add():
"""新增一条账号。"""
row, err = ledger.add(request.json or {})
if err:
return jsonify({"ok": False, "error": err}), 400
return jsonify({"ok": True, "msg": "已新增", "account": row})
@bp.route("/api/ledger/<acc_id>", methods=["PUT"])
@perm_required(PERM_DEVICES)
def api_ledger_update(acc_id):
"""改一条账号。"""
row, err = ledger.update(acc_id, request.json or {})
if err:
code = 404 if "不存在" in err else 400
return jsonify({"ok": False, "error": err}), code
return jsonify({"ok": True, "msg": "已保存", "account": row})
@bp.route("/api/ledger/<acc_id>", methods=["DELETE"])
@perm_required(PERM_DEVICES)
def api_ledger_delete(acc_id):
"""删一条账号(删掉之后任务就取不到这个号了)。"""
if not ledger.delete(acc_id):
return jsonify({"ok": False, "error": "记录不存在或删除失败"}), 404
return jsonify({"ok": True, "msg": "已删除"})
@bp.route("/api/ledger/import", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_ledger_import():
"""从表格粘贴文本批量导入。
请求:{"text": "设备号\\t手机号\\t…\\nA01\\t…", "delimiter": "\\t",
"mode": "skip|overwrite", "dry_run": true}
dry_run=true 只解析+判重、**一个字节都不写** —— 界面默认先预览再导入。
返回逐行结果(line/action/device_name/douyin_id/nickname/error/warns)+ 汇总。
"""
data = request.json or {}
text = data.get("text") or ""
if not text.strip():
return jsonify({"ok": False, "error": "粘贴的内容是空的"}), 400
delim = data.get("delimiter") or "\t"
if delim == "tab":
delim = "\t"
mode = data.get("mode") or "skip"
dry_run = bool(data.get("dry_run"))
try:
rows, errors = ledger.parse_paste(text, delim)
except Exception as e:
_log.warning(f"账号台账解析失败: {e}")
return jsonify({"ok": False, "error": f"解析失败: {str(e)[:120]}"}), 400
if not rows:
return jsonify({"ok": True, "counts": {"total": 0, "added": 0, "updated": 0,
"skipped": 0, "failed": 0},
"rows": [], "errors": errors, "mode": mode, "dry_run": dry_run,
"msg": "没有可导入的行"})
try:
result = ledger.import_rows(rows, mode=mode, dry_run=dry_run)
except Exception as e:
_log.warning(f"账号台账导入失败: {e}")
return jsonify({"ok": False, "error": str(e)[:120]}), 503
result["errors"] = errors
result["msg"] = ("预览完成(未写入)" if dry_run else "导入完成")
return jsonify({"ok": True, **result})
# ================== API:Tailscale 管理(仅管理员) ==================
# 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。
# 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。