refactor: 架构优化-后端(SQLite WAL/迁移机制/健康检查/uiautodev孤儿清理/残留提示)
- SQLite 开 WAL + busy_timeout + synchronous=NORMAL,避免多 worker 并发写锁 - 新增版本化 schema 迁移机制(SCHEMA_MIGRATIONS + app_meta 表记录版本) - 新增 /api/health 健康检查(无需登录,返回设备/任务/错误摘要) - uiautodev 用 PID 文件追踪,启动时清理上次异常退出残留的孤儿进程 - 启动时提示残留的 STF 占用(不自动释放,避免多实例误伤)
This commit is contained in:
@@ -12,6 +12,8 @@ env/
|
|||||||
logs/*.log
|
logs/*.log
|
||||||
data/apks/*.apk
|
data/apks/*.apk
|
||||||
data/*.migrated
|
data/*.migrated
|
||||||
|
data/*.db-shm
|
||||||
|
data/*.db-wal
|
||||||
|
|
||||||
# 数据库(含用户和任务数据,按需决定是否提交)
|
# 数据库(含用户和任务数据,按需决定是否提交)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import hashlib
|
|||||||
|
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from flask_login import UserMixin
|
from flask_login import UserMixin
|
||||||
|
from sqlalchemy import event, text
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
|
||||||
from core.logger import get_logger
|
from core.logger import get_logger
|
||||||
|
|
||||||
@@ -28,6 +30,19 @@ _log = get_logger("core.models")
|
|||||||
db = SQLAlchemy()
|
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):
|
class User(UserMixin, db.Model):
|
||||||
"""后台用户。"""
|
"""后台用户。"""
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
@@ -164,6 +179,13 @@ class ApkFile(db.Model):
|
|||||||
return f"<ApkFile {self.display_name}>"
|
return f"<ApkFile {self.display_name}>"
|
||||||
|
|
||||||
|
|
||||||
|
# 版本化 schema 迁移:新增结构变更时在此追加 (版本号, 说明, SQL)
|
||||||
|
# 版本号单调递增,只执行比当前 schema_version 新的迁移。
|
||||||
|
SCHEMA_MIGRATIONS = [
|
||||||
|
# (1, "初始 schema(由 create_all 建立,版本标记从 1 开始)", None),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def init_db(app):
|
def init_db(app):
|
||||||
"""在 Flask app context 里初始化数据库 + 创建默认管理员。
|
"""在 Flask app context 里初始化数据库 + 创建默认管理员。
|
||||||
|
|
||||||
@@ -172,10 +194,37 @@ def init_db(app):
|
|||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
_migrate_schema()
|
||||||
_ensure_default_admin()
|
_ensure_default_admin()
|
||||||
_migrate_old_json()
|
_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():
|
def _ensure_default_admin():
|
||||||
"""首次启动创建默认管理员 admin/admin123。"""
|
"""首次启动创建默认管理员 admin/admin123。"""
|
||||||
if not User.query.filter_by(username="admin").first():
|
if not User.query.filter_by(username="admin").first():
|
||||||
|
|||||||
@@ -89,6 +89,30 @@ def logout():
|
|||||||
return redirect(url_for("login"))
|
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:状态(监控大屏用)==================
|
# ================== API:状态(监控大屏用)==================
|
||||||
@app.route("/api/status")
|
@app.route("/api/status")
|
||||||
@login_required
|
@login_required
|
||||||
@@ -550,6 +574,35 @@ def api_apks_install_status():
|
|||||||
|
|
||||||
# ================== uiautodev 自动启动 ==================
|
# ================== uiautodev 自动启动 ==================
|
||||||
_uiauto_proc = None
|
_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():
|
def _ensure_uiauto_running():
|
||||||
@@ -559,6 +612,7 @@ def _ensure_uiauto_running():
|
|||||||
启动失败只记日志,不影响主服务。
|
启动失败只记日志,不影响主服务。
|
||||||
"""
|
"""
|
||||||
global _uiauto_proc
|
global _uiauto_proc
|
||||||
|
_kill_stale_uiauto()
|
||||||
if uiauto_helper.is_running():
|
if uiauto_helper.is_running():
|
||||||
_log.info("uiauto2 服务已在运行 (端口 20242)")
|
_log.info("uiauto2 服务已在运行 (端口 20242)")
|
||||||
return
|
return
|
||||||
@@ -572,6 +626,11 @@ def _ensure_uiauto_running():
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
_log.info(f"uiautodev 服务已启动 (PID={_uiauto_proc.pid})")
|
_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)
|
atexit.register(_stop_uiauto)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
_log.warning("uiautodev 未安装,元素抓取功能不可用。请运行: pip install uiautodev")
|
_log.warning("uiautodev 未安装,元素抓取功能不可用。请运行: pip install uiautodev")
|
||||||
@@ -582,6 +641,11 @@ def _ensure_uiauto_running():
|
|||||||
def _stop_uiauto():
|
def _stop_uiauto():
|
||||||
"""停止自动启动的 uiautodev 子进程。"""
|
"""停止自动启动的 uiautodev 子进程。"""
|
||||||
global _uiauto_proc
|
global _uiauto_proc
|
||||||
|
try:
|
||||||
|
if os.path.exists(_UIAUTO_PID_FILE):
|
||||||
|
os.remove(_UIAUTO_PID_FILE)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
if not _uiauto_proc:
|
if not _uiauto_proc:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -634,6 +698,16 @@ if __name__ == "__main__":
|
|||||||
from config import WEB_HOST, WEB_PORT
|
from config import WEB_HOST, WEB_PORT
|
||||||
# 自动启动 uiautodev 服务(元素抓取功能依赖,端口 20242)
|
# 自动启动 uiautodev 服务(元素抓取功能依赖,端口 20242)
|
||||||
_ensure_uiauto_running()
|
_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)")
|
_log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)")
|
||||||
try:
|
try:
|
||||||
_run_server(WEB_HOST, WEB_PORT)
|
_run_server(WEB_HOST, WEB_PORT)
|
||||||
|
|||||||
Reference in New Issue
Block a user