用户反馈:① 元素抓取的选择设备列表显示的是 IP;② 所有 webhook 通知里都是 IP;
要都显示设备名。
- 通知链路统一补名字(调用点不用动):`core/notifier.py`
· 新增 `set_device_name_resolver()` + `fill_device_names()`,在 `notify()` 与
`build_message()`(预览/测试发送也走它)里把 `serial` 补成 `device_name`、
把 `serials`/`devices` 列表逐项换成名字。
· 补一处就全带名字了——标题主体、字段表、聚合样本认的都是 `device_name`。
· **做成"纯内存回调"是刻意的**:notify() 的硬红线是零 DB,不能为了取个名字去查库
(那等于在业务线程里加一次阻塞查询)。
· 降级规则:调用点自己传了 device_name 就用它的;查不到名字(没命名/不在池里)
保留原地址;解析器缺失或抛异常都只是降级,绝不影响发送。
- `core/device_pool.py`:维护 `serial → 名称` 内存快照——`init_app` 同步刷一次、
增删改名/迁址后各刷一次(改名立刻生效)、`device-names` 线程每 60s 兜底刷一次
(覆盖整库恢复这类进程外改动)。`name_of()` 只读内存,可在通知路径上安全调用。
- `web_server.py`:装配层接上 `notifier.set_device_name_resolver(device_pool.name_of)`。
- 元素抓取/测试此步骤的设备列表:`GET /api/uiauto/devices` 的 `name` 改用**平台名**,
前端 `editor.js` 新增共用的 `_devCard()`——名字做主标题(粗体),
`型号 · 地址` 作副标题。
· 池外设备**退回地址而不是 uiautodev 的 name**:实测那份 name 是设备 codename
(一柜子机器全叫 "earth"),拿它认设备等于没名字,地址至少唯一。
· 「测试此步骤」的设备列表原来连状态角标都没有,一并统一成同一个卡片。
- 顺带修一个**既有 bug(不是本次需求)**:`/locate` 设备端定位页从 ce47a5b 那次
web 蓝图拆分起就一直 500——拆分时漏掉了 `render_template_string` 与
`markupsafe.escape as _esc` 两个 import。后者不只是缺个名字:定位页把 query 里的
serial 拼进 HTML,而 `render_template_string` 的模板名是 "<template>"、
**不会自动转义**,所以那还是个反射 XSS。已显式转义(已用 `<img onerror>` 验证)。
自测:device_pool 快照/改名即时生效、fill_device_names 全分支(含列表、
未命名保留地址、不在池保留地址、解析器缺失/抛异常降级)、真消息渲染断言
**通篇不含 IP**;GET 路由冒烟 54 个 0 个 500;前端语法 + 卡片渲染截图核对。
359 lines
15 KiB
Python
359 lines
15 KiB
Python
"""设备自动化管理后台入口:Flask app 装配 + 启动。
|
||
|
||
路由按功能域拆分到 web/ 蓝图包(auth/monitor/tasks/admin/tools/devices/apks/tailscale),
|
||
本文件只负责:app 创建、数据库初始化、共享对象装配、蓝图注册、uiautodev 生命周期、启动。
|
||
"""
|
||
import os
|
||
import time
|
||
import atexit
|
||
import secrets
|
||
import socket
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
|
||
from flask import Flask
|
||
from flask_login import LoginManager
|
||
|
||
from core.task_manager import TaskManager
|
||
from core.apk_manager import ApkManager
|
||
from core.logger import get_logger
|
||
from core.models import db, init_db, User
|
||
from core import uiauto_helper
|
||
from config import WEB_HOST, WEB_PORT
|
||
|
||
_log = get_logger("web")
|
||
|
||
|
||
app = Flask(__name__)
|
||
# 会话密钥:优先 .env 的 WEB_SECRET_KEY;未配置则随机生成(重启后登录态失效,生产务必配置固定值)
|
||
_web_secret = os.environ.get("WEB_SECRET_KEY") or secrets.token_hex(32)
|
||
if not os.environ.get("WEB_SECRET_KEY"):
|
||
_log.warning("未配置 WEB_SECRET_KEY,已随机生成会话密钥(重启后登录态失效)")
|
||
app.config["SECRET_KEY"] = _web_secret
|
||
# debug=False 时模板默认不自动重载,开发期改 HTML 需重启才生效;这里显式开启
|
||
app.config["TEMPLATES_AUTO_RELOAD"] = True
|
||
# 数据库目标与连接参数统一由 core/db_config 装配(.env 的 DEPLOY_ENV / DB_* 决定)。
|
||
# 这里就把配置校验做完:连不上、环境与库名不匹配 → 直接拒绝启动,不要带着错配置跑起来。
|
||
from core import db_config
|
||
try:
|
||
_DB_URI = db_config.build_db_uri()
|
||
db_config.check_connection(_DB_URI)
|
||
except db_config.DBConfigError as _e:
|
||
_log.error("数据库配置校验失败,拒绝启动:\n%s", _e)
|
||
raise SystemExit(2)
|
||
app.config["SQLALCHEMY_DATABASE_URI"] = _DB_URI
|
||
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = db_config.engine_options(_DB_URI)
|
||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||
|
||
login_manager = LoginManager(app)
|
||
login_manager.login_view = "auth.login" # 蓝图化后路由前缀 auth
|
||
|
||
|
||
def _consume_pending_restore():
|
||
"""消费「待生效的备份恢复」(若存在 data/restore_pending/users.db)。
|
||
|
||
必须在 init_db 之后调用:现在是「单事务整库替换」而不是换文件,
|
||
要先有建好的表和 app context。导入仍然要重启才生效——因为 TaskManager /
|
||
device_pool 把任务与设备缓存在内存里,整库替换后内存副本全部失效。
|
||
"""
|
||
try:
|
||
from core.system_backup import consume_pending_restore
|
||
with app.app_context():
|
||
consume_pending_restore()
|
||
except Exception as _e:
|
||
_log.error(f"消费备份恢复任务异常(不影响启动): {_e}")
|
||
|
||
# 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB)
|
||
init_db(app)
|
||
# 通知模块:**必须早于 _consume_pending_restore()**——启动期就要能发「备份已恢复」
|
||
# 这类通知(notify 在未就绪时静默丢弃,晚初始化就丢事件了)
|
||
try:
|
||
from core import notifier as _notifier
|
||
_notifier.init_app(app)
|
||
except Exception as _e: # 通知出问题不影响启动
|
||
_log.warning(f"通知模块初始化失败(不影响启动): {_e}")
|
||
# 任务步骤明细:起一个专用写线程,任务线程只入队(见 core/step_log.py)
|
||
try:
|
||
from core import step_log as _step_log
|
||
_step_log.init_app(app)
|
||
except Exception as _e:
|
||
_log.warning(f"步骤明细模块初始化失败(不影响启动): {_e}")
|
||
# 消费「待生效的备份恢复」——必须在 init_db 之后(表已建好,且现在是事务替换而非换文件)
|
||
_consume_pending_restore()
|
||
# 库环境标签校验 + 启动横幅:每次启动都明确写出「现在连的是哪个库」,
|
||
# 避免在不知情的情况下对着生产库操作(app_meta 表要存在,所以放在 init_db 之后)
|
||
try:
|
||
with app.app_context(): # db.engine / app_meta 读取都需要 app context
|
||
db_config.verify_deployment_label()
|
||
db_config.print_banner()
|
||
# 页面顶部环境徽标(防混库第 4 层:点开任何页面都能看出连的是哪个库)
|
||
app.jinja_env.globals["db_env"] = db_config.env_badge()
|
||
except db_config.DBConfigError as _e:
|
||
_log.error("数据库环境校验失败,拒绝启动:\n%s", _e)
|
||
raise SystemExit(2)
|
||
# 设备池(本地清单 + adb 在线状态):任务调度的数据源
|
||
from core import device_pool
|
||
device_pool.init_app(app)
|
||
# 设备自动发现:定时扫描网段 → 待连接池(确认后才入池)
|
||
from core import device_discovery
|
||
device_discovery.init_app(app)
|
||
mgr = TaskManager(app=app)
|
||
# 设备换地址(认领/迁址)后同步分组与任务的引用:它们的"内存副本"在 TaskManager 里
|
||
# (调度用内存对象),device_pool 只改库不改内存不生效,所以在此注册回调打通
|
||
device_pool.set_move_hook(mgr.sync_device_serial)
|
||
# 通知里要显示设备名(A08)而不是地址(IP):notifier 只读 device_pool 的**内存**名称
|
||
# 快照,不查库(notify 的红线是零 DB)。快照在 device_pool.init_app 里已刷过一次。
|
||
_notifier.set_device_name_resolver(device_pool.name_of)
|
||
apk_mgr = ApkManager(app=app)
|
||
|
||
# Web 层按模块拆分(web/ 蓝图包):认证/监控/任务/管理/工具/设备池/APK/Tailscale
|
||
from web import context, register_blueprints
|
||
from web.auth import _csrf_protect, _csrf_token
|
||
context.init(mgr, apk_mgr, device_pool)
|
||
register_blueprints(app)
|
||
|
||
def _purge_step_log():
|
||
"""清理超期的任务步骤明细(启动时 + 每日 04:13)。
|
||
|
||
app context 由本函数自建:APScheduler 的作业跑在调度线程里,没有请求上下文。
|
||
"""
|
||
try:
|
||
from core.step_log import purge_old
|
||
with app.app_context():
|
||
purge_old()
|
||
except Exception as e:
|
||
_log.warning(f"步骤明细清理失败(不影响主服务): {e}")
|
||
|
||
|
||
def _purge_step_log_async():
|
||
"""启动时后台清理一次(不阻塞启动;服务若频繁重启,定时任务可能一直轮不到)。"""
|
||
threading.Thread(target=_purge_step_log, daemon=True).start()
|
||
|
||
|
||
# 经验库每日 AI 巡检(凌晨 03:47):评审标记疑似问题经验,删除只走人工确认。
|
||
# 巡检线程在 run_experience_audit 内自建 app context,不依赖这里。
|
||
try:
|
||
from apscheduler.schedulers.background import BackgroundScheduler
|
||
from apscheduler.triggers.cron import CronTrigger
|
||
from web.agent_api import run_experience_audit
|
||
_audit_sched = BackgroundScheduler(timezone="Asia/Shanghai")
|
||
_audit_sched.add_job(run_experience_audit, CronTrigger(hour=3, minute=47))
|
||
# 步骤明细清理:超过保留期(core/step_log.KEEP_DAYS)的记录每天删一次。
|
||
# 不清理的话这张表是唯一无界增长的表,会一路顶大备份包。
|
||
_audit_sched.add_job(_purge_step_log, CronTrigger(hour=4, minute=13))
|
||
_audit_sched.start()
|
||
_log.info("经验库每日巡检已注册(03:47 Asia/Shanghai)")
|
||
except Exception as e:
|
||
_log.warning(f"经验库巡检调度注册失败(不影响主服务): {e}")
|
||
|
||
|
||
@login_manager.user_loader
|
||
def load_user(user_id):
|
||
return User.query.get(int(user_id))
|
||
|
||
|
||
# ================== API:健康检查(无需登录,供探活) ==================
|
||
|
||
|
||
_UIAUTO_PID_FILE = os.path.join(
|
||
os.path.dirname(os.path.abspath(__file__)), "data", "uiauto.pid")
|
||
_uiauto_proc = None
|
||
|
||
|
||
def _pid_cmdline_has(pid, keyword):
|
||
"""校验 PID 对应的进程命令行含 keyword(容器重启后 PID 复用会误杀,
|
||
杀前必须确认目标真是残留的 uiautodev,而不是 MCP/web 等其它进程)。"""
|
||
try:
|
||
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
||
cmd = f.read().replace(b"\x00", b" ").decode(errors="replace")
|
||
return keyword in cmd
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _kill_stale_uiauto():
|
||
"""清理上次 web_server 残留的 uiautodev 进程(读取 PID 文件)。
|
||
|
||
容器重启后 PID namespace 重建、PID 会被复用(MCP/其它进程可能拿到与
|
||
uiauto.pid 相同的值),因此 kill 前先校验命令行确为 uiautodev,防止
|
||
误杀同容器内的 MCP server 等进程导致连锁崩溃。
|
||
"""
|
||
if not os.path.exists(_UIAUTO_PID_FILE):
|
||
return
|
||
try:
|
||
with open(_UIAUTO_PID_FILE) as f:
|
||
pid = int(f.read().strip())
|
||
if pid > 0 and pid != os.getpid() and _pid_cmdline_has(pid, "uiautodev"):
|
||
try:
|
||
os.kill(pid, 0) # 探测进程是否存活
|
||
os.kill(pid, 15) # 终止残留
|
||
_log.warning(f"已清理残留的 uiautodev 进程 (PID={pid})")
|
||
except ProcessLookupError:
|
||
pass
|
||
except PermissionError:
|
||
_log.warning(f"无权限清理残留 uiautodev (PID={pid}),忽略")
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
try:
|
||
os.remove(_UIAUTO_PID_FILE)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _ensure_uiauto_running():
|
||
"""确保 uiautodev 本地服务在运行(元素抓取功能依赖,端口 20242)。
|
||
|
||
已在运行则跳过;未运行则用子进程启动 `uiautodev server --no-browser`。
|
||
启动失败只记日志,不影响主服务。
|
||
"""
|
||
global _uiauto_proc
|
||
_kill_stale_uiauto()
|
||
if uiauto_helper.is_running():
|
||
_log.info("uiauto2 服务已在运行 (端口 20242)")
|
||
return
|
||
try:
|
||
_log.info("正在自动启动 uiautodev 服务 (端口 20242)...")
|
||
kwargs = dict(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
if sys.platform == "win32":
|
||
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||
_uiauto_proc = subprocess.Popen(
|
||
[sys.executable, "-m", "uiautodev", "server", "--no-browser"],
|
||
**kwargs,
|
||
)
|
||
_log.info(f"uiautodev 服务已启动 (PID={_uiauto_proc.pid})")
|
||
try:
|
||
with open(_UIAUTO_PID_FILE, "w") as f:
|
||
f.write(str(_uiauto_proc.pid))
|
||
except OSError:
|
||
pass
|
||
atexit.register(_stop_uiauto)
|
||
except FileNotFoundError:
|
||
_log.warning("uiautodev 未安装,元素抓取功能不可用。请运行: pip install uiautodev")
|
||
except Exception as e:
|
||
_log.warning(f"自动启动 uiautodev 失败: {e}(元素抓取功能不可用)")
|
||
|
||
|
||
def _stop_uiauto():
|
||
"""停止自动启动的 uiautodev 子进程。"""
|
||
global _uiauto_proc
|
||
try:
|
||
if os.path.exists(_UIAUTO_PID_FILE):
|
||
os.remove(_UIAUTO_PID_FILE)
|
||
except OSError:
|
||
pass
|
||
if not _uiauto_proc:
|
||
return
|
||
try:
|
||
_uiauto_proc.terminate()
|
||
_uiauto_proc.wait(timeout=5)
|
||
_log.info("uiautodev 服务已停止")
|
||
except Exception:
|
||
try:
|
||
_uiauto_proc.kill()
|
||
except Exception:
|
||
pass
|
||
_uiauto_proc = None
|
||
|
||
|
||
def _preconnect_pool_devices():
|
||
"""容器/服务重启后 adb server 重启,设备全部掉线,逐个重连要几分钟;
|
||
启动时后台并发 connect 设备池里的网络设备(IP:5555),把恢复时间降到秒级。
|
||
|
||
USB 设备无需 connect(adb server 自动重枚举),只连网络设备。
|
||
预连接幂等:已连接的设备返回 already connected,无副作用。
|
||
"""
|
||
def _run():
|
||
try:
|
||
from core import device_pool
|
||
from core.adb_helper import _adb
|
||
serials = [s for s in device_pool.list_configured() if ":" in s]
|
||
if not serials:
|
||
return
|
||
_log.info(f"预连接设备池 {len(serials)} 台网络设备(重启后加速恢复)...")
|
||
import concurrent.futures
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
|
||
list(ex.map(lambda s: _adb("connect", s), serials))
|
||
out = _adb("devices")
|
||
online = sum(1 for ln in (out or "").splitlines()[1:]
|
||
if len(ln.split()) >= 2 and ln.split()[1] == "device")
|
||
_log.info(f"设备池预连接完成: {online} 台在线")
|
||
except Exception as e:
|
||
_log.warning(f"设备池预连接失败: {e}")
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
|
||
def _run_server(host, port):
|
||
"""启动 Flask,自动处理端口冲突和权限问题。
|
||
|
||
常见失败原因:
|
||
WinError 10013 — 端口在 Windows 动态端口范围内被出站连接占用,
|
||
或非管理员绑定 0.0.0.0。前者换端口,后者降级 127.0.0.1。
|
||
WinError 10048 — 端口已被其他进程监听。换端口重试。
|
||
策略:原端口失败 → 试 127.0.0.1:原端口 → 试 127.0.0.1:原端口+1..+5
|
||
"""
|
||
import socket
|
||
# 先探测原端口是否可用(避免 flask 报错打日志难看)
|
||
candidates = [(host, port)]
|
||
if host == "0.0.0.0":
|
||
candidates.append(("127.0.0.1", port))
|
||
for i in range(1, 6):
|
||
candidates.append(("127.0.0.1", port + i))
|
||
|
||
for h, p in candidates:
|
||
try:
|
||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||
s.bind((h, p))
|
||
# 探测成功,启动 flask
|
||
if (h, p) != (host, port):
|
||
_log.warning(f"原端口 {host}:{port} 不可用,改用 {h}:{p}")
|
||
_log.info(f"启动服务: http://localhost:{p}/")
|
||
app.run(host=h, port=p, debug=False, threaded=True)
|
||
return
|
||
except OSError as e:
|
||
_log.warning(f"绑定 {h}:{p} 失败: {e}")
|
||
# 全部失败
|
||
raise OSError(f"无法绑定任何候选端口({host}:{port} 及 127.0.0.1:{port}~{port+5})")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
from config import WEB_HOST, WEB_PORT
|
||
# 自动启动 uiautodev 服务(元素抓取功能依赖,端口 20242)
|
||
_ensure_uiauto_running()
|
||
# 预连接设备池:adb server 重启后设备全掉线,后台并发重连加速恢复
|
||
_preconnect_pool_devices()
|
||
# 步骤明细清理:服务频繁重启时定时任务可能一直轮不到,启动也清一次
|
||
_purge_step_log_async()
|
||
_log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)")
|
||
# 启动通知:放在 __main__ 里是刻意的——以 WSGI 方式 import 本模块只有"半个启动"
|
||
# (阶段 A~F 在 import 期就跑完了),不该发"服务已启动"。
|
||
try:
|
||
from core import notifier as _notifier
|
||
with app.app_context():
|
||
_devs = len(device_pool.list_configured())
|
||
_notifier.notify("service.started", env=db_config.env_badge().get("env", ""),
|
||
db_target=db_config.describe_target(),
|
||
device_count=_devs, job_count=len(mgr.jobs))
|
||
except Exception as _e:
|
||
_log.warning(f"发送启动通知失败(不影响启动): {_e}")
|
||
_t0 = time.time()
|
||
try:
|
||
_run_server(WEB_HOST, WEB_PORT)
|
||
finally:
|
||
try:
|
||
from core import notifier as _notifier
|
||
_notifier.notify("service.stopping", uptime_s=int(time.time() - _t0))
|
||
_notifier.shutdown(timeout=3.0)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
from core import step_log as _step_log
|
||
_step_log.shutdown(timeout=3.0) # 排空队列后再退
|
||
except Exception:
|
||
pass
|
||
mgr.shutdown()
|
||
device_discovery.shutdown()
|
||
_stop_uiauto()
|