"""设备自动化管理后台入口: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 app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join( os.path.dirname(os.path.abspath(__file__)), "data", "users.db") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False login_manager = LoginManager(app) login_manager.login_view = "auth.login" # 蓝图化后路由前缀 auth # 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB) init_db(app) # 设备池(本地清单 + 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) 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) # 经验库每日 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)) _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 _kill_stale_uiauto(): """清理上次 web_server 残留的 uiautodev 进程(读取 PID 文件)。""" 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(): 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() _log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)") try: _run_server(WEB_HOST, WEB_PORT) finally: mgr.shutdown() device_discovery.shutdown() _stop_uiauto()