一、平台侧(账号 → 发布计划页) - 新表 video_plan(schema v9→v10):账号×发布日期×编号 → 素材 + 标题 + 发布状态 + 分享链接; 状态机 pending/ready/pushing/publishing/done/failed/unknown/skipped(**failed 与 unknown 必须分开**: 推送阶段的失败可安全重试;碰过抖音之后的岔子只能算"结果未知",绝不自动重发) - 素材上传:文件名 `手机号_日期_编号`(编号可省)解析配对;标题 txt `标题内容_手机号_日期_编号`; 内容寻址落盘 data/videos/YYYY-MM/(sha1 分块算,同名不存两份),**不进整库备份**但进 manifest 反查 - 新蓝图 web/video_plan_api.py:上传/时间线/统计/单条增删改/推送到手机/标记结果/裁决/链接导出 CSV/ 任务列表与一键新建、**就地编辑**(GET/PUT /tasks/<id>)、**一键推送**(POST /push_all,按设备分组、设备内串行) - 账号页拆子分栏(台账 / 发布计划)+ static/admin/release.js;清理 job(04:41 僵尸回收+过期行、04:47 素材文件) - 上传体积:MAX_CONTENT_LENGTH(默认 2GiB)+ 413 JSON + nginx client_max_body_size(修现有 APK 上传隐患) 二、任务侧(平台推素材,抖音流程你自己写) - 新步骤 push_release「推送发布视频」:原子占位 → adb push → **touch 改成"现在"** → 清旧目录同名副本 → 触发扫描并**按路径**校验相册索引 → 标题写进剪贴板;默认目录 /sdcard/DCIM/Camera - 新步骤 mark_release「标记发布结果」:回写 done/failed/unknown,成功时抓作品分享链接、删手机素材 - input_text 支持 text_source=release_title(自动取计划标题 + 回读校验); if_el 的候选值来源新增 release(**本机当前发布计划**的抖音号/昵称,发布前校验"登的是不是要发的号") - build_release_steps 骨架 15 步:⓪ 亮屏 → ① 打开抖音(等首页) → ② 点「我」→ ③ 等抖音号出现 → ④ 条件判断(账号) → then ⑤ 推送 ⑥⑦⑧⑨⑩⑪⑫ 抖音点击/填标题 → ⑬ 标记 / else 发通知跳过 三、修(推送这一路的检测机制) - **uiautomator2 3.x 的 d.shell() 返回 ShellResponse(tuple 子类)不是 str**:`'x' in resp` 恒 False、 `.strip()` 不存在 → "推上去的文件大小不对"每次都判失败(文件其实推上去了)、相册校验永远报没进、 删除确认永远判没删掉。新增 publish_flow._sh() 统一取 .output;大小改成解析 ls -l 的大小列 - **adb push 保留本地 mtime** → 推 3 天前上传的素材在按时间排序的相册里排不到最前, "点第一个 = 刚推的那个"不成立 → 推完 touch - 相册校验**按路径**比(MediaStore 的 _data 会把目录小写、/storage/emulated/0 ≡ /sdcard), 只比文件名会被老目录的同名残留骗过去 - 屏幕没亮就启动抖音会永远停在启动页(UI 树为空)→ 后面"点我/等抖音号"必然 miss, 最后报成误导人的"账号不符" → 骨架第一步固定加「亮屏」,open_app 等「首页」出现 四、其它 - core/ledger.serial_of():设备名 → 当前地址(设备换 IP 后快照是错的) - 通知事件 task.video.published / task.video.failed;备份清单加 video_plan 与素材统计 - 文档同步:DATA_MODEL §2.11 + schema v10、API(新接口与语义)、TASK_DEV §4.7 专章、 ARCHITECTURE(账号页子分栏/release.js/两个 job)、DEPLOY(表数/nginx)、NOTIFY、DEVELOPMENT、README
437 lines
19 KiB
Python
437 lines
19 KiB
Python
"""设备自动化管理后台入口: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 config import MAX_CONTENT_LENGTH as _MAX_UPLOAD
|
||
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
|
||
# 单次请求体上限(视频发布计划要传几百 MB 的视频素材)。
|
||
# 默认 2GiB:**必须大于现有 APK 上传的最大包(实测 336MB)**,否则会把「应用管理」的
|
||
# APK 上传一起卡死。原来是没设的(Werkzeug 默认无上限)—— 现在有了上限,
|
||
# 超限必须回 **JSON**(见下面 413 处理器),否则前端 JSON.parse 会炸成
|
||
# "解析响应失败",用户看不出是文件太大。
|
||
app.config["MAX_CONTENT_LENGTH"] = _MAX_UPLOAD
|
||
|
||
login_manager = LoginManager(app)
|
||
login_manager.login_view = "auth.login" # 蓝图化后路由前缀 auth
|
||
|
||
|
||
@app.errorhandler(413)
|
||
def _payload_too_large(e):
|
||
"""上传超限要说人话(默认返回 HTML,前端 JSON.parse 会炸成"解析响应失败")。"""
|
||
from flask import jsonify as _jsonify
|
||
mb = _MAX_UPLOAD // (1024 * 1024)
|
||
return _jsonify({"ok": False,
|
||
"error": f"文件太大:单次上传上限 {mb} MB(可在 .env 里调 MAX_UPLOAD_MB)"}), 413
|
||
|
||
|
||
def _consume_pending_restore():
|
||
"""消费「待生效的备份恢复」(若存在 data/restore_pending/users.db)。
|
||
|
||
必须在 init_db 之后调用:现在是「单事务整库替换」而不是换文件,
|
||
要先有建好的表和 app context。导入仍然要重启才生效——因为 TaskManager /
|
||
device_pool 把任务与设备缓存在内存里,整库替换后内存副本全部失效。
|
||
"""
|
||
try:
|
||
from core.system_backup import consume_pending_restore
|
||
with app.app_context():
|
||
consume_pending_restore()
|
||
except Exception as _e:
|
||
_log.error(f"消费备份恢复任务异常(不影响启动): {_e}")
|
||
|
||
# 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB)
|
||
init_db(app)
|
||
# 通知模块:**必须早于 _consume_pending_restore()**——启动期就要能发「备份已恢复」
|
||
# 这类通知(notify 在未就绪时静默丢弃,晚初始化就丢事件了)
|
||
try:
|
||
from core import notifier as _notifier
|
||
_notifier.init_app(app)
|
||
except Exception as _e: # 通知出问题不影响启动
|
||
_log.warning(f"通知模块初始化失败(不影响启动): {_e}")
|
||
# 任务步骤明细:起一个专用写线程,任务线程只入队(见 core/step_log.py)
|
||
try:
|
||
from core import step_log as _step_log
|
||
_step_log.init_app(app)
|
||
except Exception as _e:
|
||
_log.warning(f"步骤明细模块初始化失败(不影响启动): {_e}")
|
||
# 消费「待生效的备份恢复」——必须在 init_db 之后(表已建好,且现在是事务替换而非换文件)
|
||
_consume_pending_restore()
|
||
# 库环境标签校验 + 启动横幅:每次启动都明确写出「现在连的是哪个库」,
|
||
# 避免在不知情的情况下对着生产库操作(app_meta 表要存在,所以放在 init_db 之后)
|
||
try:
|
||
with app.app_context(): # db.engine / app_meta 读取都需要 app context
|
||
db_config.verify_deployment_label()
|
||
db_config.print_banner()
|
||
# 页面顶部环境徽标(防混库第 4 层:点开任何页面都能看出连的是哪个库)
|
||
app.jinja_env.globals["db_env"] = db_config.env_badge()
|
||
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)
|
||
# 设备电量采集:定时 dumpsys battery(只读)→ 内存缓存 → /api/status 展示 + 低电量告警
|
||
from core import device_battery
|
||
device_battery.init_app(app)
|
||
# 去重账本(跨设备幂等):绑 app 供任务线程/Web/清理任务自推 context
|
||
from core import dedup
|
||
dedup.init_app(app)
|
||
# 账号台账:绑 app 供任务线程(条件判断取号)/ Web / 设备端身份页自推 context
|
||
from core import ledger
|
||
ledger.init_app(app)
|
||
# 视频发布计划:绑 app 供任务线程(发布步骤)/ Web / 清理 job 自推 context
|
||
from core import video_plan
|
||
video_plan.init_app(app)
|
||
mgr = TaskManager(app=app)
|
||
# 设备换地址(认领/迁址)后同步分组与任务的引用:它们的"内存副本"在 TaskManager 里
|
||
# (调度用内存对象),device_pool 只改库不改内存不生效,所以在此注册回调打通
|
||
device_pool.set_move_hook(mgr.sync_device_serial)
|
||
# 通知里要显示设备名(A08)而不是地址(IP):notifier 只读 device_pool 的**内存**名称
|
||
# 快照,不查库(notify 的红线是零 DB)。快照在 device_pool.init_app 里已刷过一次。
|
||
_notifier.set_device_name_resolver(device_pool.name_of)
|
||
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)
|
||
|
||
def _purge_step_log():
|
||
"""清理超期的任务步骤明细(启动时 + 每日 04:13)。
|
||
|
||
app context 由本函数自建:APScheduler 的作业跑在调度线程里,没有请求上下文。
|
||
"""
|
||
try:
|
||
from core.step_log import purge_old
|
||
with app.app_context():
|
||
purge_old()
|
||
except Exception as e:
|
||
_log.warning(f"步骤明细清理失败(不影响主服务): {e}")
|
||
|
||
|
||
def _purge_step_log_async():
|
||
"""启动时后台清理一次(不阻塞启动;服务若频繁重启,定时任务可能一直轮不到)。"""
|
||
threading.Thread(target=_purge_step_log, daemon=True).start()
|
||
|
||
|
||
def _purge_done_mark():
|
||
"""清理过期的去重记录(每日 04:23)。
|
||
|
||
**只清 day/hours 桶**:`kind='all'`("只做一次")的记录清了就等于去重失效,
|
||
见 core/dedup.purge_old。量级很小(设备数 × 天数),单条 DELETE 足够。
|
||
"""
|
||
try:
|
||
from core.dedup import purge_old
|
||
with app.app_context():
|
||
purge_old()
|
||
except Exception as e:
|
||
_log.warning(f"去重记录清理失败(不影响主服务): {e}")
|
||
|
||
|
||
def _purge_video_plan():
|
||
"""发布计划:僵尸占位回收 + 过期行清理(每日 04:41)。
|
||
|
||
僵尸回收是**必须有的一步**:占位(pushing/publishing)后崩溃的话,没有它
|
||
这条计划永远停在"发布中",再也不会被取到 —— 表现成"这个号就是不发"。
|
||
"""
|
||
try:
|
||
from core.video_plan import reclaim_zombies, purge_plan
|
||
with app.app_context():
|
||
reclaim_zombies()
|
||
purge_plan()
|
||
except Exception as e:
|
||
_log.warning(f"发布计划清理失败(不影响主服务): {e}")
|
||
|
||
|
||
def _purge_video_files():
|
||
"""发布计划:平台素材文件清理(每日 04:47)。
|
||
|
||
**只删"已发布 + 已抓到分享链接 + 超过保留期"的**:没抓到链接的一律留着
|
||
(链接与素材至少留一个),未发布/失败/未知的更不删。
|
||
"""
|
||
try:
|
||
from core.video_plan import purge_files
|
||
with app.app_context():
|
||
purge_files()
|
||
except Exception as e:
|
||
_log.warning(f"发布素材清理失败(不影响主服务): {e}")
|
||
|
||
|
||
# 经验库每日 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))
|
||
# 步骤明细清理:超过保留期(core/step_log.KEEP_DAYS)的记录每天删一次。
|
||
# 不清理的话这张表是唯一无界增长的表,会一路顶大备份包。
|
||
_audit_sched.add_job(_purge_step_log, CronTrigger(hour=4, minute=13))
|
||
# 去重记录清理:只清 day/hours 桶(all 永不清理,清了等于"只做一次"失效)
|
||
_audit_sched.add_job(_purge_done_mark, CronTrigger(hour=4, minute=23))
|
||
# 发布计划:僵尸占位回收 + 过期行清理(04:41)、孤儿素材清理(04:47)。
|
||
# 时间刻意与 04:13/04:23 错开 15 分钟以上:都是删数据的大操作,别挤同一分钟。
|
||
_audit_sched.add_job(_purge_video_plan, CronTrigger(hour=4, minute=41))
|
||
_audit_sched.add_job(_purge_video_files, CronTrigger(hour=4, 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()
|
||
# 步骤明细清理:服务频繁重启时定时任务可能一直轮不到,启动也清一次
|
||
_purge_step_log_async()
|
||
_log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)")
|
||
# 启动通知:放在 __main__ 里是刻意的——以 WSGI 方式 import 本模块只有"半个启动"
|
||
# (阶段 A~F 在 import 期就跑完了),不该发"服务已启动"。
|
||
try:
|
||
from core import notifier as _notifier
|
||
with app.app_context():
|
||
_devs = len(device_pool.list_configured())
|
||
_notifier.notify("service.started", env=db_config.env_badge().get("env", ""),
|
||
db_target=db_config.describe_target(),
|
||
device_count=_devs, job_count=len(mgr.jobs))
|
||
except Exception as _e:
|
||
_log.warning(f"发送启动通知失败(不影响启动): {_e}")
|
||
_t0 = time.time()
|
||
try:
|
||
_run_server(WEB_HOST, WEB_PORT)
|
||
finally:
|
||
try:
|
||
from core import notifier as _notifier
|
||
_notifier.notify("service.stopping", uptime_s=int(time.time() - _t0))
|
||
_notifier.shutdown(timeout=3.0)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
from core import step_log as _step_log
|
||
_step_log.shutdown(timeout=3.0) # 排空队列后再退
|
||
except Exception:
|
||
pass
|
||
mgr.shutdown()
|
||
device_discovery.shutdown()
|
||
device_battery.shutdown()
|
||
_stop_uiauto()
|