为把数据库从单文件 SQLite 迁到 MySQL 5.7 铺路。本期不改后端: DB_HOST 为空时仍走 SQLite,本地开发无感。 - config.py: 新增 DEPLOY_ENV(默认 dev)与 DB_HOST/PORT/USER/PASSWORD/NAME/ CHARSET/COLLATION、DATABASE_URL、两个逃生阀(DB_ALLOW_ENV_MISMATCH / DB_ALLOW_SQLITE_FALLBACK) - core/db_config.py(新增): URI 组装;按方言分叉的引擎参数(utf8mb4、 pool_pre_ping、pool_recycle=1800、READ COMMITTED、STRICT_TRANS_TABLES); 连接探活;app_meta 方言中立读写(MySQL 里 key 是保留字,需反引号) - 防混库三层: ①库名与环境绑定(dev→auto_control_dev / prod→auto_control) ②库标签 app_meta.deployment_env 与 .env 声明比对 ③启动横幅打印当前库 (生产用 WARNING 级)。不符直接拒绝启动并说明两边分别是什么 - web_server.py: 硬编码 sqlite URI → db_config;配置错在装配期就 exit 2; init_db 之后跑库标签校验 + 横幅 - core/models.py: PRAGMA 监听器加 sqlite 类型守卫——它挂在 Engine 基类上, MySQL 连接执行 PRAGMA 会直接导致建连失败 - requirements.txt 加 PyMySQL;scripts/start.sh 依赖守卫加 pymysql, 并在 exec 前打印 DEPLOY_ENV/DB_NAME/DB_HOST - .env.example 新增「数据库」段;DEVELOPMENT.md §4.1/4.2、DEPLOY.md §2.2 同步 验证: 用 DATABASE_URL 指向 users.db 的一致快照副本跑通主要只读接口 (health/devices/jobs/pool/groups/discovery/summary 全 200,app_meta 读写正常); DEPLOY_ENV=prod 且无 DB_HOST 时退出码 2;开 DB_ALLOW_SQLITE_FALLBACK 后可回退。
287 lines
12 KiB
Python
287 lines
12 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
|
||
|
||
# 消费「待生效的备份恢复」:若存在 data/restore_pending/users.db,在 engine 首次
|
||
# 打开数据库之前换位。导入必须重启生效——TaskManager 启动时读库入内存,
|
||
# Windows 无法热替换正被持有的库文件。
|
||
try:
|
||
from core.system_backup import consume_pending_restore
|
||
consume_pending_restore()
|
||
except Exception as _e:
|
||
_log.error(f"消费备份恢复任务异常(不影响启动): {_e}")
|
||
|
||
# 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB)
|
||
init_db(app)
|
||
# 库环境标签校验 + 启动横幅:每次启动都明确写出「现在连的是哪个库」,
|
||
# 避免在不知情的情况下对着生产库操作(app_meta 表要存在,所以放在 init_db 之后)
|
||
try:
|
||
with app.app_context(): # db.engine / app_meta 读取都需要 app context
|
||
db_config.verify_deployment_label()
|
||
db_config.print_banner()
|
||
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)
|
||
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 _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()
|
||
_log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)")
|
||
try:
|
||
_run_server(WEB_HOST, WEB_PORT)
|
||
finally:
|
||
mgr.shutdown()
|
||
device_discovery.shutdown()
|
||
_stop_uiauto()
|