Files
auto_control/web_server.py
T
butubb f22263ab45 feat: 数据库连接层改造——库目标由 .env 装配 + 环境防呆(迁 MySQL 第一步)
为把数据库从单文件 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 后可回退。
2026-09-13 10:27:14 +08:00

287 lines
12 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.
"""设备自动化管理后台入口: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()