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
|
||||
data/apks/*.apk
|
||||
data/*.migrated
|
||||
data/*.db-shm
|
||||
data/*.db-wal
|
||||
|
||||
# 数据库(含用户和任务数据,按需决定是否提交)
|
||||
|
||||
|
||||
@@ -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"<ApkFile {self.display_name}>"
|
||||
|
||||
|
||||
# 版本化 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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user