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:
2026-08-08 20:55:53 +08:00
parent 1fc0deaecf
commit b19caf3d55
3 changed files with 125 additions and 0 deletions
+2
View File
@@ -12,6 +12,8 @@ env/
logs/*.log
data/apks/*.apk
data/*.migrated
data/*.db-shm
data/*.db-wal
# 数据库(含用户和任务数据,按需决定是否提交)
+49
View File
@@ -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():
+74
View File
@@ -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)