diff --git a/.gitignore b/.gitignore index 5eccdc9..efce3cd 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ env/ logs/*.log data/apks/*.apk data/*.migrated +data/*.db-shm +data/*.db-wal # 数据库(含用户和任务数据,按需决定是否提交) diff --git a/core/models.py b/core/models.py index 0885d79..4ef2947 100644 --- a/core/models.py +++ b/core/models.py @@ -20,6 +20,8 @@ import hashlib from flask_sqlalchemy import SQLAlchemy from flask_login import UserMixin +from sqlalchemy import event, text +from sqlalchemy.engine import Engine from core.logger import get_logger @@ -28,6 +30,19 @@ _log = get_logger("core.models") db = SQLAlchemy() +@event.listens_for(Engine, "connect") +def _sqlite_pragma(dbapi_connection, connection_record): + """SQLite 并发写优化:WAL 模式 + 忙等待超时 + 降同步级别。 + + 多 worker 后台线程同时写库(任务参数/分组)时,避免 database is locked。 + """ + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA busy_timeout=5000") + cursor.execute("PRAGMA synchronous=NORMAL") + cursor.close() + + class User(UserMixin, db.Model): """后台用户。""" id = db.Column(db.Integer, primary_key=True) @@ -164,6 +179,13 @@ class ApkFile(db.Model): return f"" +# 版本化 schema 迁移:新增结构变更时在此追加 (版本号, 说明, SQL) +# 版本号单调递增,只执行比当前 schema_version 新的迁移。 +SCHEMA_MIGRATIONS = [ + # (1, "初始 schema(由 create_all 建立,版本标记从 1 开始)", None), +] + + def init_db(app): """在 Flask app context 里初始化数据库 + 创建默认管理员。 @@ -172,10 +194,37 @@ def init_db(app): db.init_app(app) with app.app_context(): db.create_all() + _migrate_schema() _ensure_default_admin() _migrate_old_json() +def _migrate_schema(): + """按 SCHEMA_MIGRATIONS 顺序执行版本化迁移,记录当前 schema_version。 + + create_all 只负责首次建表;结构变更必须走迁移,避免改了模型后老库对不上。 + """ + try: + db.session.execute(text( + "CREATE TABLE IF NOT EXISTS app_meta (key TEXT PRIMARY KEY, value TEXT)")) + db.session.commit() + cur = db.session.execute( + text("SELECT value FROM app_meta WHERE key='schema_version'")).scalar() + current = int(cur) if cur else 0 + for version, desc, sql in SCHEMA_MIGRATIONS: + if version <= current: + continue + if sql: + db.session.execute(text(sql)) + db.session.execute( + text("INSERT OR REPLACE INTO app_meta(key,value) VALUES('schema_version',:v)"), + {"v": str(version)}) + db.session.commit() + _log.info(f"schema 迁移到版本 {version}: {desc}") + except Exception as e: + _log.error(f"schema 迁移失败(不阻塞启动): {e}") + + def _ensure_default_admin(): """首次启动创建默认管理员 admin/admin123。""" if not User.query.filter_by(username="admin").first(): diff --git a/web_server.py b/web_server.py index 16d7fcd..5cba703 100644 --- a/web_server.py +++ b/web_server.py @@ -89,6 +89,30 @@ def logout(): return redirect(url_for("login")) +# ================== API:健康检查(无需登录,供探活) ================== +@app.route("/api/health") +def api_health(): + """轻量健康检查:返回进程/设备/任务摘要,不暴露敏感信息,供运维探活。""" + try: + devices, err = mgr.get_status() + devs = devices or [] + return jsonify({ + "ok": True, + "status": "up", + "time": time.time(), + "device_total": len(devs), + "device_online": sum(1 for d in devs if d.get("present")), + "device_running": sum(1 for d in devs + if d.get("worker_status") in ("running", "connecting")), + "device_error": sum(1 for d in devs + if d.get("worker_status") in ("error", "failed")), + "occupied": sum(1 for d in devs if d.get("stf_occupied")), + "jobs": len(mgr.jobs), + }) + except Exception as e: + return jsonify({"ok": False, "status": "down", "error": str(e)}), 500 + + # ================== API:状态(监控大屏用)================== @app.route("/api/status") @login_required @@ -550,6 +574,35 @@ def api_apks_install_status(): # ================== uiautodev 自动启动 ================== _uiauto_proc = None +# PID 文件:web_server 异常退出(kill -9/崩溃)时 uiautodev 成孤儿残留, +# 用 PID 文件在下次启动时识别并清理,避免多次重启后堆积进程。 +_UIAUTO_PID_FILE = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "data", "uiauto.pid") + + +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(): @@ -559,6 +612,7 @@ def _ensure_uiauto_running(): 启动失败只记日志,不影响主服务。 """ global _uiauto_proc + _kill_stale_uiauto() if uiauto_helper.is_running(): _log.info("uiauto2 服务已在运行 (端口 20242)") return @@ -572,6 +626,11 @@ def _ensure_uiauto_running(): **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") @@ -582,6 +641,11 @@ def _ensure_uiauto_running(): 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: @@ -634,6 +698,16 @@ if __name__ == "__main__": from config import WEB_HOST, WEB_PORT # 自动启动 uiautodev 服务(元素抓取功能依赖,端口 20242) _ensure_uiauto_running() + # 启动时提示可能残留的 STF 占用(上次异常退出/强杀遗留)。 + # 不自动释放:本机与生产可能是多实例共用一个 STF 账户,误放会中断另一实例的任务。 + try: + _mine = stf.list_my_devices() + if _mine: + _serials = ", ".join(d["serial"] for d in _mine) + _log.warning(f"启动时发现 {len(_mine)} 台设备仍被本账户占用(可能异常退出残留):{_serials}。" + f"确认无其他实例在跑时可用\"强制释放占用\"清理") + except Exception: + pass _log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)") try: _run_server(WEB_HOST, WEB_PORT)