chore: 初始化仓库(排除 adb 二进制文件以解决推送超时)
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# 运行时数据
|
||||||
|
logs/*.log
|
||||||
|
data/apks/*.apk
|
||||||
|
data/*.migrated
|
||||||
|
|
||||||
|
# 数据库(含用户和任务数据,按需决定是否提交)
|
||||||
|
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# 临时文件
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
|
||||||
|
bin/adb/*
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""项目根配置:只放核心程序级配置。
|
||||||
|
|
||||||
|
任务自身的参数(观看时长、点赞开关、滑动速度等)不在这里,
|
||||||
|
放各自 tasks/xxx.py 顶部,保持每个任务自包含、互不干扰。
|
||||||
|
|
||||||
|
改 STF 地址/token、adb 路径、web 端口都改这里。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
# ================== STF 配置 ==================
|
||||||
|
STF_URL = "http://192.168.20.220:7100"
|
||||||
|
STF_TOKEN = "9024544b571647d3a6b09dab5dbfcb28bfd950f5b659400394e44e7217b7051b"
|
||||||
|
|
||||||
|
# ================== adb 路径 ==================
|
||||||
|
# adb 二进制及依赖统一放在 bin/adb/ 下
|
||||||
|
# Windows: adb.exe(依赖同目录的 dll)
|
||||||
|
# Linux: adb(需 chmod +x,Linux 版二进制)
|
||||||
|
# 代码自动识别操作系统,用户只需把对应平台的 adb 放进 bin/adb/
|
||||||
|
import sys as _sys
|
||||||
|
_ADB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bin", "adb")
|
||||||
|
if _sys.platform == "win32":
|
||||||
|
ADB_PATH = os.path.join(_ADB_DIR, "adb.exe")
|
||||||
|
else:
|
||||||
|
# Linux / macOS:用 bin/adb/adb(无 .exe 后缀)
|
||||||
|
# 部署后需执行 chmod +x bin/adb/adb
|
||||||
|
ADB_PATH = os.path.join(_ADB_DIR, "adb")
|
||||||
|
|
||||||
|
# ================== Web 后台 ==================
|
||||||
|
WEB_HOST = "0.0.0.0"
|
||||||
|
WEB_PORT = 5050
|
||||||
|
|
||||||
|
# ================== 数据目录 ==================
|
||||||
|
# 持久化数据(分组、任务计划)存放目录
|
||||||
|
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
|
||||||
|
|
||||||
|
# APK 文件存储目录(应用管理功能)
|
||||||
|
APK_DIR = os.path.join(DATA_DIR, "apks")
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""core 包:基础设施层。
|
||||||
|
|
||||||
|
config — 全局配置常量(STF、adb 路径、养号参数)
|
||||||
|
adb_helper — adb 命令封装(并发安全)
|
||||||
|
stf_client — OpenSTF REST API 客户端
|
||||||
|
device_worker — 设备生命周期 + 养号 worker + 全局状态注册表
|
||||||
|
task_manager — 任务管理框架(分组/计划/调度/重试/持久化)
|
||||||
|
"""
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""全局操作框架:所有 app 任务共用的 Action 基类与注册机制。
|
||||||
|
|
||||||
|
为什么放这里:
|
||||||
|
抖音的点赞/评论 xpath 只适用抖音,留在 tasks/douyin/actions/。
|
||||||
|
但"操作"的抽象(BaseAction 接口、概率触发、注册机制)是通用的,
|
||||||
|
放这里让所有 app 任务共享同一套操作开发范式。
|
||||||
|
|
||||||
|
加新 app 的专属操作:
|
||||||
|
在 tasks/<app>/actions/ 下建 .py,继承本模块的 BaseAction,
|
||||||
|
用 @register_action 装饰(注册到该 app 自己的注册表,见下方)。
|
||||||
|
|
||||||
|
加跨 app 通用操作(如"滑动到下一个"、"等待元素出现"):
|
||||||
|
在 core/actions/ 下建 .py,继承 BaseAction。
|
||||||
|
"""
|
||||||
|
from .base import (
|
||||||
|
BaseAction, ActionContext,
|
||||||
|
register_action, create_action_registry,
|
||||||
|
list_actions, get_action,
|
||||||
|
should_trigger,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BaseAction", "ActionContext",
|
||||||
|
"register_action", "create_action_registry",
|
||||||
|
"list_actions", "get_action",
|
||||||
|
"should_trigger",
|
||||||
|
]
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Action 基类与注册机制(全局通用,所有 app 任务共享接口)。
|
||||||
|
|
||||||
|
设计要点:
|
||||||
|
1. BaseAction 定义统一接口:execute(d, params, worker) -> bool
|
||||||
|
2. 每个 app 任务有自己的注册表(create_action_registry),
|
||||||
|
互不污染——抖音的 like 和快手的 like 各注册各的。
|
||||||
|
3. 概率触发、状态上报等通用逻辑放这里,子类不重复写。
|
||||||
|
4. ActionContext 封装执行上下文,方便扩展(传 d/worker/进度/计数器)。
|
||||||
|
|
||||||
|
新增 app 专属操作步骤(照着 tasks/douyin/actions/like.py 抄):
|
||||||
|
|
||||||
|
1. 在 tasks/<app>/actions/ 下新建 my_action.py
|
||||||
|
2. 写一个 BaseAction 子类,用 @register_action 装饰,实现 execute:
|
||||||
|
|
||||||
|
from core.actions import BaseAction, register_action, should_trigger
|
||||||
|
from . import ACTIONS # 本 app 的注册表
|
||||||
|
|
||||||
|
@register_action(ACTIONS)
|
||||||
|
class MyAction(BaseAction):
|
||||||
|
action_type = "my_action"
|
||||||
|
name = "我的操作"
|
||||||
|
description = "做什么"
|
||||||
|
default_params = {"rate": 0.5}
|
||||||
|
|
||||||
|
def execute(self, d, params, worker):
|
||||||
|
if not should_trigger(params["rate"]):
|
||||||
|
return False
|
||||||
|
# ... 用 d(xpath=...) / d(text=...) 操作
|
||||||
|
return True
|
||||||
|
|
||||||
|
3. 在 tasks/<app>/actions/__init__.py 加:from . import my_action
|
||||||
|
|
||||||
|
做完前端自动出现该操作的可勾选项。
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
|
||||||
|
from core.logger import get_logger
|
||||||
|
|
||||||
|
_log = get_logger("action")
|
||||||
|
|
||||||
|
|
||||||
|
class ActionContext:
|
||||||
|
"""操作执行上下文,封装给 Action 用的所有依赖。
|
||||||
|
|
||||||
|
现在只是简单封装,后续可扩展(如传任务参数、设备信息、进度回调)。
|
||||||
|
Action 子类通过 worker 间接访问,或直接用 execute 的 d 参数。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, worker=None, params=None):
|
||||||
|
self.worker = worker
|
||||||
|
self.params = params or {}
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAction:
|
||||||
|
"""所有操作的基类。子类实现 execute。
|
||||||
|
|
||||||
|
约定:
|
||||||
|
execute 返回 True = 执行成功(会计入 action_counts)
|
||||||
|
execute 返回 False = 跳过/失败(不影响主流程,不抛异常)
|
||||||
|
execute 内部异常由调用方捕获,不影响其他操作和主循环
|
||||||
|
"""
|
||||||
|
action_type = "base"
|
||||||
|
name = "基础操作"
|
||||||
|
description = ""
|
||||||
|
default_params = {}
|
||||||
|
|
||||||
|
def execute(self, d, params, worker):
|
||||||
|
"""执行操作。
|
||||||
|
|
||||||
|
d: u2.Device — 已连好的设备
|
||||||
|
params: 本操作的参数 dict(已合并默认值)
|
||||||
|
worker: Worker 实例,可调 worker.set_action() 上报状态
|
||||||
|
返回 True=执行成功,False=跳过/失败。
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<{self.__class__.__name__}({self.action_type})>"
|
||||||
|
|
||||||
|
|
||||||
|
def should_trigger(rate):
|
||||||
|
"""按概率决定是否触发(rate 是 0~1 的概率)。"""
|
||||||
|
return random.random() < rate
|
||||||
|
|
||||||
|
|
||||||
|
def register_action(registry):
|
||||||
|
"""装饰器:把 Action 类注册到指定注册表。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
ACTIONS = create_action_registry()
|
||||||
|
@register_action(ACTIONS)
|
||||||
|
class MyAction(BaseAction): ...
|
||||||
|
|
||||||
|
每个 app 任务有自己的 ACTIONS 注册表,互不污染。
|
||||||
|
"""
|
||||||
|
def _decorator(action_cls):
|
||||||
|
registry[action_cls.action_type] = action_cls
|
||||||
|
_log.debug(f"注册操作: {action_cls.action_type} -> {action_cls.__name__}")
|
||||||
|
return action_cls
|
||||||
|
return _decorator
|
||||||
|
|
||||||
|
|
||||||
|
def create_action_registry():
|
||||||
|
"""创建一个独立的操作注册表(每个 app 任务一个)。
|
||||||
|
|
||||||
|
返回 dict: action_type -> ActionClass
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def list_actions(registry):
|
||||||
|
"""返回注册表中所有操作的元信息(供前端展示)。"""
|
||||||
|
return [{"action_type": a.action_type, "name": a.name,
|
||||||
|
"description": a.description,
|
||||||
|
"default_params": a.default_params} for a in registry.values()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_action(registry, action_type):
|
||||||
|
"""按 action_type 取操作类。"""
|
||||||
|
return registry.get(action_type)
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""adb 命令工具封装。
|
||||||
|
|
||||||
|
并发关键:adb_connect 绝不 kill-server(会断开其他 worker 正在用的设备),
|
||||||
|
用全局锁串行化连接阶段,connect 本身很快,串行不影响整体并发。
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from config import ADB_PATH
|
||||||
|
from core.logger import get_logger
|
||||||
|
|
||||||
|
_log = get_logger("core.adb")
|
||||||
|
|
||||||
|
_ADB_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _adb(*args):
|
||||||
|
"""执行 adb 命令,返回 (stdout+stderr) 文本。不用 shell=True,避免子进程环境问题。
|
||||||
|
|
||||||
|
用 bytes 模式自己解码(Windows 下 subprocess text=True 会用 GBK 读 adb 输出,
|
||||||
|
遇到非 GBK 字节会崩溃)。
|
||||||
|
"""
|
||||||
|
r = subprocess.run([ADB_PATH, *args], capture_output=True)
|
||||||
|
out = (r.stdout or b"") + (r.stderr or b"")
|
||||||
|
try:
|
||||||
|
return out.decode("utf-8", errors="replace").strip()
|
||||||
|
except Exception:
|
||||||
|
return out.decode("gbk", errors="replace").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def get_foreground_app(remote_adb_url, timeout=3):
|
||||||
|
"""获取设备当前前台 App 包名。
|
||||||
|
|
||||||
|
通过 adb shell dumpsys window 解析 mCurrentFocus。
|
||||||
|
remote_adb_url: adb 连接地址(如 192.168.20.17:12345),设备需已 adb connect。
|
||||||
|
返回包名字符串(如 com.ss.android.ugc.aweme),失败/无设备返回 None。
|
||||||
|
"""
|
||||||
|
if not remote_adb_url:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
[ADB_PATH, "-s", remote_adb_url, "shell",
|
||||||
|
"dumpsys window | grep mCurrentFocus"],
|
||||||
|
capture_output=True, timeout=timeout
|
||||||
|
)
|
||||||
|
out = (r.stdout or b"").decode("utf-8", errors="replace").strip()
|
||||||
|
if not out:
|
||||||
|
return None
|
||||||
|
low = out.lower()
|
||||||
|
if "device offline" in low or "device not found" in low or "error:" in low:
|
||||||
|
return None
|
||||||
|
# 解析 mCurrentFocus=Window{abc u0 com.xx/com.xx.MainActivity}
|
||||||
|
for line in out.split("\n"):
|
||||||
|
if "mCurrentFocus" not in line:
|
||||||
|
continue
|
||||||
|
# 提取 u0 后面的 token(包名/Activity 或包名)
|
||||||
|
m = re.search(r'\bu0\s+(\S+)', line)
|
||||||
|
if m:
|
||||||
|
token = m.group(1).rstrip("}")
|
||||||
|
if "/" in token:
|
||||||
|
return token.split("/")[0]
|
||||||
|
return token or None
|
||||||
|
return None
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def adb_connect(remote_adb_url, retries=5):
|
||||||
|
"""adb connect(带重试)。返回 True/False。
|
||||||
|
|
||||||
|
首次常因认证握手未就绪报 failed to authenticate,第2次重试通常 already connected。
|
||||||
|
STF remoteConnect 启用后端口监听可能需要几秒,所以重试间隔递增。
|
||||||
|
若全部重试失败,做一次 kill-server 硬重置后再试(long-running 进程的 adb daemon
|
||||||
|
可能状态坏掉,必须重置)。硬重置在全局锁内,会短暂影响并发 worker,但只在万不得已时触发。
|
||||||
|
"""
|
||||||
|
with _ADB_LOCK:
|
||||||
|
if _try_connect(remote_adb_url, retries):
|
||||||
|
return True
|
||||||
|
# 全部失败,硬重置 adb server
|
||||||
|
_log.warning(f"{remote_adb_url} 重试耗尽,执行 kill-server 硬重置")
|
||||||
|
_adb("kill-server")
|
||||||
|
time.sleep(2)
|
||||||
|
_adb("start-server")
|
||||||
|
time.sleep(2)
|
||||||
|
return _try_connect(remote_adb_url, 3)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _try_connect(remote_adb_url, retries):
|
||||||
|
"""尝试连接,不 kill-server。返回 True/False。"""
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
out = _adb("connect", remote_adb_url)
|
||||||
|
_log.info(f"{remote_adb_url} 第 {attempt} 次尝试: {out}")
|
||||||
|
if "connected" in out.lower() and "failed" not in out.lower():
|
||||||
|
return True
|
||||||
|
time.sleep(1 + attempt)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def adb_connect_light(remote_adb_url):
|
||||||
|
"""轻量 adb connect(扫描专用):单次尝试,不重试,绝不 kill-server。
|
||||||
|
|
||||||
|
与 adb_connect 的区别:
|
||||||
|
- 不重试:失败立即返回 False
|
||||||
|
- 不 kill-server:避免断开其他 worker 正在使用的 ADB 连接
|
||||||
|
用于前台 App 扫描等非关键路径,连不上就跳过,不影响正在运行的任务。
|
||||||
|
"""
|
||||||
|
with _ADB_LOCK:
|
||||||
|
out = _adb("connect", remote_adb_url)
|
||||||
|
if "connected" in out.lower() and "failed" not in out.lower():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def adb_disconnect(remote_adb_url):
|
||||||
|
"""adb disconnect。"""
|
||||||
|
try:
|
||||||
|
_adb("disconnect", remote_adb_url)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def identify_device(serial, duration=3):
|
||||||
|
"""让设备响铃以供识别(直连 serial,不经过 STF)。
|
||||||
|
|
||||||
|
播放铃声 duration 秒后自动停止。
|
||||||
|
不 disconnect(避免断开 STF agent 共享的 adb 连接)。
|
||||||
|
返回 (ok, msg)。
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
if not adb_connect_light(serial):
|
||||||
|
return False, "adb connect 失败"
|
||||||
|
try:
|
||||||
|
# 异步启动铃声播放(cmd audio play-ringtone 会阻塞,用 Popen)
|
||||||
|
p = subprocess.Popen(
|
||||||
|
[ADB_PATH, "-s", serial, "shell", "cmd", "audio", "play-ringtone"],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
time.sleep(duration)
|
||||||
|
p.terminate()
|
||||||
|
# 停止铃声
|
||||||
|
subprocess.run(
|
||||||
|
[ADB_PATH, "-s", serial, "shell", "cmd", "audio", "stop-ringtone"],
|
||||||
|
capture_output=True, timeout=5,
|
||||||
|
)
|
||||||
|
_log.info(f"[{serial}] 识别响铃 {duration}s 完成")
|
||||||
|
return True, f"已响铃 {duration} 秒"
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"[{serial}] 识别响铃失败: {e}")
|
||||||
|
return False, str(e)
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
"""APK 文件管理 + 批量安装。
|
||||||
|
|
||||||
|
功能:
|
||||||
|
- upload: 保存 APK 到 data/apks/,用 pyaxmlparser 解析包名/版本/应用名,入库
|
||||||
|
- list_all: 列出所有已上传的 APK
|
||||||
|
- delete: 删文件 + 删数据库记录
|
||||||
|
- install: 批量安装到指定设备(后台线程,直连设备 adb)
|
||||||
|
- get_install_status: 获取安装进度
|
||||||
|
|
||||||
|
设备连接策略(直连设备 IP:5555,完全绕过 STF occupy/release):
|
||||||
|
- worker 运行中:跳过(避免打断任务)
|
||||||
|
- 其他设备(自己占用/空闲/他人占用):直接 adb connect serial → install → disconnect
|
||||||
|
不经过 STF,不会触发 STF agent 清理,安装的 app 会永久保留
|
||||||
|
- serial 格式为 IP:5555(设备本身的 adb 网络地址),STF 返回的 serial 即此格式
|
||||||
|
|
||||||
|
为什么不用 STF occupy/release:
|
||||||
|
STF release 会触发 agent 清理设备(卸载第三方 app、清除数据、回桌面),
|
||||||
|
导致刚安装的 app 被自动删除。直连设备完全绕过 STF,安装完断开连接即可。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
import threading
|
||||||
|
import subprocess
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
from config import APK_DIR, ADB_PATH
|
||||||
|
from core.logger import get_logger
|
||||||
|
from core.models import db, ApkFile as ApkRow
|
||||||
|
from .stf_client import STFClient
|
||||||
|
from .adb_helper import adb_disconnect
|
||||||
|
from .device_worker import get_all_worker_status
|
||||||
|
|
||||||
|
_log = get_logger("core.apk")
|
||||||
|
|
||||||
|
# adb install 超时(秒)。大 APK 安装慢,给 5 分钟
|
||||||
|
_INSTALL_TIMEOUT = 300
|
||||||
|
# 并发安装数
|
||||||
|
_INSTALL_CONCURRENCY = 5
|
||||||
|
|
||||||
|
|
||||||
|
class ApkManager:
|
||||||
|
"""APK 文件管理 + 批量安装。"""
|
||||||
|
|
||||||
|
def __init__(self, stf_client=None, app=None):
|
||||||
|
self.stf = stf_client or STFClient()
|
||||||
|
self.app = app # Flask app,用于 db context
|
||||||
|
os.makedirs(APK_DIR, exist_ok=True)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._install_task = None # 当前安装任务
|
||||||
|
|
||||||
|
def _db(self):
|
||||||
|
if self.app is None:
|
||||||
|
raise RuntimeError("ApkManager 未关联 Flask app")
|
||||||
|
return self.app.app_context()
|
||||||
|
|
||||||
|
# ================== 上传 ==================
|
||||||
|
def upload(self, file_storage):
|
||||||
|
"""保存上传的 APK 文件,解析元信息,入库。
|
||||||
|
|
||||||
|
file_storage: werkzeug FileStorage 对象
|
||||||
|
返回: dict (APK 信息) 或 None(失败)
|
||||||
|
"""
|
||||||
|
# 生成唯一 id
|
||||||
|
apk_id = uuid.uuid4().hex[:8]
|
||||||
|
original_name = file_storage.filename or f"{apk_id}.apk"
|
||||||
|
if not original_name.lower().endswith(".apk"):
|
||||||
|
original_name += ".apk"
|
||||||
|
disk_filename = f"{apk_id}.apk"
|
||||||
|
disk_path = os.path.join(APK_DIR, disk_filename)
|
||||||
|
|
||||||
|
# 保存文件
|
||||||
|
try:
|
||||||
|
file_storage.save(disk_path)
|
||||||
|
except Exception as e:
|
||||||
|
_log.error(f"保存 APK 文件失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
size = os.path.getsize(disk_path)
|
||||||
|
display_name = os.path.splitext(original_name)[0]
|
||||||
|
package_name = ""
|
||||||
|
version_name = ""
|
||||||
|
version_code = 0
|
||||||
|
|
||||||
|
# 解析 APK 元信息
|
||||||
|
try:
|
||||||
|
from pyaxmlparser import APK as APKParser
|
||||||
|
apk = APKParser(disk_path)
|
||||||
|
package_name = apk.package or ""
|
||||||
|
version_name = getattr(apk, "version_name", "") or ""
|
||||||
|
version_code = getattr(apk, "version_code", 0) or 0
|
||||||
|
# 应用名:优先 get_app_name(),其次 application.label
|
||||||
|
try:
|
||||||
|
display_name = apk.get_app_name() or display_name
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
display_name = apk.application.label or display_name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_log.info(f"解析 APK 成功: {display_name} ({package_name} v{version_name})")
|
||||||
|
except ImportError:
|
||||||
|
_log.warning("pyaxmlparser 未安装,跳过 APK 元信息解析")
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"解析 APK 元信息失败(不影响上传): {e}")
|
||||||
|
|
||||||
|
# 入库
|
||||||
|
upload_time = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
try:
|
||||||
|
with self._db():
|
||||||
|
row = ApkRow(id=apk_id, filename=disk_filename,
|
||||||
|
display_name=display_name,
|
||||||
|
package_name=package_name,
|
||||||
|
version_name=version_name,
|
||||||
|
version_code=version_code,
|
||||||
|
size=size, upload_time=upload_time)
|
||||||
|
db.session.add(row)
|
||||||
|
db.session.commit()
|
||||||
|
_log.info(f"APK 上传成功: {display_name}({apk_id}) {size}字节")
|
||||||
|
return row.to_dict()
|
||||||
|
except Exception as e:
|
||||||
|
_log.error(f"APK 入库失败: {e}")
|
||||||
|
# 入库失败但文件已存,仍然返回基本信息
|
||||||
|
try:
|
||||||
|
os.remove(disk_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ================== 列表 ==================
|
||||||
|
def list_all(self):
|
||||||
|
"""列出所有 APK。"""
|
||||||
|
try:
|
||||||
|
with self._db():
|
||||||
|
rows = ApkRow.query.order_by(ApkRow.upload_time.desc()).all()
|
||||||
|
return [r.to_dict() for r in rows]
|
||||||
|
except Exception as e:
|
||||||
|
_log.error(f"列出 APK 失败: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# ================== 删除 ==================
|
||||||
|
def delete(self, apk_id):
|
||||||
|
"""删除 APK 文件和数据库记录。"""
|
||||||
|
try:
|
||||||
|
with self._db():
|
||||||
|
row = ApkRow.query.get(apk_id)
|
||||||
|
if not row:
|
||||||
|
return False, "APK 不存在"
|
||||||
|
name = row.display_name or row.filename
|
||||||
|
# 删文件
|
||||||
|
disk_path = os.path.join(APK_DIR, row.filename)
|
||||||
|
if os.path.exists(disk_path):
|
||||||
|
try:
|
||||||
|
os.remove(disk_path)
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"删除 APK 文件失败: {e}")
|
||||||
|
# 删记录
|
||||||
|
db.session.delete(row)
|
||||||
|
db.session.commit()
|
||||||
|
_log.info(f"已删除 APK: {name}({apk_id})")
|
||||||
|
return True, f"已删除 {name}"
|
||||||
|
except Exception as e:
|
||||||
|
_log.error(f"删除 APK 失败: {e}")
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
# ================== 批量安装 ==================
|
||||||
|
def install(self, apk_id, serials):
|
||||||
|
"""批量安装 APK 到指定设备(后台线程执行)。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
apk_id: APK ID
|
||||||
|
serials: 设备 serial 列表
|
||||||
|
|
||||||
|
返回: (ok, msg)
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if self._install_task and not self._install_task.get("finished"):
|
||||||
|
return False, "已有安装任务正在进行,请等待完成"
|
||||||
|
|
||||||
|
# 查 APK 信息
|
||||||
|
try:
|
||||||
|
with self._db():
|
||||||
|
row = ApkRow.query.get(apk_id)
|
||||||
|
if not row:
|
||||||
|
return False, "APK 不存在"
|
||||||
|
apk_path = os.path.join(APK_DIR, row.filename)
|
||||||
|
apk_name = row.display_name or row.filename
|
||||||
|
package_name = row.package_name or ""
|
||||||
|
if not os.path.exists(apk_path):
|
||||||
|
return False, "APK 文件不存在"
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
if not serials:
|
||||||
|
return False, "未选择设备"
|
||||||
|
|
||||||
|
# 获取设备信息(型号等)
|
||||||
|
try:
|
||||||
|
all_devices = {d["serial"]: d for d in self.stf.list_all_devices()}
|
||||||
|
except Exception:
|
||||||
|
all_devices = {}
|
||||||
|
|
||||||
|
# 获取 worker 状态(判断设备是否运行中)
|
||||||
|
worker_status = {w["serial"]: w for w in get_all_worker_status()}
|
||||||
|
|
||||||
|
# 构造安装任务
|
||||||
|
items = {}
|
||||||
|
for s in serials:
|
||||||
|
dev = all_devices.get(s, {})
|
||||||
|
model = dev.get("model") or dev.get("manufacturer") or s
|
||||||
|
items[s] = {
|
||||||
|
"name": model,
|
||||||
|
"status": "pending",
|
||||||
|
"msg": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
self._install_task = {
|
||||||
|
"apk_id": apk_id,
|
||||||
|
"apk_name": apk_name,
|
||||||
|
"started_at": time.time(),
|
||||||
|
"finished": False,
|
||||||
|
"total": len(serials),
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 后台线程执行安装
|
||||||
|
t = threading.Thread(target=self._install_worker,
|
||||||
|
args=(apk_id, apk_path, apk_name, package_name,
|
||||||
|
serials, all_devices, worker_status),
|
||||||
|
name="apk-install", daemon=True)
|
||||||
|
t.start()
|
||||||
|
_log.info(f"开始安装 {apk_name} 到 {len(serials)} 台设备")
|
||||||
|
return True, f"开始安装 {apk_name} 到 {len(serials)} 台设备"
|
||||||
|
|
||||||
|
def _install_worker(self, apk_id, apk_path, apk_name, package_name,
|
||||||
|
serials, all_devices, worker_status):
|
||||||
|
"""后台安装线程:直连设备安装,完全绕过 STF。"""
|
||||||
|
# 只跳过 worker 运行中的设备,其他设备(空闲/自己占用/他人占用)都直连安装
|
||||||
|
install_list = []
|
||||||
|
for s in serials:
|
||||||
|
w = worker_status.get(s, {})
|
||||||
|
if w.get("status") in ("running", "connecting"):
|
||||||
|
self._set_item(s, "skipped", "任务运行中,已跳过")
|
||||||
|
continue
|
||||||
|
install_list.append(s)
|
||||||
|
|
||||||
|
_log.info(f"安装任务 {apk_name}: 待安装={len(install_list)}, "
|
||||||
|
f"跳过={len(serials)-len(install_list)}")
|
||||||
|
|
||||||
|
if install_list:
|
||||||
|
with ThreadPoolExecutor(max_workers=_INSTALL_CONCURRENCY) as pool:
|
||||||
|
futures = {pool.submit(self._install_one, s, apk_path,
|
||||||
|
package_name): s
|
||||||
|
for s in install_list}
|
||||||
|
for fut in as_completed(futures, timeout=_INSTALL_TIMEOUT + 60):
|
||||||
|
s = futures[fut]
|
||||||
|
try:
|
||||||
|
fut.result()
|
||||||
|
except Exception as e:
|
||||||
|
self._set_item(s, "failed", f"安装异常: {e}")
|
||||||
|
|
||||||
|
# 标记完成
|
||||||
|
if self._install_task:
|
||||||
|
self._install_task["finished"] = True
|
||||||
|
# 统计
|
||||||
|
success = sum(1 for v in self._install_task["items"].values()
|
||||||
|
if v["status"] == "success")
|
||||||
|
failed = sum(1 for v in self._install_task["items"].values()
|
||||||
|
if v["status"] == "failed")
|
||||||
|
skipped = sum(1 for v in self._install_task["items"].values()
|
||||||
|
if v["status"] == "skipped")
|
||||||
|
_log.info(f"安装完成 {apk_name}: 成功={success}, 失败={failed}, 跳过={skipped}")
|
||||||
|
|
||||||
|
def _install_one(self, serial, apk_path, package_name=""):
|
||||||
|
"""直连设备安装 APK。
|
||||||
|
|
||||||
|
serial 即设备的 adb 网络地址(IP:5555),直接 adb connect 即可。
|
||||||
|
不经过 STF occupy/release,安装完断开连接,app 永久保留。
|
||||||
|
"""
|
||||||
|
from .adb_helper import _adb
|
||||||
|
self._set_item(serial, "installing", f"正在连接 {serial}...")
|
||||||
|
try:
|
||||||
|
# adb connect(带重试,设备网络可能需要几次握手)
|
||||||
|
connected = False
|
||||||
|
last_out = ""
|
||||||
|
for attempt in range(1, 4): # 最多 3 次
|
||||||
|
out = _adb("connect", serial)
|
||||||
|
last_out = out
|
||||||
|
if "connected" in out.lower() and "failed" not in out.lower():
|
||||||
|
connected = True
|
||||||
|
break
|
||||||
|
_log.info(f"[{serial}] adb connect 第{attempt}次: {out}")
|
||||||
|
time.sleep(1 + attempt)
|
||||||
|
if not connected:
|
||||||
|
_log.warning(f"[{serial}] adb connect 失败: 输出={last_out}")
|
||||||
|
self._set_item(serial, "failed", f"adb connect 失败: {last_out[:150]}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 安装
|
||||||
|
self._set_item(serial, "installing", "正在安装...")
|
||||||
|
r = subprocess.run(
|
||||||
|
[ADB_PATH, "-s", serial, "install", "-r", apk_path],
|
||||||
|
capture_output=True, timeout=_INSTALL_TIMEOUT
|
||||||
|
)
|
||||||
|
out = ((r.stdout or b"") + (r.stderr or b""))
|
||||||
|
try:
|
||||||
|
out = out.decode("utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
out = out.decode("gbk", errors="replace")
|
||||||
|
out = out.strip()
|
||||||
|
_log.info(f"[{serial}] adb install 返回码={r.returncode}, 输出={out[:300]}")
|
||||||
|
low = out.lower()
|
||||||
|
install_ok = False
|
||||||
|
if "success" in low:
|
||||||
|
install_ok = True
|
||||||
|
elif "already_installed" in low or "already installed" in low:
|
||||||
|
install_ok = True
|
||||||
|
|
||||||
|
if not install_ok:
|
||||||
|
msg = out.replace("\n", " ").strip()[:200]
|
||||||
|
self._set_item(serial, "failed", msg or "安装失败(returncode=%d)" % r.returncode)
|
||||||
|
_log.warning(f"[{serial}] APK 安装失败: {msg}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 安装后验证:查 pm list packages 确认包名是否真正存在
|
||||||
|
if package_name:
|
||||||
|
self._set_item(serial, "installing", "正在验证安装结果...")
|
||||||
|
try:
|
||||||
|
rv = subprocess.run(
|
||||||
|
[ADB_PATH, "-s", serial, "shell", "pm", "list", "packages", package_name],
|
||||||
|
capture_output=True, timeout=30
|
||||||
|
)
|
||||||
|
vout = (rv.stdout or b"").decode("utf-8", errors="replace").strip()
|
||||||
|
_log.info(f"[{serial}] pm list packages {package_name}: {vout[:200]}")
|
||||||
|
if package_name in vout:
|
||||||
|
self._set_item(serial, "success", "安装成功(已验证)")
|
||||||
|
_log.info(f"[{serial}] APK 安装成功且已验证")
|
||||||
|
else:
|
||||||
|
self._set_item(serial, "failed", "adb返回Success但设备上未检测到包,可能被系统拦截")
|
||||||
|
_log.warning(f"[{serial}] 安装验证失败: adb返回Success但pm list未找到 {package_name}")
|
||||||
|
except Exception as e:
|
||||||
|
self._set_item(serial, "success", "安装成功(验证异常)")
|
||||||
|
_log.warning(f"[{serial}] 安装验证异常: {e}")
|
||||||
|
else:
|
||||||
|
self._set_item(serial, "success", "安装成功")
|
||||||
|
_log.info(f"[{serial}] APK 安装成功(无包名,跳过验证)")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self._set_item(serial, "failed", "安装超时")
|
||||||
|
except Exception as e:
|
||||||
|
self._set_item(serial, "failed", str(e)[:200])
|
||||||
|
_log.exception(f"[{serial}] 安装异常")
|
||||||
|
finally:
|
||||||
|
# 断开 adb 连接(不影响设备,app 永久保留)
|
||||||
|
adb_disconnect(serial)
|
||||||
|
|
||||||
|
def _set_item(self, serial, status, msg=""):
|
||||||
|
"""更新安装任务中某设备的状态。"""
|
||||||
|
if not self._install_task:
|
||||||
|
return
|
||||||
|
items = self._install_task["items"]
|
||||||
|
if serial not in items:
|
||||||
|
items[serial] = {"name": serial, "status": status, "msg": msg}
|
||||||
|
else:
|
||||||
|
items[serial]["status"] = status
|
||||||
|
items[serial]["msg"] = msg
|
||||||
|
|
||||||
|
def get_install_status(self):
|
||||||
|
"""获取当前安装任务状态。"""
|
||||||
|
if not self._install_task:
|
||||||
|
return None
|
||||||
|
task = self._install_task
|
||||||
|
items = task["items"]
|
||||||
|
success = sum(1 for v in items.values() if v["status"] == "success")
|
||||||
|
failed = sum(1 for v in items.values() if v["status"] == "failed")
|
||||||
|
skipped = sum(1 for v in items.values() if v["status"] == "skipped")
|
||||||
|
installing = sum(1 for v in items.values() if v["status"] == "installing")
|
||||||
|
pending = sum(1 for v in items.values() if v["status"] == "pending")
|
||||||
|
return {
|
||||||
|
"apk_id": task["apk_id"],
|
||||||
|
"apk_name": task["apk_name"],
|
||||||
|
"started_at": task["started_at"],
|
||||||
|
"finished": task.get("finished", False),
|
||||||
|
"total": task["total"],
|
||||||
|
"success": success,
|
||||||
|
"failed": failed,
|
||||||
|
"skipped": skipped,
|
||||||
|
"installing": installing,
|
||||||
|
"pending": pending,
|
||||||
|
"items": dict(items),
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
"""设备生命周期 + 通用 Worker 基类 + 全局状态注册表 + 心跳看门狗。
|
||||||
|
|
||||||
|
STFDevice — 单设备生命周期:acquire(占用+adb连接)→ release(断开+释放)
|
||||||
|
BaseWorker — 通用 worker 基类:封装 STF 生命周期 + u2 连接 + 状态上报 + stop
|
||||||
|
+ 生命周期钩子(setup/teardown/on_error) + 心跳上报
|
||||||
|
子类只需实现 run_task(d),可选实现钩子,不用管 STF/adb/状态/异常
|
||||||
|
_WORKERS — 全局状态注册表,供 web_server 读取实时状态
|
||||||
|
_watchdog — 心跳看门狗线程,检测 worker 卡死
|
||||||
|
|
||||||
|
新增任务只需写一个 BaseWorker 子类 + 一个 BaseTask 子类,
|
||||||
|
完全不用碰这里的代码。
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||||
|
|
||||||
|
import uiautomator2 as u2
|
||||||
|
|
||||||
|
from core.logger import get_logger
|
||||||
|
from core.stf_client import DeviceOfflineError, STFError
|
||||||
|
from .adb_helper import adb_connect, adb_disconnect
|
||||||
|
|
||||||
|
_log = get_logger("core.worker")
|
||||||
|
|
||||||
|
# 心跳超时阈值(秒)。worker 超过这个时间没更新心跳,判定为卡死
|
||||||
|
_HEARTBEAT_TIMEOUT = 120
|
||||||
|
# 看门狗检查间隔
|
||||||
|
_WATCHDOG_INTERVAL = 30
|
||||||
|
# u2.connect / d.info 超时(秒)。超过则放弃,避免 worker 永久 hang
|
||||||
|
_U2_CONNECT_TIMEOUT = 30
|
||||||
|
_U2_INFO_TIMEOUT = 10
|
||||||
|
|
||||||
|
|
||||||
|
class STFDevice:
|
||||||
|
"""单设备生命周期:acquire(占用+连接)→ release(断开+释放)。
|
||||||
|
|
||||||
|
用 try/finally 保证设备一定被释放,即使养号中途报错。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, stf_client, serial=None):
|
||||||
|
self.stf = stf_client
|
||||||
|
self.serial = serial
|
||||||
|
self.remote_adb_url = None
|
||||||
|
|
||||||
|
def acquire(self, serial=None):
|
||||||
|
self.serial = serial or self.serial or self._pick_free()
|
||||||
|
_log.info(f"[{self.serial}] 选定设备")
|
||||||
|
|
||||||
|
ok, msg = self.stf.occupy(self.serial)
|
||||||
|
if not ok:
|
||||||
|
raise STFError(f"占用设备失败: {msg}", "conflict")
|
||||||
|
_log.info(f"[{self.serial}] STF 占用成功")
|
||||||
|
|
||||||
|
# 优先直连设备 serial(当 serial 本身就是 IP:5555 时,如 Tailscale 网络)
|
||||||
|
# 这比 STF remoteConnect 桥接更稳定,避免桥接地址认证失败
|
||||||
|
if ":" in self.serial:
|
||||||
|
self.remote_adb_url = self.serial
|
||||||
|
_log.info(f"[{self.serial}] 直连设备: {self.remote_adb_url}")
|
||||||
|
else:
|
||||||
|
# serial 不是 IP:port 格式,走 STF remoteConnect 桥接
|
||||||
|
self.remote_adb_url = self.stf.remote_connect(self.serial)
|
||||||
|
_log.info(f"[{self.serial}] STF 桥接: {self.remote_adb_url}")
|
||||||
|
|
||||||
|
if not adb_connect(self.remote_adb_url):
|
||||||
|
raise STFError(f"adb connect 失败: {self.remote_adb_url}", "offline")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
def _pick_free(self):
|
||||||
|
free = self.stf.list_free_devices()
|
||||||
|
if not free:
|
||||||
|
raise STFError("没有可用的在线空闲设备", "offline")
|
||||||
|
return free[0]["serial"]
|
||||||
|
|
||||||
|
def release(self):
|
||||||
|
if not self.serial:
|
||||||
|
return
|
||||||
|
# 直连模式(remote_adb_url == serial):不 disconnect
|
||||||
|
# STF agent 可能共享同一个 adb 连接,disconnect 会导致 STF 设备离线后重新连接
|
||||||
|
if self.remote_adb_url and self.remote_adb_url != self.serial:
|
||||||
|
adb_disconnect(self.remote_adb_url)
|
||||||
|
try:
|
||||||
|
self.stf.remote_disconnect(self.serial)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self.stf.release(self.serial)
|
||||||
|
_log.info(f"[{self.serial}] 已释放")
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"[{self.serial}] 释放失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 全局 worker 状态注册表(供 web_server 读取) ==================
|
||||||
|
_WORKERS_LOCK = threading.Lock()
|
||||||
|
_WORKERS = {} # serial -> status dict
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_worker_status():
|
||||||
|
with _WORKERS_LOCK:
|
||||||
|
return list(_WORKERS.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _update_status(serial, **fields):
|
||||||
|
with _WORKERS_LOCK:
|
||||||
|
s = _WORKERS.setdefault(serial, {"serial": serial})
|
||||||
|
s.update(fields)
|
||||||
|
s["last_heartbeat"] = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_worker(serial):
|
||||||
|
with _WORKERS_LOCK:
|
||||||
|
_WORKERS.pop(serial, None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_worker_heartbeat(serial):
|
||||||
|
"""返回 worker 最近一次心跳时间,没有返回 0。"""
|
||||||
|
with _WORKERS_LOCK:
|
||||||
|
return _WORKERS.get(serial, {}).get("last_heartbeat", 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 心跳看门狗 ==================
|
||||||
|
class _Watchdog(threading.Thread):
|
||||||
|
"""后台线程:检测 worker 心跳超时,自动标记异常并设置 stop 信号。
|
||||||
|
|
||||||
|
worker 卡死(如 u2 操作 hang 住)时,看门狗能发现并标记状态,
|
||||||
|
避免设备一直被占用却不干活。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(daemon=True, name="worker-watchdog")
|
||||||
|
self._stop = threading.Event()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
while not self._stop.wait(_WATCHDOG_INTERVAL):
|
||||||
|
try:
|
||||||
|
self._check()
|
||||||
|
except Exception as e:
|
||||||
|
_log.error(f"看门狗检查异常: {e}")
|
||||||
|
|
||||||
|
def _check(self):
|
||||||
|
now = time.time()
|
||||||
|
with _WORKERS_LOCK:
|
||||||
|
stale = []
|
||||||
|
for serial, s in _WORKERS.items():
|
||||||
|
# 只检查运行中的 worker
|
||||||
|
if s.get("status") not in ("running", "connecting"):
|
||||||
|
continue
|
||||||
|
hb = s.get("last_heartbeat", 0)
|
||||||
|
if hb and now - hb > _HEARTBEAT_TIMEOUT:
|
||||||
|
stale.append(serial)
|
||||||
|
for serial in stale:
|
||||||
|
_log.error(f"[{serial}] 心跳超时 {now - get_worker_heartbeat(serial):.0f}s,标记卡死")
|
||||||
|
_update_status(serial, status="error",
|
||||||
|
last_error=f"心跳超时 {_HEARTBEAT_TIMEOUT}s,worker 可能卡死")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._stop.set()
|
||||||
|
|
||||||
|
|
||||||
|
_watchdog = None
|
||||||
|
|
||||||
|
|
||||||
|
def start_watchdog():
|
||||||
|
"""启动看门狗(web_server 启动时调一次)。"""
|
||||||
|
global _watchdog
|
||||||
|
if _watchdog is None or not _watchdog.is_alive():
|
||||||
|
_watchdog = _Watchdog()
|
||||||
|
_watchdog.start()
|
||||||
|
_log.info("心跳看门狗已启动")
|
||||||
|
|
||||||
|
|
||||||
|
def stop_watchdog():
|
||||||
|
"""停止看门狗。"""
|
||||||
|
global _watchdog
|
||||||
|
if _watchdog:
|
||||||
|
_watchdog.stop()
|
||||||
|
_watchdog = None
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 通用 Worker 基类 ==================
|
||||||
|
class BaseWorker(threading.Thread):
|
||||||
|
"""通用 worker 基类。
|
||||||
|
|
||||||
|
自动处理:STF 占用/释放、u2 连接、状态上报、异常捕获、stop 信号、心跳。
|
||||||
|
子类只需实现 run_task(d) 方法,专注业务逻辑。
|
||||||
|
|
||||||
|
生命周期(基类 run() 已封装,不要重写):
|
||||||
|
1. acquire 设备(STF 占用 + adb 连接)
|
||||||
|
2. u2.connect 拿到 d
|
||||||
|
3. 调用 setup(d) ← 子类可选钩子,做初始化
|
||||||
|
4. 调用子类 run_task(d) ← 业务逻辑
|
||||||
|
5. 调用 teardown(d) ← 子类可选钩子,做清理
|
||||||
|
6. finally: release 设备
|
||||||
|
|
||||||
|
子类可选钩子:
|
||||||
|
setup(d) — run_task 前的初始化(如启动 app、检查登录)。默认空。
|
||||||
|
teardown(d) — run_task 后的清理(无论成功失败)。默认空。
|
||||||
|
on_error(d, err) — run_task 抛异常时回调。默认记录日志。
|
||||||
|
|
||||||
|
子类可用工具方法:
|
||||||
|
self.stopped() — 判断是否被要求停止(循环里检查)
|
||||||
|
self.set_action(s) — 上报当前动作(同时刷新心跳)
|
||||||
|
self.set_progress() — 上报进度字段(同时刷新心跳)
|
||||||
|
self.heartbeat() — 手动刷新心跳(长操作中间调)
|
||||||
|
|
||||||
|
进度上报规范(通用,适配任意 app):
|
||||||
|
self.set_progress(done=5, total=80, unit="视频")
|
||||||
|
- done: 已完成数量(int)
|
||||||
|
- total: 总数量(int)
|
||||||
|
- unit: 计数单位(str,如 "视频"/"轮次"/"条")
|
||||||
|
- action_counts: 操作计数 dict(可选,如 {"like": 3, "comment": 1})
|
||||||
|
- 也可传任意业务字段,前端会原样展示在"进度"列
|
||||||
|
示例:
|
||||||
|
self.set_progress(done=5, total=80, unit="视频",
|
||||||
|
action_counts={"like": 3, "comment": 1})
|
||||||
|
前端展示:进度条 + "5/80 视频" + 操作计数徽章
|
||||||
|
|
||||||
|
运行时长终止(通用,适配任意 app):
|
||||||
|
self.max_duration — 最大运行时长(秒),0=不限时
|
||||||
|
self.is_time_up() — 判断是否已达 max_duration
|
||||||
|
子类在循环里检查 self.stopped() or self.is_time_up()
|
||||||
|
到时自动停(自然结束,不是 error),状态=done
|
||||||
|
|
||||||
|
异常分类(影响上层重试策略):
|
||||||
|
DeviceOfflineError — 设备掉线/不可用,不重试(换设备也没用)
|
||||||
|
其他异常 — 可重试
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, stf_client, serial, params=None, daemon=True):
|
||||||
|
super().__init__(daemon=daemon)
|
||||||
|
self.stf = stf_client
|
||||||
|
self.serial = serial
|
||||||
|
self.params = params or {}
|
||||||
|
self._stop_flag = threading.Event()
|
||||||
|
self.d = None # u2.Device,run_task 里用
|
||||||
|
# 通用进度字段(子类通过 set_progress 上报)
|
||||||
|
self._progress = {"done": 0, "total": 0, "unit": "",
|
||||||
|
"action_counts": {}}
|
||||||
|
# 通用运行时长终止(子类设 max_duration>0 启用)
|
||||||
|
self.max_duration = 0 # 秒,0=不限时
|
||||||
|
self._start_time = 0.0 # run_task 开始时间戳
|
||||||
|
|
||||||
|
def _start_timer(self):
|
||||||
|
"""子类在 run_task 开头调用,启动运行时长计时。"""
|
||||||
|
self._start_time = time.time()
|
||||||
|
|
||||||
|
def is_time_up(self):
|
||||||
|
"""是否已达最大运行时长。max_duration=0 时永远返回 False。"""
|
||||||
|
if self.max_duration <= 0 or self._start_time <= 0:
|
||||||
|
return False
|
||||||
|
return (time.time() - self._start_time) >= self.max_duration
|
||||||
|
|
||||||
|
def elapsed(self):
|
||||||
|
"""已运行时长(秒)。run_task 未启动时返回 0。"""
|
||||||
|
if self._start_time <= 0:
|
||||||
|
return 0
|
||||||
|
return int(time.time() - self._start_time)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""外部调用,请求停止(子类应在循环里检查 self.stopped())。"""
|
||||||
|
self._stop_flag.set()
|
||||||
|
|
||||||
|
def stopped(self):
|
||||||
|
"""子类在循环里调用,判断是否被要求停止。"""
|
||||||
|
return self._stop_flag.is_set()
|
||||||
|
|
||||||
|
def set_action(self, action):
|
||||||
|
"""上报当前动作(子类用)。同时刷新心跳。"""
|
||||||
|
_update_status(self.serial, current_action=action)
|
||||||
|
|
||||||
|
def set_progress(self, **fields):
|
||||||
|
"""上报进度字段(子类用)。同时刷新心跳。
|
||||||
|
|
||||||
|
通用字段(前端会解析展示):
|
||||||
|
done (int) — 已完成数量
|
||||||
|
total (int) — 总数量
|
||||||
|
unit (str) — 计数单位("视频"/"轮次"/"条")
|
||||||
|
action_counts (dict) — 操作计数 {"like": 3, "comment": 1}
|
||||||
|
|
||||||
|
也可传任意业务字段,会合并到 progress 里,前端原样展示。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
self.set_progress(done=5, total=80, unit="视频",
|
||||||
|
action_counts={"like": 3, "comment": 1})
|
||||||
|
"""
|
||||||
|
self._progress.update(fields)
|
||||||
|
# 同步到全局状态字典,前端通过 get_status 读取
|
||||||
|
_update_status(self.serial, progress=dict(self._progress))
|
||||||
|
|
||||||
|
def heartbeat(self):
|
||||||
|
"""手动刷新心跳。长操作中间调,避免看门狗误判卡死。"""
|
||||||
|
_update_status(self.serial, last_heartbeat=time.time())
|
||||||
|
|
||||||
|
# ================== 子类实现/可选钩子 ==================
|
||||||
|
def run_task(self, d):
|
||||||
|
"""子类实现:拿到 u2.Device d,做具体业务。
|
||||||
|
|
||||||
|
不需要管 STF 占用/释放、异常、状态上报,基类已处理。
|
||||||
|
循环里记得检查 self.stopped() 以便响应停止请求。
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def setup(self, d):
|
||||||
|
"""run_task 前的初始化钩子。默认空,子类按需重写。
|
||||||
|
|
||||||
|
典型用途:启动 app、检查登录状态、进入主页。
|
||||||
|
抛异常会中断任务(走 on_error)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def teardown(self, d):
|
||||||
|
"""run_task 后的清理钩子(无论成功失败都调)。默认空,子类按需重写。
|
||||||
|
|
||||||
|
典型用途:退出 app、清理临时文件。
|
||||||
|
抛异常只记日志,不影响主流程。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def on_error(self, d, error):
|
||||||
|
"""run_task 抛异常时的回调。默认空,子类按需重写。
|
||||||
|
|
||||||
|
典型用途:截图保存现场、上报错误详情。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ================== 基类主循环(不要重写) ==================
|
||||||
|
def run(self):
|
||||||
|
"""基类主循环:不要重写。子类实现 run_task + 可选钩子。"""
|
||||||
|
device = STFDevice(self.stf, serial=self.serial)
|
||||||
|
_update_status(self.serial, status="connecting", stf_occupied=False,
|
||||||
|
last_error="", remote_adb_url="", model="",
|
||||||
|
current_action="")
|
||||||
|
try:
|
||||||
|
device.acquire()
|
||||||
|
remote = device.remote_adb_url
|
||||||
|
# u2.connect 可能 hang 住(atx-agent 无响应),用线程池+超时保护
|
||||||
|
self.d = self._u2_connect_with_timeout(remote)
|
||||||
|
if self.d is None:
|
||||||
|
raise STFError(f"u2.connect 超时 {_U2_CONNECT_TIMEOUT}s,atx-agent 无响应", "offline")
|
||||||
|
_log.info(f"[{self.serial}] u2 连接成功")
|
||||||
|
model = ""
|
||||||
|
try:
|
||||||
|
info = self._u2_info_with_timeout()
|
||||||
|
if info:
|
||||||
|
model = info.get("productName") or ""
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_update_status(self.serial, status="running", stf_occupied=True,
|
||||||
|
remote_adb_url=remote, model=model)
|
||||||
|
|
||||||
|
# setup 钩子
|
||||||
|
try:
|
||||||
|
self.setup(self.d)
|
||||||
|
except Exception as e:
|
||||||
|
_log.error(f"[{self.serial}] setup 异常: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# 业务逻辑
|
||||||
|
_log.info(f"[{self.serial}] 进入 run_task,开始执行业务逻辑")
|
||||||
|
self.run_task(self.d)
|
||||||
|
_log.info(f"[{self.serial}] run_task 执行完毕")
|
||||||
|
|
||||||
|
# teardown 钩子(成功路径)
|
||||||
|
try:
|
||||||
|
self.teardown(self.d)
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"[{self.serial}] teardown 异常: {e}")
|
||||||
|
|
||||||
|
if not self.stopped():
|
||||||
|
_update_status(self.serial, status="done")
|
||||||
|
|
||||||
|
except DeviceOfflineError as e:
|
||||||
|
# 设备掉线,不重试
|
||||||
|
_log.error(f"[{self.serial}] 设备离线: {e}")
|
||||||
|
_update_status(self.serial, status="error", last_error=str(e))
|
||||||
|
self._safe_on_error(e)
|
||||||
|
except Exception as e:
|
||||||
|
err_str = str(e)
|
||||||
|
# 识别 Windows TCP 端口耗尽(WinError 10048)或 adb server 连接失败
|
||||||
|
# 这类错误是临时性的,重试时需要更长退避等端口释放
|
||||||
|
is_transient = ("10048" in err_str
|
||||||
|
or "connect to adb server failed" in err_str
|
||||||
|
or "Unable to connect to uiautomator2 server" in err_str)
|
||||||
|
if is_transient:
|
||||||
|
_log.error(f"[{self.serial}] ADB/u2 连接错误(可能端口耗尽): {e}")
|
||||||
|
_update_status(self.serial, status="error",
|
||||||
|
last_error=f"[transient] {e}")
|
||||||
|
else:
|
||||||
|
_log.error(f"[{self.serial}] 异常: {e}", exc_info=True)
|
||||||
|
_update_status(self.serial, status="error", last_error=str(e))
|
||||||
|
self._safe_on_error(e)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
device.release()
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"[{self.serial}] release 异常: {e}")
|
||||||
|
# 只在仍在运行/连接中时才标记 released;
|
||||||
|
# done/error/failed 等终态保持不变,避免覆盖业务结果
|
||||||
|
with _WORKERS_LOCK:
|
||||||
|
cur = _WORKERS.get(self.serial, {}).get("status")
|
||||||
|
if cur in ("running", "connecting"):
|
||||||
|
_update_status(self.serial, stf_occupied=False, status="released")
|
||||||
|
else:
|
||||||
|
_update_status(self.serial, stf_occupied=False)
|
||||||
|
|
||||||
|
def _u2_connect_with_timeout(self, remote):
|
||||||
|
"""u2.connect 带超时保护,避免 atx-agent 无响应时永久 hang。"""
|
||||||
|
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||||
|
fut = pool.submit(u2.connect, remote)
|
||||||
|
try:
|
||||||
|
return fut.result(timeout=_U2_CONNECT_TIMEOUT)
|
||||||
|
except FuturesTimeout:
|
||||||
|
_log.error(f"[{self.serial}] u2.connect 超时 {_U2_CONNECT_TIMEOUT}s")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
_log.error(f"[{self.serial}] u2.connect 异常: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _u2_info_with_timeout(self):
|
||||||
|
"""self.d.info 带超时保护,避免 atx-agent 无响应时永久 hang。"""
|
||||||
|
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||||
|
fut = pool.submit(lambda: self.d.info)
|
||||||
|
try:
|
||||||
|
return fut.result(timeout=_U2_INFO_TIMEOUT)
|
||||||
|
except FuturesTimeout:
|
||||||
|
_log.warning(f"[{self.serial}] d.info 超时 {_U2_INFO_TIMEOUT}s,跳过型号获取")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"[{self.serial}] d.info 异常: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _safe_on_error(self, error):
|
||||||
|
"""安全调用 on_error 钩子,不让钩子异常影响主流程。"""
|
||||||
|
try:
|
||||||
|
self.on_error(self.d, error)
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"[{self.serial}] on_error 钩子异常: {e}")
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""统一日志系统:按模块分文件,带时间戳和级别,自动滚动。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
from core.logger import get_logger
|
||||||
|
log = get_logger("task") # 写 logs/task.log + 控制台
|
||||||
|
log.info("开始任务")
|
||||||
|
log.error("失败", exc_info=True)
|
||||||
|
|
||||||
|
模块划分:
|
||||||
|
core — 核心程序(STF/adb/worker/task_manager)
|
||||||
|
task — 任务执行(worker 业务逻辑)
|
||||||
|
web — web_server 请求/管理
|
||||||
|
action — 操作执行(点赞/评论等)
|
||||||
|
|
||||||
|
所有日志同时输出到控制台和对应文件,logs/ 目录自动创建。
|
||||||
|
文件按 10MB 滚动,保留 5 个历史文件。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
|
_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logs")
|
||||||
|
os.makedirs(_LOG_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# 已创建的 logger,避免重复添加 handler
|
||||||
|
_LOGGERS = {}
|
||||||
|
|
||||||
|
# 模块 → 文件名映射
|
||||||
|
_MODULE_FILES = {
|
||||||
|
"core": "core.log",
|
||||||
|
"task": "task.log",
|
||||||
|
"web": "web.log",
|
||||||
|
"action": "action.log",
|
||||||
|
}
|
||||||
|
|
||||||
|
_FORMAT = "%(asctime)s [%(levelname)s] [%(name)s] %(message)s"
|
||||||
|
_DATE_FMT = "%Y-%m-%d %H:%M:%S"
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name="core"):
|
||||||
|
"""获取指定模块的 logger。
|
||||||
|
|
||||||
|
name: 模块名(core/task/web/action),决定写哪个文件。
|
||||||
|
也可传子模块名如 "task.douyin",会归到 task.log。
|
||||||
|
返回配置好的 logging.Logger。
|
||||||
|
"""
|
||||||
|
if name in _LOGGERS:
|
||||||
|
return _LOGGERS[name]
|
||||||
|
|
||||||
|
# 归类到对应文件:取顶层模块名
|
||||||
|
top = name.split(".")[0]
|
||||||
|
filename = _MODULE_FILES.get(top, "core.log")
|
||||||
|
|
||||||
|
log = logging.getLogger(name)
|
||||||
|
log.setLevel(logging.DEBUG)
|
||||||
|
# 避免向 root logger 传播导致重复输出
|
||||||
|
log.propagate = False
|
||||||
|
|
||||||
|
fmt = logging.Formatter(_FORMAT, _DATE_FMT)
|
||||||
|
|
||||||
|
# 文件 handler:10MB 滚动,保留 5 个
|
||||||
|
file_path = os.path.join(_LOG_DIR, filename)
|
||||||
|
fh = RotatingFileHandler(file_path, maxBytes=10 * 1024 * 1024,
|
||||||
|
backupCount=5, encoding="utf-8")
|
||||||
|
fh.setLevel(logging.DEBUG)
|
||||||
|
fh.setFormatter(fmt)
|
||||||
|
|
||||||
|
# 控制台 handler
|
||||||
|
ch = logging.StreamHandler()
|
||||||
|
ch.setLevel(logging.INFO)
|
||||||
|
ch.setFormatter(fmt)
|
||||||
|
|
||||||
|
log.addHandler(fh)
|
||||||
|
log.addHandler(ch)
|
||||||
|
|
||||||
|
_LOGGERS[name] = log
|
||||||
|
return log
|
||||||
|
|
||||||
|
|
||||||
|
# 兼容旧代码的 print 风格:紧急排查时可临时用
|
||||||
|
def log_print(msg, level="info", module="core"):
|
||||||
|
"""print 的替代品,转发到 logging。"""
|
||||||
|
log = get_logger(module)
|
||||||
|
getattr(log, level, log.info)(msg)
|
||||||
+242
@@ -0,0 +1,242 @@
|
|||||||
|
"""SQLAlchemy 数据模型 + db 初始化(用户/设备分组/任务计划)。
|
||||||
|
|
||||||
|
为什么放这里:
|
||||||
|
web_server 和 task_manager 都要访问这些模型,放 core/ 避免循环依赖。
|
||||||
|
web_server 负责初始化 db(app context),task_manager 只读写数据。
|
||||||
|
|
||||||
|
模型说明:
|
||||||
|
User — 后台用户(Flask-Login 认证)
|
||||||
|
DeviceGroup — 设备分组(持久化,替代旧 groups.json)
|
||||||
|
TaskJob — 任务计划(持久化,替代旧 jobs.json)
|
||||||
|
|
||||||
|
字段设计:
|
||||||
|
DeviceGroup.serials 用 JSON 存列表(SQLite 无数组类型)
|
||||||
|
TaskJob.target/params/schedule/retry 用 JSON 存嵌套结构
|
||||||
|
Flask-Admin 默认用 TextArea 编辑 JSON,够用且通用
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_login import UserMixin
|
||||||
|
|
||||||
|
from core.logger import get_logger
|
||||||
|
|
||||||
|
_log = get_logger("core.models")
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
|
||||||
|
|
||||||
|
class User(UserMixin, db.Model):
|
||||||
|
"""后台用户。"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||||
|
password_hash = db.Column(db.String(120), nullable=False)
|
||||||
|
is_admin = db.Column(db.Boolean, default=True)
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
self.password_hash = hashlib.sha256(password.encode()).hexdigest()
|
||||||
|
|
||||||
|
def check_password(self, password):
|
||||||
|
return self.password_hash == hashlib.sha256(password.encode()).hexdigest()
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<User {self.username}>"
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceGroup(db.Model):
|
||||||
|
"""设备分组(替代旧 groups.json 的 DeviceGroup 类)。"""
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(80), unique=True, nullable=False)
|
||||||
|
serials = db.Column(db.Text, default="[]") # JSON 列表
|
||||||
|
description = db.Column(db.Text, default="")
|
||||||
|
|
||||||
|
def get_serials(self):
|
||||||
|
try:
|
||||||
|
return json.loads(self.serials or "[]")
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def set_serials(self, lst):
|
||||||
|
self.serials = json.dumps(lst or [], ensure_ascii=False)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"name": self.name, "serials": self.get_serials(),
|
||||||
|
"description": self.description or ""}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Group {self.name}>"
|
||||||
|
|
||||||
|
|
||||||
|
class TaskJob(db.Model):
|
||||||
|
"""任务计划(替代旧 jobs.json 的 TaskJob 类)。
|
||||||
|
|
||||||
|
字段含义和旧 TaskJob 一致,只是持久化方式从 JSON 文件改到 SQLite。
|
||||||
|
"""
|
||||||
|
id = db.Column(db.String(32), primary_key=True) # uuid 前 8 位
|
||||||
|
name = db.Column(db.String(120), nullable=False)
|
||||||
|
task_type = db.Column(db.String(60), default="douyin_nurture")
|
||||||
|
target = db.Column(db.Text, default='{"mode":"all"}') # JSON
|
||||||
|
params = db.Column(db.Text, default="{}") # JSON
|
||||||
|
schedule = db.Column(db.Text, default='{"mode":"once"}') # JSON
|
||||||
|
retry = db.Column(db.Text, default='{"max_attempts":1,"delay":60}') # JSON
|
||||||
|
enabled = db.Column(db.Boolean, default=True)
|
||||||
|
|
||||||
|
def _load_json(self, field, default):
|
||||||
|
try:
|
||||||
|
return json.loads(getattr(self, field) or default)
|
||||||
|
except Exception:
|
||||||
|
return json.loads(default)
|
||||||
|
|
||||||
|
def _dump_json(self, field, value):
|
||||||
|
setattr(self, field, json.dumps(value or {}, ensure_ascii=False))
|
||||||
|
|
||||||
|
def get_target(self): return self._load_json("target", '{"mode":"all"}')
|
||||||
|
def set_target(self, v): self._dump_json("target", v)
|
||||||
|
def get_params(self): return self._load_json("params", "{}")
|
||||||
|
def set_params(self, v): self._dump_json("params", v)
|
||||||
|
def get_schedule(self): return self._load_json("schedule", '{"mode":"once"}')
|
||||||
|
def set_schedule(self, v): self._dump_json("schedule", v)
|
||||||
|
def get_retry(self): return self._load_json("retry", '{"max_attempts":1,"delay":60}')
|
||||||
|
def set_retry(self, v): self._dump_json("retry", v)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"id": self.id, "name": self.name, "task_type": self.task_type,
|
||||||
|
"target": self.get_target(), "params": self.get_params(),
|
||||||
|
"schedule": self.get_schedule(), "retry": self.get_retry(),
|
||||||
|
"enabled": self.enabled}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<TaskJob {self.name}>"
|
||||||
|
|
||||||
|
|
||||||
|
class ApkFile(db.Model):
|
||||||
|
"""上传的 APK 文件元信息(应用管理功能)。"""
|
||||||
|
id = db.Column(db.String(32), primary_key=True) # uuid 前 8 位
|
||||||
|
filename = db.Column(db.String(255), nullable=False) # 磁盘文件名 (id.apk)
|
||||||
|
display_name = db.Column(db.String(120), default="") # 应用名
|
||||||
|
package_name = db.Column(db.String(200), default="") # 包名
|
||||||
|
version_name = db.Column(db.String(50), default="") # 版本号
|
||||||
|
version_code = db.Column(db.Integer, default=0) # 版本码
|
||||||
|
size = db.Column(db.Integer, default=0) # 文件大小(字节)
|
||||||
|
upload_time = db.Column(db.String(20), default="") # 上传时间
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"id": self.id, "filename": self.filename,
|
||||||
|
"display_name": self.display_name or "",
|
||||||
|
"package_name": self.package_name or "",
|
||||||
|
"version_name": self.version_name or "",
|
||||||
|
"version_code": self.version_code or 0,
|
||||||
|
"size": self.size or 0,
|
||||||
|
"upload_time": self.upload_time or ""}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<ApkFile {self.display_name}>"
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(app):
|
||||||
|
"""在 Flask app context 里初始化数据库 + 创建默认管理员。
|
||||||
|
|
||||||
|
web_server 启动时调用。自动迁移旧 groups.json/jobs.json 到 SQLite。
|
||||||
|
"""
|
||||||
|
db.init_app(app)
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
_ensure_default_admin()
|
||||||
|
_migrate_old_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_default_admin():
|
||||||
|
"""首次启动创建默认管理员 admin/admin123。"""
|
||||||
|
if not User.query.filter_by(username="admin").first():
|
||||||
|
u = User(username="admin", is_admin=True)
|
||||||
|
u.set_password("admin123")
|
||||||
|
db.session.add(u)
|
||||||
|
db.session.commit()
|
||||||
|
_log.info("已创建默认管理员 admin/admin123,请及时改密码")
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_old_json():
|
||||||
|
"""把旧 groups.json / jobs.json 迁移到 SQLite(仅首次)。
|
||||||
|
|
||||||
|
迁移策略:
|
||||||
|
1. 仅当数据库对应表为空时才迁移(首次启动场景)
|
||||||
|
2. 迁移成功后立即把 JSON 文件重命名为 <name>.json.migrated,
|
||||||
|
保留备份但永不再迁移——避免"用户删完全部任务后重启又从旧文件复原"
|
||||||
|
3. 若数据库已有数据但 JSON 文件仍在(历史残留),直接归档,
|
||||||
|
避免将来数据库被清空后又触发迁移导致已删任务复原
|
||||||
|
4. 迁移任务时顺手剔除已废弃的 comment action 配置,
|
||||||
|
避免 _load 阶段还要再写回一次
|
||||||
|
"""
|
||||||
|
from config import DATA_DIR
|
||||||
|
|
||||||
|
# 迁移分组
|
||||||
|
groups_file = os.path.join(DATA_DIR, "groups.json")
|
||||||
|
if os.path.exists(groups_file) and DeviceGroup.query.count() == 0:
|
||||||
|
try:
|
||||||
|
with open(groups_file, encoding="utf-8") as f:
|
||||||
|
groups = json.load(f)
|
||||||
|
for g in groups:
|
||||||
|
if not DeviceGroup.query.filter_by(name=g["name"]).first():
|
||||||
|
row = DeviceGroup(name=g["name"],
|
||||||
|
description=g.get("description", ""))
|
||||||
|
row.set_serials(g.get("serials", []))
|
||||||
|
db.session.add(row)
|
||||||
|
db.session.commit()
|
||||||
|
_log.info(f"已迁移 {len(groups)} 个分组到数据库")
|
||||||
|
_archive_migrated(groups_file)
|
||||||
|
except Exception as e:
|
||||||
|
_log.error(f"迁移 groups.json 失败: {e}")
|
||||||
|
|
||||||
|
# 迁移任务
|
||||||
|
jobs_file = os.path.join(DATA_DIR, "jobs.json")
|
||||||
|
if os.path.exists(jobs_file) and TaskJob.query.count() == 0:
|
||||||
|
try:
|
||||||
|
with open(jobs_file, encoding="utf-8") as f:
|
||||||
|
jobs = json.load(f)
|
||||||
|
for j in jobs:
|
||||||
|
if TaskJob.query.get(j["id"]):
|
||||||
|
continue
|
||||||
|
params = j.get("params", {})
|
||||||
|
# 剔除已废弃的 comment action,避免带入数据库
|
||||||
|
actions = params.get("actions", {})
|
||||||
|
if "comment" in actions:
|
||||||
|
del actions["comment"]
|
||||||
|
_log.info(f"迁移任务 {j.get('name')}: 已剔除废弃的 comment 配置")
|
||||||
|
row = TaskJob(id=j["id"], name=j["name"],
|
||||||
|
task_type=j.get("task_type", "douyin_nurture"),
|
||||||
|
enabled=j.get("enabled", True))
|
||||||
|
row.set_target(j.get("target", {"mode": "all"}))
|
||||||
|
row.set_params(params)
|
||||||
|
row.set_schedule(j.get("schedule", {"mode": "once"}))
|
||||||
|
row.set_retry(j.get("retry", {"max_attempts": 1, "delay": 60}))
|
||||||
|
db.session.add(row)
|
||||||
|
db.session.commit()
|
||||||
|
_log.info(f"已迁移 {len(jobs)} 个任务到数据库")
|
||||||
|
_archive_migrated(jobs_file)
|
||||||
|
except Exception as e:
|
||||||
|
_log.error(f"迁移 jobs.json 失败: {e}")
|
||||||
|
|
||||||
|
# 清理历史残留:数据库已有数据但 JSON 文件仍在(修复前遗留下来的文件)。
|
||||||
|
# 不归档的话,用户哪天删光所有任务/分组,count==0 又会触发迁移导致已删数据复原。
|
||||||
|
if DeviceGroup.query.count() > 0 and os.path.exists(groups_file):
|
||||||
|
_archive_migrated(groups_file)
|
||||||
|
if TaskJob.query.count() > 0 and os.path.exists(jobs_file):
|
||||||
|
_archive_migrated(jobs_file)
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_migrated(file_path):
|
||||||
|
"""把已迁移的 JSON 文件重命名为 <name>.migrated,避免下次重启再迁移。
|
||||||
|
|
||||||
|
保留备份以便排查,但 _migrate_old_json 的 exists 判断会跳过它。
|
||||||
|
重命名失败只警告不抛出,不影响启动。
|
||||||
|
"""
|
||||||
|
archived = file_path + ".migrated"
|
||||||
|
try:
|
||||||
|
if os.path.exists(archived):
|
||||||
|
os.remove(archived)
|
||||||
|
os.rename(file_path, archived)
|
||||||
|
_log.info(f"已归档迁移文件: {os.path.basename(file_path)} -> {os.path.basename(archived)}")
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"归档 {file_path} 失败(不影响运行): {e}")
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""OpenSTF REST API 客户端封装。
|
||||||
|
|
||||||
|
负责:设备列表查询、占用、远程 ADB 隧道建立、释放。
|
||||||
|
不负责实际 UI 操作(那是 DeviceWorker 的事)。
|
||||||
|
|
||||||
|
健壮性设计:
|
||||||
|
- 所有请求带 timeout,避免 STF 网关 504 时长时间卡死
|
||||||
|
- remoteConnect 带重试,STF provider 偶尔慢启动
|
||||||
|
- 友好错误分类:设备离线 / 网络不通 / STF 内部错误,便于上层决策
|
||||||
|
- 占用冲突自动重试(STF 偶发 400 "device already in use")
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from config import STF_URL, STF_TOKEN
|
||||||
|
from core.logger import get_logger
|
||||||
|
|
||||||
|
_log = get_logger("core.stf")
|
||||||
|
|
||||||
|
# 请求超时(秒)。connect 超时 + read 超时
|
||||||
|
_TIMEOUT = (5, 15)
|
||||||
|
# remoteConnect 重试次数(STF provider 慢启动时需要重试)
|
||||||
|
_RC_RETRIES = 3
|
||||||
|
# remoteConnect 重试间隔
|
||||||
|
_RC_DELAY = 2
|
||||||
|
|
||||||
|
|
||||||
|
class STFError(Exception):
|
||||||
|
"""STF 相关错误基类。"""
|
||||||
|
|
||||||
|
def __init__(self, message, code=""):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code # 错误码:offline / network / conflict / server / unknown
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceOfflineError(STFError):
|
||||||
|
"""设备离线/不可用。"""
|
||||||
|
|
||||||
|
|
||||||
|
class STFNetworkError(STFError):
|
||||||
|
"""STF 服务不可达。"""
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceConflictError(STFError):
|
||||||
|
"""设备已被占用。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _headers():
|
||||||
|
return {"Authorization": f"Bearer {STF_TOKEN}"}
|
||||||
|
|
||||||
|
|
||||||
|
class STFClient:
|
||||||
|
"""OpenSTF REST API 封装。"""
|
||||||
|
|
||||||
|
def __init__(self, base_url=STF_URL, token=STF_TOKEN):
|
||||||
|
self.base_url = base_url
|
||||||
|
self.headers = _headers()
|
||||||
|
|
||||||
|
# ================== 设备查询 ==================
|
||||||
|
def list_all_devices(self):
|
||||||
|
"""返回 STF 上所有设备(含状态)。"""
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{self.base_url}/api/v1/devices",
|
||||||
|
headers=self.headers, timeout=_TIMEOUT)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("devices", [])
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
raise STFNetworkError(f"STF 服务不可达: {e}", "network") from e
|
||||||
|
except requests.exceptions.Timeout as e:
|
||||||
|
raise STFNetworkError(f"STF 请求超时: {e}", "network") from e
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
_log.error(f"获取设备列表失败: {e}")
|
||||||
|
raise STFError(f"获取设备列表失败: {e}", "server") from e
|
||||||
|
|
||||||
|
def list_free_devices(self):
|
||||||
|
"""返回可占用的空闲设备。"""
|
||||||
|
return [d for d in self.list_all_devices()
|
||||||
|
if d.get("present") and d.get("ready")
|
||||||
|
and not d.get("using") and d.get("owner") is None]
|
||||||
|
|
||||||
|
def list_my_devices(self):
|
||||||
|
"""返回当前账户已占用的设备。"""
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{self.base_url}/api/v1/user/devices",
|
||||||
|
headers=self.headers, timeout=_TIMEOUT)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("devices", [])
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
_log.error(f"获取已占用设备失败: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# ================== 占用/释放 ==================
|
||||||
|
def occupy(self, serial, retries=2):
|
||||||
|
"""占用设备。返回 (ok, msg)。
|
||||||
|
|
||||||
|
冲突(已被占用)时自动重试,偶发 400 "already in use" 可能是脏状态。
|
||||||
|
"""
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
r = requests.post(f"{self.base_url}/api/v1/user/devices/{serial}",
|
||||||
|
headers=self.headers, timeout=_TIMEOUT)
|
||||||
|
if r.status_code in (200, 201):
|
||||||
|
return True, r.text[:200]
|
||||||
|
if r.status_code == 400 and "already" in r.text.lower():
|
||||||
|
# 已被占用(可能是自己之前占用没释放干净),尝试先释放再占
|
||||||
|
_log.warning(f"{serial} 占用冲突,尝试清理后重试 ({attempt}/{retries})")
|
||||||
|
try:
|
||||||
|
requests.delete(f"{self.base_url}/api/v1/user/devices/{serial}",
|
||||||
|
headers=self.headers, timeout=_TIMEOUT)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
return False, f"HTTP {r.status_code}: {r.text[:200]}"
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
_log.warning(f"{serial} 占用请求异常 ({attempt}/{retries}): {e}")
|
||||||
|
if attempt < retries:
|
||||||
|
time.sleep(1)
|
||||||
|
return False, str(e)
|
||||||
|
return False, "占用冲突重试耗尽"
|
||||||
|
|
||||||
|
def release(self, serial):
|
||||||
|
"""释放设备占用(含远程断开)。"""
|
||||||
|
self.remote_disconnect(serial)
|
||||||
|
try:
|
||||||
|
requests.delete(f"{self.base_url}/api/v1/user/devices/{serial}",
|
||||||
|
headers=self.headers, timeout=_TIMEOUT)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
_log.warning(f"释放 {serial} 请求失败: {e}")
|
||||||
|
|
||||||
|
def release_all_mine(self):
|
||||||
|
"""释放当前账户占用的所有设备(清理用)。"""
|
||||||
|
released = []
|
||||||
|
for d in self.list_my_devices():
|
||||||
|
serial = d["serial"]
|
||||||
|
try:
|
||||||
|
self.release(serial)
|
||||||
|
released.append(serial)
|
||||||
|
_log.info(f"已释放 {serial}")
|
||||||
|
except Exception as e:
|
||||||
|
_log.error(f"释放 {serial} 失败: {e}")
|
||||||
|
return released
|
||||||
|
|
||||||
|
# ================== 远程 ADB 隧道 ==================
|
||||||
|
def remote_connect(self, serial):
|
||||||
|
"""建立远程 ADB 隧道,返回 remoteConnectUrl。
|
||||||
|
|
||||||
|
STF provider 慢启动或设备掉线时会 504,这里带重试。
|
||||||
|
设备真离线时快速失败,不长时间卡住 worker。
|
||||||
|
"""
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(1, _RC_RETRIES + 1):
|
||||||
|
try:
|
||||||
|
conn = requests.post(
|
||||||
|
f"{self.base_url}/api/v1/user/devices/{serial}/remoteConnect",
|
||||||
|
headers=self.headers, timeout=_TIMEOUT,
|
||||||
|
)
|
||||||
|
# 504 = STF 网关等 provider 响应超时,通常是设备掉线或 provider 卡死
|
||||||
|
if conn.status_code == 504:
|
||||||
|
last_err = f"STF 网关超时(504),设备可能掉线"
|
||||||
|
_log.warning(f"{serial} remoteConnect 504 ({attempt}/{_RC_RETRIES})")
|
||||||
|
if attempt < _RC_RETRIES:
|
||||||
|
time.sleep(_RC_DELAY)
|
||||||
|
continue
|
||||||
|
raise DeviceOfflineError(
|
||||||
|
f"{serial} remoteConnect 超时,设备可能掉线或 provider 卡死",
|
||||||
|
"offline",
|
||||||
|
)
|
||||||
|
conn.raise_for_status()
|
||||||
|
data = conn.json()
|
||||||
|
if not data.get("success"):
|
||||||
|
raise STFError(f"远程连接失败: {data.get('description')}", "server")
|
||||||
|
url = (data.get("remoteConnectUrl") or data.get("remoteAdbUrl")
|
||||||
|
or data.get("remote_adb_url") or data.get("adbUrl")
|
||||||
|
or data.get("url"))
|
||||||
|
if not url:
|
||||||
|
raise STFError("STF 返回成功但无 remoteConnectUrl", "server")
|
||||||
|
return url
|
||||||
|
except DeviceOfflineError:
|
||||||
|
raise
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
last_err = str(e)
|
||||||
|
_log.warning(f"{serial} remoteConnect 网络异常 ({attempt}/{_RC_RETRIES}): {e}")
|
||||||
|
if attempt < _RC_RETRIES:
|
||||||
|
time.sleep(_RC_DELAY)
|
||||||
|
except requests.exceptions.Timeout as e:
|
||||||
|
last_err = str(e)
|
||||||
|
_log.warning(f"{serial} remoteConnect 超时 ({attempt}/{_RC_RETRIES}): {e}")
|
||||||
|
if attempt < _RC_RETRIES:
|
||||||
|
time.sleep(_RC_DELAY)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
last_err = str(e)
|
||||||
|
_log.error(f"{serial} remoteConnect 请求异常: {e}")
|
||||||
|
raise STFError(f"远程连接请求失败: {e}", "server") from e
|
||||||
|
# 重试耗尽
|
||||||
|
raise DeviceOfflineError(
|
||||||
|
f"{serial} remoteConnect 重试 {_RC_RETRIES} 次失败: {last_err}",
|
||||||
|
"offline",
|
||||||
|
)
|
||||||
|
|
||||||
|
def remote_disconnect(self, serial):
|
||||||
|
"""断开远程 ADB 隧道。"""
|
||||||
|
try:
|
||||||
|
requests.post(
|
||||||
|
f"{self.base_url}/api/v1/user/devices/{serial}/remoteDisconnect",
|
||||||
|
headers=self.headers, timeout=_TIMEOUT,
|
||||||
|
)
|
||||||
|
except requests.exceptions.RequestException:
|
||||||
|
pass # 断开失败不影响主流程
|
||||||
@@ -0,0 +1,734 @@
|
|||||||
|
"""通用任务管理框架:任务类型注册 + 设备分组 + 任务计划 + 定时调度 + 重试 + 持久化。
|
||||||
|
|
||||||
|
设计目标:可扩展,未来加非抖音任务只需注册新的 Task 类。
|
||||||
|
|
||||||
|
核心概念:
|
||||||
|
TaskType — 任务类型(如"抖音养号"),可注册,含默认参数和 worker 工厂
|
||||||
|
DeviceGroup — 设备分组,持久化到 SQLite(core.models.DeviceGroup)
|
||||||
|
TaskJob — 任务计划(类型+目标+参数+调度+重试),持久化到 SQLite(core.models.TaskJob)
|
||||||
|
TaskManager — 统管调度器、分组、任务、运行实例、状态
|
||||||
|
|
||||||
|
定时:APScheduler BackgroundScheduler,cron 触发后为每个目标设备起 worker。
|
||||||
|
支持三种调度模式:
|
||||||
|
once — 手动/立即执行,不注册 cron
|
||||||
|
cron — 仅注册启动 cron(到点启动 worker)
|
||||||
|
cron_stop — 注册启动 cron + 停止 cron(到点启动,到点停止)
|
||||||
|
重试:单设备 worker 失败后按 retry.max_attempts 重试,间隔 retry.delay。
|
||||||
|
并发:同一 serial 同时只允许一个 worker,避免冲突。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
|
||||||
|
from config import DATA_DIR
|
||||||
|
from core.logger import get_logger
|
||||||
|
from core.models import db, DeviceGroup as GroupRow, TaskJob as JobRow
|
||||||
|
from .stf_client import STFClient, DeviceOfflineError
|
||||||
|
from .adb_helper import get_foreground_app, adb_connect_light, adb_disconnect
|
||||||
|
from .device_worker import (
|
||||||
|
get_all_worker_status, _update_status, _remove_worker,
|
||||||
|
_WORKERS, _WORKERS_LOCK,
|
||||||
|
start_watchdog, stop_watchdog,
|
||||||
|
)
|
||||||
|
from tasks import list_task_types, get_task_class
|
||||||
|
|
||||||
|
_log = get_logger("core.tm")
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 设备分组 ==================
|
||||||
|
class DeviceGroup:
|
||||||
|
def __init__(self, name, serials=None, description=""):
|
||||||
|
self.name = name
|
||||||
|
self.serials = serials or []
|
||||||
|
self.description = description
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"name": self.name, "serials": self.serials, "description": self.description}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d):
|
||||||
|
return cls(d["name"], d.get("serials", []), d.get("description", ""))
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 任务计划 ==================
|
||||||
|
class TaskJob:
|
||||||
|
"""一个任务计划:什么任务、跑哪些设备、什么参数、何时跑、失败重试策略。"""
|
||||||
|
|
||||||
|
def __init__(self, job_id=None, name="", task_type="douyin_nurture",
|
||||||
|
target=None, params=None, schedule=None, retry=None, enabled=True):
|
||||||
|
self.id = job_id or uuid.uuid4().hex[:8]
|
||||||
|
self.name = name
|
||||||
|
self.task_type = task_type
|
||||||
|
# target: {"mode": "all"|"group"|"serial", "group_name": "", "serial": ""}
|
||||||
|
self.target = target or {"mode": "all"}
|
||||||
|
self.params = params or {}
|
||||||
|
self.schedule = schedule or {"mode": "once"} # {"mode":"once"|"cron", "cron":"0 9 * * *"}
|
||||||
|
self.retry = retry or {"max_attempts": 1, "delay": 60}
|
||||||
|
self.enabled = enabled
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"id": self.id, "name": self.name, "task_type": self.task_type,
|
||||||
|
"target": self.target, "params": self.params,
|
||||||
|
"schedule": self.schedule, "retry": self.retry, "enabled": self.enabled}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d):
|
||||||
|
return cls(d["id"], d["name"], d["task_type"], d.get("target"),
|
||||||
|
d.get("params"), d.get("schedule"), d.get("retry"), d.get("enabled", True))
|
||||||
|
|
||||||
|
def resolve_serials(self, manager):
|
||||||
|
"""根据 target 解析出实际要跑的 serial 列表。"""
|
||||||
|
mode = self.target.get("mode", "all")
|
||||||
|
if mode == "serial":
|
||||||
|
return [self.target["serial"]]
|
||||||
|
if mode == "group":
|
||||||
|
g = manager.groups.get(self.target.get("group_name"))
|
||||||
|
return list(g.serials) if g else []
|
||||||
|
# all:返回所有空闲设备
|
||||||
|
try:
|
||||||
|
return [d["serial"] for d in manager.stf.list_free_devices()]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 前台 App 扫描器 ==================
|
||||||
|
class _ForegroundScanner:
|
||||||
|
"""前台 App 扫描器:手动触发,获取所有在线设备的前台 App。
|
||||||
|
|
||||||
|
设计原则:**不打扰设备**,扫描过程不会让设备退出当前 App。
|
||||||
|
|
||||||
|
策略(按设备状态区分获取逻辑):
|
||||||
|
- worker 运行中设备:用已有 remote_adb_url 直接查询(已有 adb 连接,无额外开销)
|
||||||
|
- 空闲设备:直接 adb connect <serial> → 查询 → adb disconnect(绕过 STF,不打扰设备)
|
||||||
|
- 被别人占用的设备:标记 "他人占用"
|
||||||
|
- STF occupy/release 会打扰设备(可能退回桌面),绝不使用
|
||||||
|
|
||||||
|
serial 格式为 IP:5555(设备本身的 adb 网络地址),可直接 adb connect。
|
||||||
|
adb connect/disconnect 只建立/断开调试连接,不影响设备 UI。
|
||||||
|
dumpsys window 只读取窗口状态,不执行任何操作。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, stf):
|
||||||
|
self.stf = stf
|
||||||
|
self._cache = {} # serial -> app_name
|
||||||
|
self._cache_lock = threading.Lock()
|
||||||
|
self._scanning = threading.Event() # 标记是否正在扫描
|
||||||
|
self._last_scan_time = 0 # 上次扫描完成时间戳
|
||||||
|
self._last_scan_count = 0 # 上次扫描设备数
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""兼容 TaskManager.shutdown() 调用。"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get(self, serial):
|
||||||
|
"""读取缓存的前台 App。"""
|
||||||
|
with self._cache_lock:
|
||||||
|
return self._cache.get(serial)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_scanning(self):
|
||||||
|
return self._scanning.is_set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_scan_time(self):
|
||||||
|
return self._last_scan_time
|
||||||
|
|
||||||
|
def scan_once(self):
|
||||||
|
"""手动触发一次扫描(后台线程执行,不阻塞调用者)。
|
||||||
|
|
||||||
|
如果已有扫描在进行中,直接返回 False。
|
||||||
|
"""
|
||||||
|
if self._scanning.is_set():
|
||||||
|
return False
|
||||||
|
t = threading.Thread(target=self._scan_all, name="fg-scan-once", daemon=True)
|
||||||
|
t.start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _scan_all(self):
|
||||||
|
"""扫描所有在线设备(后台线程执行)。
|
||||||
|
|
||||||
|
按设备归属分四类处理:
|
||||||
|
1. worker 运行中:用已有 remote_adb_url 直接查询(无额外开销)
|
||||||
|
2. 自己账户占用但无 worker:调用 STF remoteConnect 获取隧道查询
|
||||||
|
(不 occupy/release,不打扰设备 UI)
|
||||||
|
3. 完全空闲设备(using=False):尝试轻量 adb connect serial
|
||||||
|
(单次尝试,不 kill-server,不影响其他 worker)
|
||||||
|
4. 被他人占用:标记 "(他人占用)"
|
||||||
|
"""
|
||||||
|
self._scanning.set()
|
||||||
|
_log.info("前台 App 扫描已启动")
|
||||||
|
try:
|
||||||
|
all_devices = self.stf.list_all_devices()
|
||||||
|
except Exception as e:
|
||||||
|
_log.error("前台 App 扫描: 获取设备列表失败: %s", e)
|
||||||
|
self._scanning.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
# 自己账户已占用的设备列表(用于区分"自己占用"vs"他人占用")
|
||||||
|
try:
|
||||||
|
my_serials = {d["serial"] for d in self.stf.list_my_devices()}
|
||||||
|
except Exception:
|
||||||
|
my_serials = set()
|
||||||
|
|
||||||
|
worker_status = {w["serial"]: w for w in get_all_worker_status()}
|
||||||
|
|
||||||
|
# 分类设备
|
||||||
|
have_conn = {} # serial -> remote_adb_url(worker 运行中,已有 adb 连接)
|
||||||
|
my_owned = [] # 自己账户占用但无 running worker,需 remoteConnect 获取隧道
|
||||||
|
free_serials = [] # 完全空闲设备,尝试轻量 adb connect
|
||||||
|
skip_serials = [] # 被他人占用
|
||||||
|
|
||||||
|
for dev in all_devices:
|
||||||
|
if not dev.get("present"):
|
||||||
|
continue
|
||||||
|
serial = dev.get("serial", "")
|
||||||
|
if not serial:
|
||||||
|
continue
|
||||||
|
w = worker_status.get(serial, {})
|
||||||
|
url = w.get("remote_adb_url")
|
||||||
|
if url and w.get("status") in ("running", "connecting"):
|
||||||
|
# 1. 设备正在执行任务,已有 adb 连接,直接查询
|
||||||
|
have_conn[serial] = url
|
||||||
|
elif serial in my_serials:
|
||||||
|
# 2. 自己账户占用但无 worker,可安全 remoteConnect(不打扰设备)
|
||||||
|
my_owned.append(serial)
|
||||||
|
elif dev.get("using"):
|
||||||
|
# 4. 被他人占用
|
||||||
|
skip_serials.append(serial)
|
||||||
|
else:
|
||||||
|
# 3. 完全空闲,尝试轻量 adb connect(不 kill-server)
|
||||||
|
free_serials.append(serial)
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
_log.info("前台 App 扫描分类: 运行中=%d, 自己占用=%d, 空闲=%d, 他人占用=%d",
|
||||||
|
len(have_conn), len(my_owned), len(free_serials), len(skip_serials))
|
||||||
|
|
||||||
|
# 1. worker 运行中设备:用已有 remote_adb_url 查询(并发 10)
|
||||||
|
if have_conn:
|
||||||
|
with ThreadPoolExecutor(max_workers=min(10, len(have_conn))) as pool:
|
||||||
|
futures = {pool.submit(get_foreground_app, url): s
|
||||||
|
for s, url in have_conn.items()}
|
||||||
|
for fut in as_completed(futures, timeout=15):
|
||||||
|
s = futures[fut]
|
||||||
|
try:
|
||||||
|
results[s] = fut.result()
|
||||||
|
except Exception:
|
||||||
|
results[s] = None
|
||||||
|
|
||||||
|
# 2. 自己占用设备:remoteConnect 获取隧道 → 查询 → 断开(并发 5,不打扰设备)
|
||||||
|
if my_owned:
|
||||||
|
with ThreadPoolExecutor(max_workers=min(5, len(my_owned))) as pool:
|
||||||
|
futures = {pool.submit(self._scan_my_owned, s): s
|
||||||
|
for s in my_owned}
|
||||||
|
for fut in as_completed(futures, timeout=30):
|
||||||
|
s = futures[fut]
|
||||||
|
try:
|
||||||
|
results[s] = fut.result()
|
||||||
|
except Exception:
|
||||||
|
results[s] = None
|
||||||
|
|
||||||
|
# 3. 空闲设备:轻量 adb connect serial → 查询 → disconnect(并发 10)
|
||||||
|
if free_serials:
|
||||||
|
with ThreadPoolExecutor(max_workers=min(10, len(free_serials))) as pool:
|
||||||
|
futures = {pool.submit(self._scan_free, s): s
|
||||||
|
for s in free_serials}
|
||||||
|
for fut in as_completed(futures, timeout=20):
|
||||||
|
s = futures[fut]
|
||||||
|
try:
|
||||||
|
results[s] = fut.result()
|
||||||
|
except Exception:
|
||||||
|
results[s] = None
|
||||||
|
|
||||||
|
# 4. 被别人占用的设备
|
||||||
|
for s in skip_serials:
|
||||||
|
results[s] = "(他人占用)"
|
||||||
|
|
||||||
|
# 更新缓存
|
||||||
|
with self._cache_lock:
|
||||||
|
for s, app in results.items():
|
||||||
|
self._cache[s] = app
|
||||||
|
|
||||||
|
self._last_scan_time = time.time()
|
||||||
|
self._last_scan_count = len(results)
|
||||||
|
self._scanning.clear()
|
||||||
|
_log.info("前台 App 扫描完成: %d 台设备", len(results))
|
||||||
|
|
||||||
|
def _scan_my_owned(self, serial):
|
||||||
|
"""自己账户占用的设备:通过 STF remoteConnect 获取隧道查询。
|
||||||
|
|
||||||
|
不调用 occupy/release,只建立/断开 ADB 隧道,不打扰设备 UI。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = self.stf.remote_connect(serial)
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if not adb_connect_light(url):
|
||||||
|
return None
|
||||||
|
return get_foreground_app(url)
|
||||||
|
finally:
|
||||||
|
adb_disconnect(url)
|
||||||
|
self.stf.remote_disconnect(serial)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _scan_free(self, serial):
|
||||||
|
"""空闲设备:轻量 adb connect serial 查询(单次尝试,不 kill-server)。
|
||||||
|
|
||||||
|
serial 通常为 IP:5555,若设备 5555 端口未开放则快速失败。
|
||||||
|
绝不重试、绝不 kill-server,避免影响正在运行的 worker。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not adb_connect_light(serial):
|
||||||
|
return "(不可达)"
|
||||||
|
try:
|
||||||
|
return get_foreground_app(serial)
|
||||||
|
finally:
|
||||||
|
adb_disconnect(serial)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 任务管理器 ==================
|
||||||
|
class TaskManager:
|
||||||
|
def __init__(self, stf_client=None, app=None):
|
||||||
|
self.stf = stf_client or STFClient()
|
||||||
|
self.app = app # Flask app,用于 db context
|
||||||
|
self.scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
|
||||||
|
self.scheduler.start()
|
||||||
|
start_watchdog() # 启动心跳看门狗
|
||||||
|
self.groups = {} # name -> DeviceGroup(内存业务对象)
|
||||||
|
self.jobs = {} # id -> TaskJob(内存业务对象)
|
||||||
|
self._running = {} # serial -> {"worker", "job_id", "started_at", "attempt"}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._fg_scanner = _ForegroundScanner(self.stf)
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _db(self):
|
||||||
|
"""获取 db context。task_manager 在后台线程跑,需自己推 app context。"""
|
||||||
|
if self.app is None:
|
||||||
|
raise RuntimeError("TaskManager 未关联 Flask app,无法访问数据库")
|
||||||
|
return self.app.app_context()
|
||||||
|
|
||||||
|
# ---- 持久化(SQLite,替代旧 JSON 文件)----
|
||||||
|
def _load(self):
|
||||||
|
"""从数据库加载分组和任务到内存。"""
|
||||||
|
try:
|
||||||
|
with self._db():
|
||||||
|
for row in GroupRow.query.all():
|
||||||
|
g = DeviceGroup(row.name, row.get_serials(), row.description or "")
|
||||||
|
self.groups[g.name] = g
|
||||||
|
dirty = False # 标记是否有任务参数需要写回
|
||||||
|
for row in JobRow.query.all():
|
||||||
|
j = TaskJob(job_id=row.id, name=row.name, task_type=row.task_type,
|
||||||
|
target=row.get_target(), params=row.get_params(),
|
||||||
|
schedule=row.get_schedule(), retry=row.get_retry(),
|
||||||
|
enabled=row.enabled)
|
||||||
|
# 数据规整:移除已废弃的 action 配置(如抖音的 comment 已删除)
|
||||||
|
if j.task_type == "douyin_nurture":
|
||||||
|
actions = j.params.get("actions", {})
|
||||||
|
if "comment" in actions:
|
||||||
|
del actions["comment"]
|
||||||
|
row.set_params(j.params)
|
||||||
|
dirty = True
|
||||||
|
_log.info(f"任务 {j.name}({j.id}): 已移除废弃的 comment 配置")
|
||||||
|
self.jobs[j.id] = j
|
||||||
|
if dirty:
|
||||||
|
db.session.commit()
|
||||||
|
_log.info("已将规整后的任务参数写回数据库")
|
||||||
|
_log.info(f"从数据库加载 {len(self.groups)} 个分组, {len(self.jobs)} 个任务")
|
||||||
|
except Exception as e:
|
||||||
|
_log.error(f"从数据库加载失败: {e}")
|
||||||
|
# 恢复启用的 cron 任务
|
||||||
|
for job in self.jobs.values():
|
||||||
|
if job.enabled and job.schedule.get("mode") in ("cron", "cron_stop"):
|
||||||
|
self._add_cron(job)
|
||||||
|
|
||||||
|
def _save_groups(self):
|
||||||
|
"""保存所有分组到数据库(upsert)。"""
|
||||||
|
with self._db():
|
||||||
|
for g in self.groups.values():
|
||||||
|
row = GroupRow.query.filter_by(name=g.name).first()
|
||||||
|
if not row:
|
||||||
|
row = GroupRow(name=g.name)
|
||||||
|
db.session.add(row)
|
||||||
|
row.set_serials(g.serials)
|
||||||
|
row.description = g.description
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
def _save_jobs(self):
|
||||||
|
"""保存所有任务到数据库(upsert)。"""
|
||||||
|
with self._db():
|
||||||
|
for j in self.jobs.values():
|
||||||
|
row = JobRow.query.get(j.id)
|
||||||
|
if not row:
|
||||||
|
row = JobRow(id=j.id)
|
||||||
|
db.session.add(row)
|
||||||
|
row.name = j.name
|
||||||
|
row.task_type = j.task_type
|
||||||
|
row.set_target(j.target)
|
||||||
|
row.set_params(j.params)
|
||||||
|
row.set_schedule(j.schedule)
|
||||||
|
row.set_retry(j.retry)
|
||||||
|
row.enabled = j.enabled
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# ---- 分组管理 ----
|
||||||
|
def add_group(self, name, serials, description=""):
|
||||||
|
g = DeviceGroup(name, serials, description)
|
||||||
|
self.groups[name] = g
|
||||||
|
self._save_groups()
|
||||||
|
return g
|
||||||
|
|
||||||
|
def update_group(self, name, serials=None, description=None):
|
||||||
|
g = self.groups.get(name)
|
||||||
|
if not g:
|
||||||
|
return None
|
||||||
|
if serials is not None:
|
||||||
|
g.serials = serials
|
||||||
|
if description is not None:
|
||||||
|
g.description = description
|
||||||
|
self._save_groups()
|
||||||
|
return g
|
||||||
|
|
||||||
|
def delete_group(self, name):
|
||||||
|
if name in self.groups:
|
||||||
|
del self.groups[name]
|
||||||
|
self._save_groups()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ---- 任务计划管理 ----
|
||||||
|
def add_job(self, name, task_type, target, params, schedule, retry, enabled=True):
|
||||||
|
job = TaskJob(name=name, task_type=task_type, target=target, params=params,
|
||||||
|
schedule=schedule, retry=retry, enabled=enabled)
|
||||||
|
self.jobs[job.id] = job
|
||||||
|
if enabled and schedule.get("mode") in ("cron", "cron_stop"):
|
||||||
|
self._add_cron(job)
|
||||||
|
self._save_jobs()
|
||||||
|
return job
|
||||||
|
|
||||||
|
def update_job(self, job_id, **fields):
|
||||||
|
job = self.jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return None
|
||||||
|
# 更新前先移除旧 cron
|
||||||
|
was_cron = job.enabled and job.schedule.get("mode") in ("cron", "cron_stop")
|
||||||
|
if was_cron:
|
||||||
|
self._remove_cron(job_id)
|
||||||
|
for k, v in fields.items():
|
||||||
|
if v is not None and hasattr(job, k):
|
||||||
|
setattr(job, k, v)
|
||||||
|
if job.enabled and job.schedule.get("mode") in ("cron", "cron_stop"):
|
||||||
|
self._add_cron(job)
|
||||||
|
self._save_jobs()
|
||||||
|
return job
|
||||||
|
|
||||||
|
def delete_job(self, job_id):
|
||||||
|
self._remove_cron(job_id)
|
||||||
|
if job_id in self.jobs:
|
||||||
|
del self.jobs[job_id]
|
||||||
|
self._save_jobs()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def toggle_job(self, job_id, enabled):
|
||||||
|
job = self.jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return None
|
||||||
|
job.enabled = enabled
|
||||||
|
if enabled and job.schedule.get("mode") in ("cron", "cron_stop"):
|
||||||
|
self._add_cron(job)
|
||||||
|
else:
|
||||||
|
self._remove_cron(job_id)
|
||||||
|
self._save_jobs()
|
||||||
|
return job
|
||||||
|
|
||||||
|
# ---- APScheduler 集成 ----
|
||||||
|
def _cron_id(self, job_id, kind="start"):
|
||||||
|
"""生成 cron job id。kind: start=启动 cron,stop=停止 cron。"""
|
||||||
|
return f"job_{job_id}_{kind}"
|
||||||
|
|
||||||
|
def _add_cron(self, job):
|
||||||
|
"""注册定时任务。支持三种模式:
|
||||||
|
- once: 不注册 cron
|
||||||
|
- cron: 只注册启动 cron(到点启动 worker)
|
||||||
|
- cron_stop: 注册启动 cron + 停止 cron(到点启动,到点停止)
|
||||||
|
"""
|
||||||
|
mode = job.schedule.get("mode", "once")
|
||||||
|
if mode not in ("cron", "cron_stop"):
|
||||||
|
return
|
||||||
|
|
||||||
|
start_cron = job.schedule.get("cron", "")
|
||||||
|
if start_cron:
|
||||||
|
try:
|
||||||
|
trigger = CronTrigger.from_crontab(start_cron)
|
||||||
|
except Exception as e:
|
||||||
|
_log.error(f"启动 cron 解析失败 {start_cron}: {e}")
|
||||||
|
else:
|
||||||
|
self.scheduler.add_job(self._on_cron_trigger, trigger, args=[job.id],
|
||||||
|
id=self._cron_id(job.id, "start"),
|
||||||
|
replace_existing=True)
|
||||||
|
_log.info(f"已注册启动定时 {job.name}({job.id}) cron={start_cron}")
|
||||||
|
|
||||||
|
if mode == "cron_stop":
|
||||||
|
stop_cron = job.schedule.get("stop_cron", "")
|
||||||
|
if stop_cron:
|
||||||
|
try:
|
||||||
|
trigger = CronTrigger.from_crontab(stop_cron)
|
||||||
|
except Exception as e:
|
||||||
|
_log.error(f"停止 cron 解析失败 {stop_cron}: {e}")
|
||||||
|
else:
|
||||||
|
self.scheduler.add_job(self._on_cron_stop, trigger, args=[job.id],
|
||||||
|
id=self._cron_id(job.id, "stop"),
|
||||||
|
replace_existing=True)
|
||||||
|
_log.info(f"已注册停止定时 {job.name}({job.id}) stop_cron={stop_cron}")
|
||||||
|
|
||||||
|
def _remove_cron(self, job_id):
|
||||||
|
"""移除该任务的所有 cron job(启动 + 停止)。"""
|
||||||
|
for kind in ("start", "stop"):
|
||||||
|
try:
|
||||||
|
self.scheduler.remove_job(self._cron_id(job_id, kind))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_cron_trigger(self, job_id):
|
||||||
|
"""cron 触发:启动该任务的所有目标设备 worker。"""
|
||||||
|
job = self.jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return
|
||||||
|
_log.info(f"定时触发: {job.name}({job.id})")
|
||||||
|
self._run_job(job)
|
||||||
|
|
||||||
|
def _on_cron_stop(self, job_id):
|
||||||
|
"""停止 cron 触发:停止该任务正在运行的所有 worker。
|
||||||
|
|
||||||
|
只停本 job 启动的 worker,不影响其他任务。
|
||||||
|
"""
|
||||||
|
job = self.jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
items = [(s, info) for s, info in self._running.items()
|
||||||
|
if info.get("job_id") == job_id]
|
||||||
|
stopped = []
|
||||||
|
for serial, info in items:
|
||||||
|
w = info.get("worker")
|
||||||
|
if w and w.is_alive():
|
||||||
|
w.stop()
|
||||||
|
stopped.append(serial)
|
||||||
|
_log.info(f"定时停止 {job.name}({job.id}): 停止 {len(stopped)} 台设备 {stopped}")
|
||||||
|
|
||||||
|
def run_job_now(self, job_id):
|
||||||
|
"""立即执行任务(手动触发)。"""
|
||||||
|
job = self.jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return {"ok": False, "error": "任务不存在"}
|
||||||
|
# 在独立线程跑,不阻塞调用方
|
||||||
|
t = threading.Thread(target=self._run_job, args=(job,), daemon=True)
|
||||||
|
t.start()
|
||||||
|
return {"ok": True, "msg": f"任务 {job.name} 已触发"}
|
||||||
|
|
||||||
|
def _run_job(self, job):
|
||||||
|
"""执行任务:为每个目标设备起 worker(含重试循环)。"""
|
||||||
|
serials = job.resolve_serials(self)
|
||||||
|
if not serials:
|
||||||
|
_log.warning(f"任务 {job.name} 无可用设备")
|
||||||
|
return
|
||||||
|
task_cls = get_task_class(job.task_type)
|
||||||
|
if not task_cls:
|
||||||
|
_log.error(f"未知任务类型: {job.task_type}")
|
||||||
|
return
|
||||||
|
task = task_cls()
|
||||||
|
max_attempts = max(1, job.retry.get("max_attempts", 1))
|
||||||
|
delay = job.retry.get("delay", 60)
|
||||||
|
|
||||||
|
for serial in serials:
|
||||||
|
# 每台设备一个重试循环线程,互不影响
|
||||||
|
t = threading.Thread(target=self._run_with_retry,
|
||||||
|
args=(task, serial, job, max_attempts, delay), daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
def _run_with_retry(self, task, serial, job, max_attempts, delay):
|
||||||
|
"""单设备任务执行 + 重试。
|
||||||
|
|
||||||
|
异常分类:
|
||||||
|
DeviceOfflineError — 设备掉线,立即放弃不重试(换设备也没用)
|
||||||
|
其他异常 — 按 max_attempts 重试
|
||||||
|
"""
|
||||||
|
for attempt in range(1, max_attempts + 1):
|
||||||
|
# 同一 serial 同时只能一个 worker
|
||||||
|
with self._lock:
|
||||||
|
if serial in self._running:
|
||||||
|
_log.warning(f"{serial} 已有任务在跑,跳过 (job={job.name})")
|
||||||
|
return
|
||||||
|
self._running[serial] = {"job_id": job.id, "started_at": time.time(),
|
||||||
|
"attempt": attempt, "task_type": job.task_type}
|
||||||
|
_update_status(serial, task_job=job.name, attempt=attempt,
|
||||||
|
max_attempts=max_attempts)
|
||||||
|
|
||||||
|
worker = None
|
||||||
|
try:
|
||||||
|
worker = task.create_worker(self.stf, serial, job.params)
|
||||||
|
with self._lock:
|
||||||
|
self._running[serial]["worker"] = worker
|
||||||
|
_log.info(f"{serial} 开始任务 {job.name} (第{attempt}/{max_attempts}次)")
|
||||||
|
worker.start()
|
||||||
|
worker.join() # 等待 worker 结束
|
||||||
|
# worker 正常结束(done 或被 stop)
|
||||||
|
with self._lock:
|
||||||
|
self._running.pop(serial, None)
|
||||||
|
# 判断是否成功:看 status
|
||||||
|
with _WORKERS_LOCK:
|
||||||
|
st = _WORKERS.get(serial, {}).get("status")
|
||||||
|
# 清除 task_job 标记,避免前端误判"运行中"
|
||||||
|
_update_status(serial, task_job="")
|
||||||
|
if st == "done":
|
||||||
|
_log.info(f"{serial} 任务 {job.name} 成功完成")
|
||||||
|
return
|
||||||
|
if st == "released" and attempt == 1:
|
||||||
|
# 被手动停止,不重试
|
||||||
|
return
|
||||||
|
_log.warning(f"{serial} 任务未成功(status={st})")
|
||||||
|
except DeviceOfflineError as e:
|
||||||
|
# 设备掉线,立即放弃,不重试
|
||||||
|
_log.error(f"{serial} 设备离线,放弃任务 {job.name}: {e}")
|
||||||
|
with self._lock:
|
||||||
|
self._running.pop(serial, None)
|
||||||
|
_update_status(serial, status="failed", task_job="",
|
||||||
|
last_error=f"设备离线: {e}")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
_log.error(f"{serial} 执行异常: {e}", exc_info=True)
|
||||||
|
with self._lock:
|
||||||
|
self._running.pop(serial, None)
|
||||||
|
_update_status(serial, task_job="")
|
||||||
|
|
||||||
|
if attempt < max_attempts:
|
||||||
|
# 检查是否是端口耗尽类临时错误,需要更长退避等端口释放
|
||||||
|
with _WORKERS_LOCK:
|
||||||
|
err = _WORKERS.get(serial, {}).get("last_error", "")
|
||||||
|
if err.startswith("[transient]"):
|
||||||
|
# Windows TCP 端口耗尽,TIME_WAIT 默认 2-4 分钟,等 120 秒
|
||||||
|
extra_delay = max(delay, 120)
|
||||||
|
_log.info(f"{serial} ADB 连接临时错误(端口耗尽),{extra_delay}s 后重试 ({attempt+1}/{max_attempts})")
|
||||||
|
time.sleep(extra_delay)
|
||||||
|
else:
|
||||||
|
_log.info(f"{serial} {delay}s 后重试 ({attempt+1}/{max_attempts})")
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
_log.error(f"{serial} 任务 {job.name} 重试耗尽,放弃")
|
||||||
|
_update_status(serial, status="failed", last_error=f"{job.name} 重试{max_attempts}次失败")
|
||||||
|
|
||||||
|
# ---- 运行控制 ----
|
||||||
|
def stop_device(self, serial):
|
||||||
|
"""停止指定设备的 worker。"""
|
||||||
|
with self._lock:
|
||||||
|
info = self._running.get(serial)
|
||||||
|
if not info:
|
||||||
|
return False
|
||||||
|
w = info.get("worker")
|
||||||
|
if w and w.is_alive():
|
||||||
|
w.stop()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop_all(self):
|
||||||
|
"""停止所有运行中的 worker。"""
|
||||||
|
with self._lock:
|
||||||
|
items = list(self._running.items())
|
||||||
|
stopped = []
|
||||||
|
for serial, info in items:
|
||||||
|
w = info.get("worker")
|
||||||
|
if w and w.is_alive():
|
||||||
|
w.stop()
|
||||||
|
stopped.append(serial)
|
||||||
|
return stopped
|
||||||
|
|
||||||
|
def get_running(self):
|
||||||
|
with self._lock:
|
||||||
|
return {s: dict(v) for s, v in self._running.items()}
|
||||||
|
|
||||||
|
# ---- 状态查询(带缓存,避免 STF 请求阻塞前端)----
|
||||||
|
_status_cache = None # (timestamp, data, error)
|
||||||
|
_status_cache_lock = threading.Lock()
|
||||||
|
_STATUS_CACHE_TTL = 5.0 # 缓存 5 秒,前端 5 秒刷新刚好命中
|
||||||
|
|
||||||
|
def get_status(self):
|
||||||
|
"""综合状态:STF 设备池 + 本地 worker + 运行中的任务。
|
||||||
|
|
||||||
|
带 5 秒缓存:STF 请求慢时避免每次 /api/status 都打 STF 阻塞 Flask。
|
||||||
|
worker 状态实时读(内存,无 IO),不受缓存影响。
|
||||||
|
"""
|
||||||
|
with self._status_cache_lock:
|
||||||
|
if self._status_cache:
|
||||||
|
ts, cached, err = self._status_cache
|
||||||
|
if time.time() - ts < self._STATUS_CACHE_TTL:
|
||||||
|
if err:
|
||||||
|
return None, err
|
||||||
|
# 用缓存的设备列表 + 实时 worker 状态重新组装
|
||||||
|
return self._merge_status(cached), None
|
||||||
|
# 缓存过期或不存在,重新拉 STF
|
||||||
|
try:
|
||||||
|
all_devices = self.stf.list_all_devices()
|
||||||
|
except Exception as e:
|
||||||
|
with self._status_cache_lock:
|
||||||
|
self._status_cache = (time.time(), None, str(e))
|
||||||
|
return None, f"获取设备列表失败: {e}"
|
||||||
|
with self._status_cache_lock:
|
||||||
|
self._status_cache = (time.time(), all_devices, None)
|
||||||
|
return self._merge_status(all_devices), None
|
||||||
|
|
||||||
|
def _merge_status(self, all_devices):
|
||||||
|
"""用 STF 设备列表 + 实时 worker 状态 + 前台 App 组装返回结果。"""
|
||||||
|
worker_status = {w["serial"]: w for w in get_all_worker_status()}
|
||||||
|
running = self.get_running()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for dev in all_devices:
|
||||||
|
serial = dev.get("serial", "")
|
||||||
|
owner = dev.get("owner")
|
||||||
|
w = worker_status.get(serial, {})
|
||||||
|
r = running.get(serial, {})
|
||||||
|
result.append({
|
||||||
|
"serial": serial,
|
||||||
|
"model": dev.get("model") or w.get("model", "") or dev.get("product", ""),
|
||||||
|
"device_name": dev.get("name") or "",
|
||||||
|
"present": dev.get("present", False),
|
||||||
|
"ready": dev.get("ready", False),
|
||||||
|
"stf_occupied": dev.get("using", False),
|
||||||
|
"owner": owner.get("name", "") if owner else "",
|
||||||
|
"worker_status": w.get("status", "idle"),
|
||||||
|
"foreground_app": self._fg_scanner.get(serial),
|
||||||
|
# 通用进度字段(任意 app 通用,前端统一解析展示)
|
||||||
|
# 结构:{"done": int, "total": int, "unit": str, "action_counts": dict}
|
||||||
|
"progress": w.get("progress", {}),
|
||||||
|
"current_action": w.get("current_action", ""),
|
||||||
|
"last_error": w.get("last_error", ""),
|
||||||
|
"running_job": r.get("job_id", ""),
|
||||||
|
"task_job": w.get("task_job", ""),
|
||||||
|
"attempt": r.get("attempt", 0),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
def list_all_serials(self):
|
||||||
|
"""返回 STF 上所有在线设备的 serial 列表(供分组表单勾选用)。
|
||||||
|
|
||||||
|
复用 get_status 缓存,避免开页面时阻塞。
|
||||||
|
"""
|
||||||
|
devices, err = self.get_status()
|
||||||
|
if err or not devices:
|
||||||
|
return []
|
||||||
|
return [d["serial"] for d in devices if d.get("present")]
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
self.stop_all()
|
||||||
|
self._fg_scanner.stop()
|
||||||
|
stop_watchdog()
|
||||||
|
self.scheduler.shutdown(wait=False)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""uiautomator2 通用辅助函数。
|
||||||
|
|
||||||
|
所有任务都可复用:应用启动保障、等待首页、安全点击、随机操作等。
|
||||||
|
具体业务逻辑(如抖音首页特征元素判断)不放这里,放各自 task 文件。
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
|
||||||
|
from core.logger import get_logger
|
||||||
|
|
||||||
|
_log = get_logger("core.u2")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_app_running(d, package, max_restart=3, home_check=None):
|
||||||
|
"""确保 app 在前台运行,被人为退出/崩溃则自动重启。
|
||||||
|
|
||||||
|
d: u2.Device
|
||||||
|
package: 包名
|
||||||
|
max_restart: 最大重启次数
|
||||||
|
home_check: 可选回调 d->bool,判断是否进入主页。
|
||||||
|
默认只看包名(包名对就返回 True,和原抖音逻辑一致)。
|
||||||
|
返回 True 表示 app 在前台;连续重启失败返回 False。
|
||||||
|
"""
|
||||||
|
home_check = home_check or (lambda d: True)
|
||||||
|
for attempt in range(1, max_restart + 1):
|
||||||
|
try:
|
||||||
|
cur = d.app_current()
|
||||||
|
except Exception:
|
||||||
|
cur = {}
|
||||||
|
if cur.get("package") == package and home_check(d):
|
||||||
|
return True
|
||||||
|
_log.info(f"{package} 不在前台(当前:{cur}),第 {attempt}/{max_restart} 次重启...")
|
||||||
|
try:
|
||||||
|
d.app_start(package, wait=True)
|
||||||
|
time.sleep(3)
|
||||||
|
if home_check(d):
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"重启异常: {e}")
|
||||||
|
time.sleep(3)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_app_home(d, package, home_check, timeout=40):
|
||||||
|
"""等待 app 进入主页。
|
||||||
|
|
||||||
|
home_check: d->bool 回调,返回 True 表示已进入主页。
|
||||||
|
抖音主页 Activity 名仍叫 SplashActivity,不能靠 Activity 名判断,
|
||||||
|
要用界面特征元素(由调用方通过 home_check 传入)。
|
||||||
|
"""
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
cur = d.app_current()
|
||||||
|
except Exception:
|
||||||
|
cur = {}
|
||||||
|
if cur.get("package") == package and home_check(d):
|
||||||
|
return True
|
||||||
|
time.sleep(2)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def random_sleep(min_s, max_s):
|
||||||
|
"""随机睡眠(模拟人类操作间隔)。"""
|
||||||
|
time.sleep(random.uniform(min_s, max_s))
|
||||||
|
|
||||||
|
|
||||||
|
def safe_click(el, timeout=1):
|
||||||
|
"""安全点击:元素存在才点,不抛异常。返回是否点击成功。"""
|
||||||
|
try:
|
||||||
|
if el.exists(timeout=timeout):
|
||||||
|
el.click()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def safe_set_text(el, text, timeout=1):
|
||||||
|
"""安全输入文本:元素存在才输入,不抛异常。返回是否成功。"""
|
||||||
|
try:
|
||||||
|
if el.exists(timeout=timeout):
|
||||||
|
el.set_text(text)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def find_and_click(d, timeout=1, **selectors):
|
||||||
|
"""按定位器找元素并点击,找不到不抛异常。返回是否成功。
|
||||||
|
|
||||||
|
用法:find_and_click(d, text="首页") 或 find_and_click(d, description="搜索")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
el = d(**selectors)
|
||||||
|
if el.exists(timeout=timeout):
|
||||||
|
el.click()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def random_swipe_up(d, width_ratio=0.5, height_start=0.8, height_end=0.2, duration=None):
|
||||||
|
"""随机上滑(刷视频常用)。坐标按屏幕比例算,适配不同分辨率。
|
||||||
|
|
||||||
|
duration: 滑动时长(秒),None 则随机 0.25~0.50s。
|
||||||
|
"""
|
||||||
|
info = d.info
|
||||||
|
w, h = info["displayWidth"], info["displayHeight"]
|
||||||
|
dur = duration or random.uniform(0.25, 0.50)
|
||||||
|
d.swipe(int(w * width_ratio), int(h * height_start),
|
||||||
|
int(w * width_ratio), int(h * height_end), dur)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""一次性脚本:创建"抖音定时8-9点"任务。
|
||||||
|
|
||||||
|
用法(在 cmd 中执行,不是 PowerShell):
|
||||||
|
cd /d d:\\platform-tools
|
||||||
|
python create_task.py
|
||||||
|
|
||||||
|
任务配置:
|
||||||
|
- 名称:抖音定时8-9点
|
||||||
|
- 类型:douyin_nurture(抖音养号)
|
||||||
|
- 目标:全部空闲设备
|
||||||
|
- 调度:定时启停(每天 8:00 启动,9:00 自动停止)
|
||||||
|
- 视频数量:不限(watch_count=0,靠停止 cron 到点停)
|
||||||
|
- 运行时长:不限(max_duration=0,靠停止 cron 到点停)
|
||||||
|
- 点赞:启用,rate=0.3
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
from core.models import db, init_db, TaskJob as JobRow
|
||||||
|
from core.task_manager import TaskManager, TaskJob
|
||||||
|
from core.stf_client import STFClient
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config["SECRET_KEY"] = "dev-secret-key-change-in-production"
|
||||||
|
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)), "data", "users.db")
|
||||||
|
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||||
|
|
||||||
|
init_db(app)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
# 检查是否已存在同名任务
|
||||||
|
existing = JobRow.query.filter_by(name="抖音定时8-9点").first()
|
||||||
|
if existing:
|
||||||
|
print(f"任务已存在: id={existing.id}, name={existing.name}")
|
||||||
|
print("如需重新创建,请先在前端删除该任务。")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# 构造任务计划
|
||||||
|
job = TaskJob(
|
||||||
|
name="抖音定时8-9点",
|
||||||
|
task_type="douyin_nurture",
|
||||||
|
target={"mode": "all"},
|
||||||
|
params={
|
||||||
|
"watch_count": 0, # 不限视频数量
|
||||||
|
"max_duration": 0, # 不限运行时长(靠 stop_cron 到点停止)
|
||||||
|
"actions": {
|
||||||
|
"like": {
|
||||||
|
"enabled": True,
|
||||||
|
"params": {"rate": 0.3, "method": "by_element"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
schedule={
|
||||||
|
"mode": "cron_stop",
|
||||||
|
"cron": "0 8 * * *", # 每天 8:00 启动
|
||||||
|
"stop_cron": "0 9 * * *", # 每天 9:00 停止
|
||||||
|
},
|
||||||
|
retry={"max_attempts": 1, "delay": 60},
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 写入数据库
|
||||||
|
row = JobRow(id=job.id, name=job.name, task_type=job.task_type, enabled=job.enabled)
|
||||||
|
row.set_target(job.target)
|
||||||
|
row.set_params(job.params)
|
||||||
|
row.set_schedule(job.schedule)
|
||||||
|
row.set_retry(job.retry)
|
||||||
|
db.session.add(row)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
print(f"✓ 任务创建成功!")
|
||||||
|
print(f" ID: {job.id}")
|
||||||
|
print(f" 名称: {job.name}")
|
||||||
|
print(f" 类型: {job.task_type}")
|
||||||
|
print(f" 目标: 全部空闲设备")
|
||||||
|
print(f" 调度: 每天 08:00 启动 → 09:00 自动停止")
|
||||||
|
print(f" 视频数量: 不限(靠定时停止)")
|
||||||
|
print(f" 点赞: 启用 (rate=0.3)")
|
||||||
|
print()
|
||||||
|
print("启动 web_server 后,该任务会自动加载并注册定时。")
|
||||||
|
print("启动命令: python web_server.py")
|
||||||
Binary file not shown.
+1045
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
|||||||
|
"""抖音养号命令行入口。
|
||||||
|
|
||||||
|
直接运行:python main.py
|
||||||
|
- 单设备跑一次养号,不走 web/调度
|
||||||
|
- 任务参数默认值在 tasks/douyin_nurture.py 顶部,可通过环境变量 WATCH_COUNT 覆盖视频数
|
||||||
|
|
||||||
|
web 管理后台请运行:python web_server.py
|
||||||
|
清理 STF 残留占用:python scripts/cleanup.py
|
||||||
|
|
||||||
|
目录结构:
|
||||||
|
config.py — 项目根配置(STF 地址/token、adb 路径、web 端口、数据目录)
|
||||||
|
bin/adb/ — adb.exe 及依赖 dll
|
||||||
|
core/ — 核心程序(STF 客户端、adb 工具、BaseWorker 基类、任务管理器)
|
||||||
|
tasks/ — 任务定义(每个任务一个 .py,自带参数默认值,继承 BaseTask + @register_task)
|
||||||
|
web/ — 前端页面(index.html)
|
||||||
|
data/ — 持久化数据(分组、任务计划)
|
||||||
|
scripts/ — 实用脚本
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from core.stf_client import STFClient
|
||||||
|
from tasks import get_task_class
|
||||||
|
|
||||||
|
|
||||||
|
def run_once(serial=None, **overrides):
|
||||||
|
"""单设备跑一次养号(命令行入口,不启动 web)。
|
||||||
|
|
||||||
|
走任务框架:取 douyin_nurture 任务类 → create_worker → 直接 run(当前线程)。
|
||||||
|
overrides 可传任务参数覆盖默认值,如 run_once(watch_count=20, like_enabled=False)。
|
||||||
|
"""
|
||||||
|
stf = STFClient()
|
||||||
|
task_cls = get_task_class("douyin_nurture")
|
||||||
|
task = task_cls()
|
||||||
|
params = dict(overrides)
|
||||||
|
# 命令行友好:环境变量 WATCH_COUNT 覆盖视频数
|
||||||
|
if "WATCH_COUNT" in os.environ and "watch_count" not in params:
|
||||||
|
params["watch_count"] = int(os.environ["WATCH_COUNT"])
|
||||||
|
worker = task.create_worker(stf, serial, params)
|
||||||
|
worker.run() # 直接在当前线程跑,不起子线程
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_once()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 设备自动化后台 依赖清单
|
||||||
|
# 安装:pip install -r requirements.txt
|
||||||
|
#
|
||||||
|
# 注意:uiautomator2 依赖 adb,本项目的 bin/adb/ 已自带 adb.exe,
|
||||||
|
# 无需单独安装 Android SDK Platform Tools。
|
||||||
|
# uiautomator2 首次连接设备时会自动推送 atx-agent 到设备。
|
||||||
|
|
||||||
|
# Web 框架
|
||||||
|
Flask>=2.3,<4.0
|
||||||
|
|
||||||
|
# Flask 扩展
|
||||||
|
Flask-Login>=0.6
|
||||||
|
Flask-SQLAlchemy>=3.0,<4.0
|
||||||
|
|
||||||
|
# 定时任务调度
|
||||||
|
APScheduler>=3.10,<4.0
|
||||||
|
|
||||||
|
# HTTP 客户端(STF API 调用)
|
||||||
|
requests>=2.28
|
||||||
|
|
||||||
|
# Android UI 自动化(uiautomator2)
|
||||||
|
uiautomator2>=3.0
|
||||||
|
|
||||||
|
# APK 元信息解析(应用管理功能:自动读取包名/版本/应用名)
|
||||||
|
pyaxmlparser>=0.3.27
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""scripts 包:实用脚本(清理、批量操作等)。每个脚本可独立运行。"""
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""清理 STF 上残留的设备占用(强杀进程导致没正常释放时用)。
|
||||||
|
|
||||||
|
运行:python scripts/cleanup.py
|
||||||
|
(在 auto/ 目录下运行也可:python -m scripts.cleanup)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# 让脚本既能直接运行(python scripts/cleanup.py)又能作为模块运行。
|
||||||
|
# scripts/ 的上一级 auto/ 是 core 包的父目录,需加入 sys.path。
|
||||||
|
_AUTO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if _AUTO_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, _AUTO_DIR)
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from config import STF_URL, STF_TOKEN
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {STF_TOKEN}"}
|
||||||
|
|
||||||
|
# /user/devices 返回当前用户正在占用的设备
|
||||||
|
resp = requests.get(f"{STF_URL}/api/v1/user/devices", headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
mine = data.get("devices", [])
|
||||||
|
print(f"当前账户占用的设备数: {len(mine)}")
|
||||||
|
for d in mine:
|
||||||
|
print(f" - {d.get('serial')} using={d.get('using')}")
|
||||||
|
|
||||||
|
# 全部释放
|
||||||
|
for d in mine:
|
||||||
|
serial = d["serial"]
|
||||||
|
try:
|
||||||
|
requests.post(
|
||||||
|
f"{STF_URL}/api/v1/user/devices/{serial}/remoteDisconnect",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
r = requests.delete(f"{STF_URL}/api/v1/user/devices/{serial}", headers=headers)
|
||||||
|
print(f"释放 {serial}: HTTP {r.status_code}")
|
||||||
|
|
||||||
|
# 再看一次总设备池
|
||||||
|
resp = requests.get(f"{STF_URL}/api/v1/devices", headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
free = [d for d in resp.json().get("devices", [])
|
||||||
|
if d.get("present") and d.get("ready") and not d.get("using") and d.get("owner") is None]
|
||||||
|
print(f"\n清理后空闲设备数: {len(free)}")
|
||||||
|
for d in free:
|
||||||
|
print(f" - {d.get('serial')}")
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
@echo off
|
||||||
|
title Fix Web Access
|
||||||
|
|
||||||
|
:: Auto elevate to admin
|
||||||
|
net session >nul 2>&1
|
||||||
|
if %errorLevel% neq 0 (
|
||||||
|
echo Requesting admin privileges...
|
||||||
|
powershell -Command "Start-Process cmd -ArgumentList '/c ""%~f0""' -Verb RunAs"
|
||||||
|
exit /b
|
||||||
|
)
|
||||||
|
|
||||||
|
echo ============================================
|
||||||
|
echo Fix Web Backend Access
|
||||||
|
echo ============================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: 1. Disable residual system proxy (blocks localhost)
|
||||||
|
echo [1/4] Disabling system proxy...
|
||||||
|
reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings" /v ProxyEnable /t REG_DWORD /d 0 /f >nul 2>&1
|
||||||
|
reg delete "HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings" /v ProxyServer /f >nul 2>&1
|
||||||
|
echo Done.
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: 2. Add firewall rules for Python
|
||||||
|
echo [2/4] Adding firewall rules for Python...
|
||||||
|
for /f "delims=" %%i in ('where python') do set PYTHON_PATH=%%i
|
||||||
|
if not defined PYTHON_PATH (
|
||||||
|
echo ERROR: Python not found in PATH
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo Python path: %PYTHON_PATH%
|
||||||
|
netsh advfirewall firewall delete rule name="PythonWeb" >nul 2>&1
|
||||||
|
netsh advfirewall firewall add rule name="PythonWeb" dir=in action=allow program="%PYTHON_PATH%" enable=yes profile=any >nul
|
||||||
|
netsh advfirewall firewall delete rule name="PythonWebOut" >nul 2>&1
|
||||||
|
netsh advfirewall firewall add rule name="PythonWebOut" dir=out action=allow program="%PYTHON_PATH%" enable=yes profile=any >nul
|
||||||
|
echo Done.
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: 3. Flush DNS cache
|
||||||
|
echo [3/4] Flushing DNS cache...
|
||||||
|
ipconfig /flushdns >nul 2>&1
|
||||||
|
echo Done.
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: 4. Show excluded port ranges for reference
|
||||||
|
echo [4/4] TCP excluded port ranges (for reference):
|
||||||
|
netsh interface ipv4 show excludedportrange protocol=tcp
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo ============================================
|
||||||
|
echo Fix complete! Now:
|
||||||
|
echo 1. Close ALL browser windows and reopen
|
||||||
|
echo 2. Run: python web_server.py
|
||||||
|
echo 3. Open: http://127.0.0.1:5050/
|
||||||
|
echo ============================================
|
||||||
|
pause
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""打包脚本:把项目代码打成 zip 压缩包,排除运行时产物。
|
||||||
|
|
||||||
|
用法:python scripts/pack.py
|
||||||
|
输出:项目根目录下 platform-tools.zip
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
OUT = os.path.join(os.path.dirname(ROOT), "platform-tools.zip")
|
||||||
|
|
||||||
|
SKIP_DIRS = {"__pycache__", ".git", ".idea", "venv", ".venv", "node_modules"}
|
||||||
|
SKIP_EXTS = {".pyc", ".pyo"}
|
||||||
|
# 路径片段包含这些的跳过
|
||||||
|
SKIP_PARTS = {"__pycache__"}
|
||||||
|
# 文件名完全匹配这些的跳过
|
||||||
|
SKIP_FILES = {"fix_web.bat"}
|
||||||
|
# 文件名后缀匹配这些的跳过
|
||||||
|
SKIP_SUFFIX = (".migrated",)
|
||||||
|
# 特定目录下特定后缀跳过
|
||||||
|
# logs/ 下的 .log
|
||||||
|
# data/apks/ 下的 .apk
|
||||||
|
|
||||||
|
|
||||||
|
def should_skip(path, name):
|
||||||
|
full = os.path.join(path, name)
|
||||||
|
rel = os.path.relpath(full, ROOT)
|
||||||
|
norm = rel.replace("\\", "/")
|
||||||
|
if "__pycache__" in norm:
|
||||||
|
return True
|
||||||
|
if name in SKIP_FILES:
|
||||||
|
return True
|
||||||
|
if name.endswith(SKIP_SUFFIX):
|
||||||
|
return True
|
||||||
|
ext = os.path.splitext(name)[1]
|
||||||
|
if ext in SKIP_EXTS:
|
||||||
|
return True
|
||||||
|
if name.endswith(".log") and "/logs/" in norm:
|
||||||
|
return True
|
||||||
|
if name.endswith(".apk") and "/data/apks/" in norm:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if os.path.exists(OUT):
|
||||||
|
os.remove(OUT)
|
||||||
|
count = 0
|
||||||
|
with zipfile.ZipFile(OUT, "w", zipfile.ZIP_DEFLATED) as z:
|
||||||
|
for dp, dn, fn in os.walk(ROOT):
|
||||||
|
# 原地修改 dn 跳过目录
|
||||||
|
dn[:] = [d for d in dn if d not in SKIP_DIRS]
|
||||||
|
for f in fn:
|
||||||
|
if should_skip(dp, f):
|
||||||
|
continue
|
||||||
|
full = os.path.join(dp, f)
|
||||||
|
arc = os.path.relpath(full, os.path.dirname(ROOT))
|
||||||
|
z.write(full, arc)
|
||||||
|
count += 1
|
||||||
|
size_kb = os.path.getsize(OUT) // 1024
|
||||||
|
print(f"打包完成: {OUT}")
|
||||||
|
print(f"文件数: {count}, 大小: {size_kb} KB ({size_kb/1024:.1f} MB)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul 2>&1
|
||||||
|
title 设备自动化后台(局域网模式)
|
||||||
|
|
||||||
|
REM ===== 自动提权到管理员 =====
|
||||||
|
net session >nul 2>&1
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo 需要管理员权限以支持局域网访问,正在提权...
|
||||||
|
powershell -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
|
||||||
|
exit /b
|
||||||
|
)
|
||||||
|
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
REM ===== 添加防火墙入站规则(放行 Python,仅一次) =====
|
||||||
|
for /f "delims=" %%i in ('where python') do set PY_EXE=%%i
|
||||||
|
netsh advfirewall firewall show rule name="PlatformTools Web" >nul 2>&1
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo 正在添加防火墙规则...
|
||||||
|
netsh advfirewall firewall add rule name="PlatformTools Web" dir=in action=allow program="%PY_EXE%" enable=yes >nul 2>&1
|
||||||
|
netsh advfirewall firewall add rule name="PlatformTools Web" dir=out action=allow program="%PY_EXE%" enable=yes >nul 2>&1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo ============================================
|
||||||
|
echo 管理后台已启动(局域网可访问)
|
||||||
|
echo 本机访问: http://127.0.0.1:5050/
|
||||||
|
echo 局域网访问: http://本机IP:5050/
|
||||||
|
echo 默认账号: admin / admin123
|
||||||
|
echo 按 Ctrl+C 停止
|
||||||
|
echo ============================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
python web_server.py
|
||||||
|
pause
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
:root{
|
||||||
|
--primary:#3b82f6; --primary-dark:#2563eb; --success:#10b981; --danger:#ef4444;
|
||||||
|
--bg:#f8fafc; --card:#ffffff; --border:#e2e8f0; --text:#1e293b; --text-muted:#64748b;
|
||||||
|
}
|
||||||
|
body{background:var(--bg);font-family:-apple-system,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;color:var(--text)}
|
||||||
|
/* 导航栏:深色渐变 */
|
||||||
|
.navbar{background:linear-gradient(135deg,#1e293b 0%,#334155 100%)!important;border:none!important;box-shadow:0 2px 8px rgba(0,0,0,.15);border-radius:0}
|
||||||
|
.navbar-brand,.navbar-nav>li>a,.navbar-nav>li>a>.nav-link{color:#cbd5e1!important;font-weight:500;font-size:14px}
|
||||||
|
.navbar-brand{font-weight:700!important;font-size:16px!important}
|
||||||
|
.navbar-nav>li>a:hover,.navbar-brand:hover{color:#fff!important;background:rgba(255,255,255,.08)!important}
|
||||||
|
.navbar-nav>li.active>a{background:rgba(59,130,246,.25)!important;color:#fff!important}
|
||||||
|
/* 内容容器 */
|
||||||
|
.container-fluid{max-width:1600px;padding:24px 28px}
|
||||||
|
/* 卡片面板 */
|
||||||
|
.panel{border:1px solid var(--border)!important;border-radius:12px!important;box-shadow:0 1px 3px rgba(0,0,0,.06)!important;background:var(--card)}
|
||||||
|
.panel-heading{background:#f8fafc!important;border-bottom:1px solid var(--border)!important;font-weight:600;color:var(--text)!important;border-radius:12px 12px 0 0!important}
|
||||||
|
/* 表格 */
|
||||||
|
.table{background:var(--card);border-radius:8px;overflow:hidden;border:1px solid var(--border)}
|
||||||
|
.table thead th{background:#f1f5f9!important;border-bottom:2px solid var(--border)!important;font-weight:600!important;color:var(--text-muted)!important;font-size:12px!important;text-transform:uppercase;letter-spacing:.5px;padding:12px 14px!important}
|
||||||
|
.table tbody td{padding:10px 14px!important;border-bottom:1px solid var(--border)!important;vertical-align:middle!important;font-size:13px!important}
|
||||||
|
.table tbody tr:hover td{background:#f8fafc!important}
|
||||||
|
/* 按钮 */
|
||||||
|
.btn{border-radius:8px!important;font-weight:500!important;border:1px solid var(--border)!important;padding:7px 16px!important;font-size:13px!important;transition:all .15s!important}
|
||||||
|
.btn-primary{background:var(--primary)!important;border-color:var(--primary)!important;color:#fff!important}
|
||||||
|
.btn-primary:hover{background:var(--primary-dark)!important}
|
||||||
|
.btn-danger{background:var(--danger)!important;border-color:var(--danger)!important;color:#fff!important}
|
||||||
|
.btn-danger:hover{background:#dc2626!important}
|
||||||
|
.btn-default{background:var(--card)!important;color:var(--text)!important}
|
||||||
|
.btn-default:hover{background:#f1f5f9!important;border-color:#cbd5e1!important}
|
||||||
|
/* 表单 */
|
||||||
|
.form-control{border-radius:8px!important;border:1px solid var(--border)!important;padding:7px 12px!important;font-size:13px!important}
|
||||||
|
.form-control:focus{border-color:var(--primary)!important;box-shadow:0 0 0 3px rgba(59,130,246,.1)!important}
|
||||||
|
/* 徽章 */
|
||||||
|
.label,.badge{padding:4px 10px!important;border-radius:6px!important;font-size:11px!important;font-weight:600!important}
|
||||||
|
.label-success,.badge-success{background:#dcfce7!important;color:#166534!important}
|
||||||
|
.label-default,.badge-default{background:#f1f5f9!important;color:#64748b!important}
|
||||||
|
.label-warning,.badge-warning{background:#fef3c7!important;color:#92400e!important}
|
||||||
|
.label-danger,.badge-danger{background:#fee2e2!important;color:#991b1b!important}
|
||||||
|
/* 分页/操作菜单 */
|
||||||
|
.pagination{margin:16px 0 0}
|
||||||
|
.pagination>li>a,.pagination>li>span{border-radius:6px!important;margin:0 2px;border:1px solid var(--border)!important;color:var(--text-muted)!important}
|
||||||
|
.pagination>li.active>a,.pagination>li.active>span{background:var(--primary)!important;border-color:var(--primary)!important;color:#fff!important}
|
||||||
|
.dropdown-menu{border-radius:8px!important;border:1px solid var(--border)!important;box-shadow:0 4px 12px rgba(0,0,0,.08)!important}
|
||||||
|
/* 首页链接 */
|
||||||
|
.list-group-item{border:1px solid var(--border)!important}
|
||||||
|
a{color:var(--primary)}
|
||||||
|
a:hover{color:var(--primary-dark)}
|
||||||
|
/* 页面标题 */
|
||||||
|
h4.mb-4{font-weight:700!important;color:var(--text)!important;font-size:18px!important}
|
||||||
|
/* 警告框 */
|
||||||
|
.alert{border-radius:8px!important;border:1px solid var(--border)!important}
|
||||||
|
.alert-info{background:#dbeafe!important;border-color:#bfdbfe!important;color:#1e40af!important}
|
||||||
|
.alert-danger{background:#fee2e2!important;border-color:#fecaca!important;color:#991b1b!important}
|
||||||
|
/* 选中行高亮 */
|
||||||
|
.table tbody tr.selected td{background:#dbeafe!important}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""tasks 包:任务定义层。
|
||||||
|
|
||||||
|
每个任务一个独立子包(文件夹),自包含任务定义 + 专属操作 + 参数。
|
||||||
|
新增任务:在此目录新建 xxx/ 子包,定义 BaseTask 子类并用 @register_task 装饰,
|
||||||
|
然后在这里 import(from .xxx import task)。
|
||||||
|
"""
|
||||||
|
from .base import BaseTask, register_task, list_task_types, get_task_class
|
||||||
|
|
||||||
|
# 导入所有任务包,触发 @register_task 注册
|
||||||
|
from .douyin import task # noqa: F401
|
||||||
|
|
||||||
|
__all__ = ["BaseTask", "register_task", "list_task_types", "get_task_class"]
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
"""任务类型基类与注册机制。
|
||||||
|
|
||||||
|
目录约定:
|
||||||
|
config.py — 项目根配置(STF/adb/web 等,核心程序用,不放任务参数)
|
||||||
|
core/ — 核心程序(STF、adb、BaseWorker、任务管理器)
|
||||||
|
tasks/ — 任务包,每个任务一个子文件夹,自包含任务+专属操作+参数
|
||||||
|
tasks/<name>/
|
||||||
|
__init__.py — import task 触发注册
|
||||||
|
task.py — BaseTask 子类 + Worker + DEFAULT_PARAMS
|
||||||
|
actions/ — 该任务专属操作(xpath 等只适用于本任务,不污染全局)
|
||||||
|
__init__.py
|
||||||
|
base.py — 本任务的操作基类 + 注册器
|
||||||
|
xxx.py — 具体操作
|
||||||
|
|
||||||
|
新增任务步骤(照着 tasks/douyin/ 抄即可):
|
||||||
|
|
||||||
|
1. 在 tasks/ 下新建 my_task/ 子包(文件夹 + __init__.py)
|
||||||
|
2. 在 my_task/task.py 顶部写默认参数:
|
||||||
|
|
||||||
|
DEFAULT_PARAMS = {"loop": 10, "actions": {...}}
|
||||||
|
|
||||||
|
3. 写一个 BaseWorker 子类,实现 run_task(d):
|
||||||
|
|
||||||
|
from core.device_worker import BaseWorker, _update_status
|
||||||
|
|
||||||
|
class MyWorker(BaseWorker):
|
||||||
|
def run_task(self, d): # d 是 u2.Device,基类已连好
|
||||||
|
d.app_start("com.xxx", wait=True)
|
||||||
|
while not self.stopped():
|
||||||
|
self.set_action("干活中")
|
||||||
|
self.set_progress(done=1)
|
||||||
|
|
||||||
|
4. 写一个 BaseTask 子类,用 @register_task 装饰,实现 create_worker:
|
||||||
|
|
||||||
|
@register_task
|
||||||
|
class MyTask(BaseTask):
|
||||||
|
task_type = "my_task"
|
||||||
|
name = "我的任务"
|
||||||
|
default_params = dict(DEFAULT_PARAMS)
|
||||||
|
|
||||||
|
# 有专属操作时重写这两个方法,从 actions 包取
|
||||||
|
@classmethod
|
||||||
|
def list_action_types(cls):
|
||||||
|
from .actions import list_action_types
|
||||||
|
return list_action_types()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_action_class(cls, action_type):
|
||||||
|
from .actions import get_action_class
|
||||||
|
return get_action_class(action_type)
|
||||||
|
|
||||||
|
def create_worker(self, stf, serial, params):
|
||||||
|
return MyWorker(stf, serial, params=params)
|
||||||
|
|
||||||
|
5. 在 my_task/__init__.py 加:from . import task (触发注册)
|
||||||
|
6. 在 tasks/__init__.py 加:from .my_task import task (触发注册)
|
||||||
|
|
||||||
|
做完前端自动出现新任务。有专属操作时前端"操作"区自动列出。
|
||||||
|
|
||||||
|
BaseWorker 已封装好的(子类不用管):
|
||||||
|
- STF 占用/释放(try/finally 保证释放)
|
||||||
|
- adb 连接 / u2.connect
|
||||||
|
- 状态上报(status/current_action/last_error/model/remote_adb_url)
|
||||||
|
- 异常捕获(run_task 里抛异常 → status=error,设备仍会释放)
|
||||||
|
- stop 信号(self.stopped() 检查)
|
||||||
|
|
||||||
|
子类可用的工具:
|
||||||
|
self.stopped() — 是否被要求停止
|
||||||
|
self.set_action(s) — 上报当前动作
|
||||||
|
self.set_progress() — 上报进度字段
|
||||||
|
self.d — u2.Device
|
||||||
|
self.params — 任务参数 dict
|
||||||
|
core.u2_helper — 通用 u2 辅助函数(ensure_app_running/safe_click 等)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class BaseTask:
|
||||||
|
"""任务类型基类。子类实现 create_worker。
|
||||||
|
|
||||||
|
有专属操作的任务重写 list_action_types / get_action_class,
|
||||||
|
从自己的 actions 子包取(操作 xpath 等只适用于本任务)。
|
||||||
|
"""
|
||||||
|
task_type = "base"
|
||||||
|
name = "基础任务"
|
||||||
|
description = ""
|
||||||
|
default_params = {}
|
||||||
|
|
||||||
|
def create_worker(self, stf, serial, params):
|
||||||
|
"""返回一个 threading.Thread(已启动或待启动),执行实际任务。"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def list_action_types(cls):
|
||||||
|
"""返回本任务支持的操作列表。默认无操作,子类按需重写。"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_action_class(cls, action_type):
|
||||||
|
"""按 action_type 取操作类。默认无操作,子类按需重写。"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_TASK_TYPES = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register_task(task_cls):
|
||||||
|
"""注册新任务类型。用作装饰器。"""
|
||||||
|
_TASK_TYPES[task_cls.task_type] = task_cls
|
||||||
|
return task_cls
|
||||||
|
|
||||||
|
|
||||||
|
def list_task_types():
|
||||||
|
"""返回所有已注册任务类型的元信息(供前端展示)。"""
|
||||||
|
return [{"task_type": t.task_type, "name": t.name, "description": t.description,
|
||||||
|
"default_params": t.default_params} for t in _TASK_TYPES.values()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_class(task_type):
|
||||||
|
"""按 task_type 取任务类。"""
|
||||||
|
return _TASK_TYPES.get(task_type)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""抖音养号任务包。
|
||||||
|
|
||||||
|
本包自包含:任务定义(task.py)+ 抖音专属操作(actions/)。
|
||||||
|
抖音的点赞/评论 xpath 只适用于抖音,不放全局,避免和其他任务混淆。
|
||||||
|
|
||||||
|
加新抖音操作:在 actions/ 下建 .py,继承本包 BaseAction + @register_action,
|
||||||
|
在 actions/__init__.py import。
|
||||||
|
"""
|
||||||
|
from . import task # noqa: F401 触发 @register_task 注册抖音任务
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""抖音操作注册包。import 触发各操作注册。
|
||||||
|
|
||||||
|
加新抖音操作:新建 xxx.py,在下面加一行 from . import xxx。
|
||||||
|
注意:必须先从 base 导入 ACTIONS,再导入各操作模块,否则循环导入。
|
||||||
|
"""
|
||||||
|
# 先初始化注册表(base.py 里定义了 ACTIONS = create_action_registry())
|
||||||
|
from .base import (
|
||||||
|
BaseAction, register_action, create_action_registry,
|
||||||
|
list_actions, get_action, should_trigger,
|
||||||
|
ACTIONS,
|
||||||
|
list_action_types, get_action_class,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 再导入各操作模块,触发 @register_action(ACTIONS) 注册
|
||||||
|
from . import like # noqa: F401
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BaseAction", "register_action", "create_action_registry",
|
||||||
|
"list_actions", "get_action", "should_trigger",
|
||||||
|
"ACTIONS", "list_action_types", "get_action_class",
|
||||||
|
]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""抖音操作基类与注册机制。
|
||||||
|
|
||||||
|
操作 = 抖音养号主循环里某个时机执行的一次具体行为(点赞/评论/关注等)。
|
||||||
|
这里的注册表只对抖音任务生效,其他任务有自己的 actions 包。
|
||||||
|
|
||||||
|
设计:抖音的 ACTIONS 注册表是独立的 dict,用 core.actions 的
|
||||||
|
register_action(ACTIONS) 装饰器注册,和全局/其他 app 互不污染。
|
||||||
|
|
||||||
|
新增抖音操作步骤(照着 like.py 抄):
|
||||||
|
|
||||||
|
1. 在 tasks/douyin/actions/ 下新建 my_action.py
|
||||||
|
2. 写一个 BaseAction 子类,用 @register_action 装饰,实现 execute:
|
||||||
|
|
||||||
|
from core.actions import BaseAction, register_action, should_trigger
|
||||||
|
from . import ACTIONS # 本任务注册表
|
||||||
|
|
||||||
|
@register_action(ACTIONS)
|
||||||
|
class MyAction(BaseAction):
|
||||||
|
action_type = "my_action"
|
||||||
|
name = "我的操作"
|
||||||
|
description = "做什么"
|
||||||
|
default_params = {"rate": 0.5}
|
||||||
|
|
||||||
|
def execute(self, d, params, worker):
|
||||||
|
if not should_trigger(params["rate"]):
|
||||||
|
return False
|
||||||
|
# ... 用 d(xpath=...) / d(text=...) 操作
|
||||||
|
return True
|
||||||
|
|
||||||
|
3. 在 tasks/douyin/actions/__init__.py 加一行:from . import my_action
|
||||||
|
|
||||||
|
做完前端自动出现该操作的可勾选项。
|
||||||
|
"""
|
||||||
|
from core.actions import (
|
||||||
|
BaseAction, register_action, create_action_registry,
|
||||||
|
list_actions, get_action, should_trigger,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 抖音专属操作注册表(独立 dict,不污染其他 app)
|
||||||
|
ACTIONS = create_action_registry()
|
||||||
|
|
||||||
|
|
||||||
|
# 兼容旧导入:tasks/douyin/actions/__init__.py 原来从这里导出
|
||||||
|
def list_action_types():
|
||||||
|
"""返回所有已注册抖音操作的元信息(供前端展示)。"""
|
||||||
|
return list_actions(ACTIONS)
|
||||||
|
|
||||||
|
|
||||||
|
def get_action_class(action_type):
|
||||||
|
"""按 action_type 取抖音操作类。"""
|
||||||
|
return get_action(ACTIONS, action_type)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""抖音点赞操作。
|
||||||
|
|
||||||
|
两种方式:
|
||||||
|
by_element — 用 xpath 找右侧红心按钮点击(相对定位,不依赖视频卡片序号)
|
||||||
|
double_click — 双击屏幕中央(兜底,不依赖元素,抖音"双击点赞"手势)
|
||||||
|
|
||||||
|
定位器随抖音版本变,by_element 找不到会自动回退双击,不会卡住。
|
||||||
|
要适配新版本抖音:用 weditor 重新抓当前界面,调整下面定位逻辑。
|
||||||
|
"""
|
||||||
|
from core.actions import BaseAction, register_action, should_trigger
|
||||||
|
from core.logger import get_logger
|
||||||
|
from . import ACTIONS
|
||||||
|
|
||||||
|
_log = get_logger("action.like")
|
||||||
|
|
||||||
|
|
||||||
|
@register_action(ACTIONS)
|
||||||
|
class LikeAction(BaseAction):
|
||||||
|
action_type = "like"
|
||||||
|
name = "点赞"
|
||||||
|
description = "看完视频后随机点赞(找红心按钮,找不到回退双击)"
|
||||||
|
default_params = {
|
||||||
|
"rate": 0.3, # 触发概率 0~1
|
||||||
|
"method": "by_element", # by_element=找红心 | double_click=双击屏幕
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self, d, params, worker):
|
||||||
|
rate = float(params.get("rate", 0.3))
|
||||||
|
if not should_trigger(rate):
|
||||||
|
return False
|
||||||
|
method = params.get("method", "by_element")
|
||||||
|
if method == "by_element":
|
||||||
|
if self._like_by_element(d):
|
||||||
|
worker.set_action("点赞(红心)")
|
||||||
|
return True
|
||||||
|
# 找不到红心,回退双击
|
||||||
|
ok = self._double_click_center(d)
|
||||||
|
if ok:
|
||||||
|
worker.set_action("点赞(双击)")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _like_by_element(d):
|
||||||
|
"""找右侧操作栏红心按钮点击,成功返回 True。
|
||||||
|
|
||||||
|
定位优先级:description 精确 → descriptionContains 模糊 → resourceId → xpath
|
||||||
|
每种方式找到即点即返回,找不到继续下一种。全程记日志。
|
||||||
|
"""
|
||||||
|
# 方式1:description 精确匹配(最稳,不依赖结构)
|
||||||
|
for desc in ("点赞", "未点赞", "like"):
|
||||||
|
el = d(description=desc)
|
||||||
|
if el.exists:
|
||||||
|
el.click()
|
||||||
|
_log.info(f"like 命中 description={desc}")
|
||||||
|
return True
|
||||||
|
# 方式2:descriptionContains 模糊匹配(适配不同版本文案)
|
||||||
|
for kw in ("赞", "like"):
|
||||||
|
el = d(descriptionContains=kw)
|
||||||
|
if el.exists:
|
||||||
|
el.click()
|
||||||
|
_log.info(f"like 命中 descriptionContains={kw}")
|
||||||
|
return True
|
||||||
|
# 方式3:resourceId 列表(扩充候选,覆盖更多抖音版本)
|
||||||
|
for rid in ("com.ss.android.ugc.aweme:id/aky",
|
||||||
|
"com.ss.android.ugc.aweme:id/d-like-view-icon",
|
||||||
|
"com.ss.android.ugc.aweme:id/h3r",
|
||||||
|
"com.ss.android.ugc.aweme:id/dfz",
|
||||||
|
"com.ss.android.ugc.aweme:id/c5w"):
|
||||||
|
el = d(resourceId=rid)
|
||||||
|
if el.exists:
|
||||||
|
el.click()
|
||||||
|
_log.info(f"like 命中 resourceId={rid}")
|
||||||
|
return True
|
||||||
|
# 方式4:xpath 相对定位——右侧操作栏 LinearLayout 的第一个 ImageView
|
||||||
|
# 定位思路:ViewPager 下当前 FrameLayout 里的 LinearLayout,
|
||||||
|
# 取其直接子 FrameLayout 里的第一个 ImageView(点赞在操作栏最上方)
|
||||||
|
try:
|
||||||
|
el = d.xpath(
|
||||||
|
'//androidx.viewpager.widget.ViewPager'
|
||||||
|
'/android.widget.FrameLayout'
|
||||||
|
'/com.bytedance.highperformanceview.layout.MeasureOnceRelativeLayout2'
|
||||||
|
'/com.bytedance.highperformanceview.layout.MeasureOnceRelativeLayout2'
|
||||||
|
'/android.widget.LinearLayout/android.widget.FrameLayout[2]'
|
||||||
|
'/android.widget.LinearLayout/android.widget.FrameLayout[1]'
|
||||||
|
'/android.widget.FrameLayout[1]/android.widget.ImageView'
|
||||||
|
)
|
||||||
|
if el.exists:
|
||||||
|
el.click()
|
||||||
|
_log.info("like 命中 xpath")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"like xpath 异常: {e}")
|
||||||
|
_log.info("like_by_element 全部失败,准备回退双击")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _double_click_center(d):
|
||||||
|
"""双击屏幕中央点赞。按屏幕尺寸算坐标,适配不同分辨率。"""
|
||||||
|
try:
|
||||||
|
info = d.info
|
||||||
|
w, h = info["displayWidth"], info["displayHeight"]
|
||||||
|
d.double_click(int(w * 0.5), int(h * 0.5))
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""抖音养号任务定义。
|
||||||
|
|
||||||
|
本文件自包含所有抖音养号参数(观看、滑动、操作等),不依赖 core 的业务配置。
|
||||||
|
抖音专属操作(点赞/评论)在 actions/ 子包里,xpath 只适用于抖音。
|
||||||
|
|
||||||
|
修改养号行为:改下面 DEFAULT_PARAMS 默认值,或通过前端任务参数覆盖。
|
||||||
|
修改养号逻辑:改 DouyinNurtureWorker.run_task。
|
||||||
|
加新抖音操作:在 actions/ 下建 .py,继承 BaseAction + @register_action。
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
|
||||||
|
from tasks.base import BaseTask, register_task
|
||||||
|
from .actions import list_action_types, get_action_class
|
||||||
|
from core.device_worker import BaseWorker, _update_status
|
||||||
|
from core.u2_helper import ensure_app_running, wait_for_app_home
|
||||||
|
from core.logger import get_logger
|
||||||
|
|
||||||
|
_log = get_logger("task.douyin")
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 抖音养号参数(本任务专属,写在这里) ==================
|
||||||
|
DOUYIN_PKG = "com.ss.android.ugc.aweme"
|
||||||
|
|
||||||
|
DEFAULT_PARAMS = {
|
||||||
|
# 观看
|
||||||
|
"watch_count": 80, # 观看视频数量(0=不限数量,仅按 max_duration 终止)
|
||||||
|
"watch_min": 5.0, # 单个视频最短观看秒数
|
||||||
|
"watch_max": 35.0, # 单个视频最长观看秒数
|
||||||
|
# 运行时长终止(通用,0=不限时,按 watch_count 终止)
|
||||||
|
# 两个条件哪个先到就停;都为 0 则永不停止(需手动停止)
|
||||||
|
"max_duration": 0, # 最大运行时长(秒),如 1800=30分钟,0=不限时
|
||||||
|
# 滑动
|
||||||
|
"swipe_min": 0.25, # 上滑手势最短时长(秒)
|
||||||
|
"swipe_max": 0.50, # 上滑手势最长时长(秒)
|
||||||
|
"gap_min": 1.0, # 视频间隔最短秒数
|
||||||
|
"gap_max": 3.0, # 视频间隔最长秒数
|
||||||
|
# 操作(每个操作有 enabled 开关 + 自身参数)
|
||||||
|
"actions": {
|
||||||
|
"like": {
|
||||||
|
"enabled": True,
|
||||||
|
"params": {"rate": 0.3, "method": "by_element"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DouyinNurtureWorker(BaseWorker):
|
||||||
|
"""抖音养号 worker:看视频 + 按配置执行操作(点赞/评论等),被退出自动重连。
|
||||||
|
|
||||||
|
只需实现 run_task(d),STF 占用/释放、u2 连接、异常、状态上报由基类处理。
|
||||||
|
操作参数优先级:任务 params.actions > 本文件 DEFAULT_PARAMS.actions。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, stf_client, serial, params=None):
|
||||||
|
super().__init__(stf_client, serial, params)
|
||||||
|
p = {**DEFAULT_PARAMS, **(self.params or {})}
|
||||||
|
self.watch_count = int(p["watch_count"])
|
||||||
|
self.watch_min = float(p["watch_min"])
|
||||||
|
self.watch_max = float(p["watch_max"])
|
||||||
|
# 通用运行时长终止(0=不限时)
|
||||||
|
self.max_duration = int(p.get("max_duration", 0))
|
||||||
|
self.swipe_min = float(p["swipe_min"])
|
||||||
|
self.swipe_max = float(p["swipe_max"])
|
||||||
|
self.gap_min = float(p["gap_min"])
|
||||||
|
self.gap_max = float(p["gap_max"])
|
||||||
|
# 操作配置:{action_type: {"enabled":bool, "params":{...}}}
|
||||||
|
self.actions_cfg = p.get("actions", {})
|
||||||
|
# 实例化所有启用的操作
|
||||||
|
self._actions = []
|
||||||
|
for atype, cfg in self.actions_cfg.items():
|
||||||
|
if not cfg.get("enabled"):
|
||||||
|
continue
|
||||||
|
cls = get_action_class(atype)
|
||||||
|
if cls:
|
||||||
|
self._actions.append(cls())
|
||||||
|
# 启动日志:打印终止条件 + 启用的 action,便于排查
|
||||||
|
stop_conds = []
|
||||||
|
if self.watch_count > 0:
|
||||||
|
stop_conds.append(f"数量={self.watch_count}")
|
||||||
|
if self.max_duration > 0:
|
||||||
|
stop_conds.append(f"时长={self.max_duration}s")
|
||||||
|
if not stop_conds:
|
||||||
|
stop_conds.append("无(需手动停止)")
|
||||||
|
_log.info(f"[{serial}] 终止条件: {', '.join(stop_conds)}")
|
||||||
|
if self._actions:
|
||||||
|
summary = ", ".join(
|
||||||
|
f"{a.action_type}(rate={self.actions_cfg.get(a.action_type, {}).get('params', {}).get('rate', '?')})"
|
||||||
|
for a in self._actions
|
||||||
|
)
|
||||||
|
_log.info(f"[{serial}] 启用操作: {summary}")
|
||||||
|
else:
|
||||||
|
_log.warning(f"[{serial}] 未启用任何操作,整个任务期间不会执行点赞")
|
||||||
|
|
||||||
|
def run_task(self, d):
|
||||||
|
"""抖音养号主逻辑。d 是 u2.Device,已由基类连好。
|
||||||
|
|
||||||
|
终止条件(哪个先到就停):
|
||||||
|
- watch_count > 0:看完指定数量视频
|
||||||
|
- max_duration > 0:达到最大运行时长
|
||||||
|
- 两者都为 0:永不停止,需手动停止
|
||||||
|
"""
|
||||||
|
def is_home(d):
|
||||||
|
return (d(descriptionContains="拍摄").exists
|
||||||
|
or d(descriptionContains="首页").exists)
|
||||||
|
|
||||||
|
# 启动运行时长计时(基类通用方法)
|
||||||
|
self._start_timer()
|
||||||
|
|
||||||
|
d.app_start(DOUYIN_PKG, wait=True)
|
||||||
|
if not wait_for_app_home(d, DOUYIN_PKG, is_home, timeout=40):
|
||||||
|
self.set_action("首页加载超时,继续尝试")
|
||||||
|
|
||||||
|
watched = 0
|
||||||
|
action_counts = {a.action_type: 0 for a in self._actions}
|
||||||
|
# 初始化通用进度上报
|
||||||
|
# total=0 时前端不显示百分比,只显示已看数量 + 运行时长
|
||||||
|
total = self.watch_count if self.watch_count > 0 else 0
|
||||||
|
self.set_progress(done=0, total=total, unit="视频",
|
||||||
|
action_counts=action_counts)
|
||||||
|
while not self.stopped():
|
||||||
|
# 终止条件检查
|
||||||
|
if self.watch_count > 0 and watched >= self.watch_count:
|
||||||
|
break
|
||||||
|
if self.is_time_up():
|
||||||
|
_log.info(f"[{self.serial}] 达到最大运行时长 {self.max_duration}s,停止")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not ensure_app_running(d, DOUYIN_PKG):
|
||||||
|
_update_status(self.serial, status="error",
|
||||||
|
last_error="抖音连续重启失败,放弃该设备")
|
||||||
|
return
|
||||||
|
_update_status(self.serial, douyin_running=True, last_error="")
|
||||||
|
|
||||||
|
watch = random.uniform(self.watch_min, self.watch_max)
|
||||||
|
# 动作描述包含运行时长,便于前端观察
|
||||||
|
elapsed = self.elapsed()
|
||||||
|
time_info = f",已运行 {elapsed//60}m{elapsed%60}s" if elapsed > 0 else ""
|
||||||
|
self.set_action(f"观看视频 {watched+1},{watch:.0f}s{time_info}")
|
||||||
|
time.sleep(watch)
|
||||||
|
|
||||||
|
# 检测当前是否为可操作的正常视频页(有右侧操作栏 = 点赞/评论按钮)
|
||||||
|
# 营销广告、直播、异常页通常没有这些元素,直接滑过去不做操作
|
||||||
|
is_normal = self._is_normal_video(d)
|
||||||
|
if is_normal:
|
||||||
|
# 正常视频:执行所有启用的操作(看完视频后、滑动前)
|
||||||
|
if not self._actions:
|
||||||
|
_log.info(f"[{self.serial}] 视频{watched+1}: 正常视频但无启用操作")
|
||||||
|
for action in self._actions:
|
||||||
|
if self.stopped():
|
||||||
|
break
|
||||||
|
cfg = self.actions_cfg.get(action.action_type, {})
|
||||||
|
params = {**action.default_params, **cfg.get("params", {})}
|
||||||
|
rate = params.get("rate", "?")
|
||||||
|
try:
|
||||||
|
ok = action.execute(d, params, self)
|
||||||
|
_log.info(f"[{self.serial}] 视频{watched+1}: {action.action_type} "
|
||||||
|
f"rate={rate} execute={ok}")
|
||||||
|
if ok:
|
||||||
|
action_counts[action.action_type] += 1
|
||||||
|
time.sleep(random.uniform(0.5, 1.5))
|
||||||
|
except Exception as e:
|
||||||
|
_log.error(f"[{self.serial}] 视频{watched+1}: 操作 {action.action_type} "
|
||||||
|
f"异常: {e}")
|
||||||
|
else:
|
||||||
|
self.set_action(f"跳过广告/直播 {watched+1}")
|
||||||
|
_log.info(f"[{self.serial}] 视频{watched+1}: 非正常视频页,跳过操作")
|
||||||
|
|
||||||
|
if self.stopped():
|
||||||
|
break
|
||||||
|
d.swipe(500, 1000, 500, 300, random.uniform(self.swipe_min, self.swipe_max))
|
||||||
|
time.sleep(random.uniform(self.gap_min, self.gap_max))
|
||||||
|
watched += 1
|
||||||
|
# 上报通用进度(含运行时长,前端展示)
|
||||||
|
elapsed = self.elapsed()
|
||||||
|
self.set_progress(done=watched, total=total, unit="视频",
|
||||||
|
action_counts=dict(action_counts),
|
||||||
|
elapsed=elapsed)
|
||||||
|
|
||||||
|
# 终止原因汇总
|
||||||
|
stop_reason = ""
|
||||||
|
if self.stopped():
|
||||||
|
stop_reason = "(手动停止)"
|
||||||
|
elif self.is_time_up():
|
||||||
|
stop_reason = f"(达到时长 {self.max_duration}s)"
|
||||||
|
elif self.watch_count > 0 and watched >= self.watch_count:
|
||||||
|
stop_reason = f"(达到数量 {self.watch_count})"
|
||||||
|
summary = f"完成 {watched} 个视频" + "".join(
|
||||||
|
f",{k} {v}次" for k, v in action_counts.items() if v
|
||||||
|
) + stop_reason
|
||||||
|
_update_status(self.serial, douyin_running=False, current_action=summary)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_normal_video(d):
|
||||||
|
"""判断当前是否为可操作的正常视频页。
|
||||||
|
|
||||||
|
正常视频页右侧有操作栏(点赞/评论/分享按钮)。
|
||||||
|
营销广告、直播、异常页通常没有这些元素 → 返回 False,直接滑过去。
|
||||||
|
检测任一特征元素存在即认为是正常视频页。
|
||||||
|
|
||||||
|
定位优先级:description 精确 → descriptionContains 模糊 → resourceId 列表 → 兜底
|
||||||
|
每次返回 False 时记日志,便于定位"为什么操作没执行"。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1. description 精确匹配(最稳)
|
||||||
|
for desc in ("点赞", "未点赞", "评论", "未评论"):
|
||||||
|
if d(description=desc).exists:
|
||||||
|
return True
|
||||||
|
# 2. descriptionContains 模糊匹配(适配不同版本文案)
|
||||||
|
for kw in ("赞", "评论"):
|
||||||
|
if d(descriptionContains=kw).exists:
|
||||||
|
return True
|
||||||
|
# 3. resourceId 列表(扩充候选,覆盖更多抖音版本)
|
||||||
|
for rid in ("com.ss.android.ugc.aweme:id/aky",
|
||||||
|
"com.ss.android.ugc.aweme:id/akq",
|
||||||
|
"com.ss.android.ugc.aweme:id/d-like-view-icon",
|
||||||
|
"com.ss.android.ugc.aweme:id/d-comment-view-icon",
|
||||||
|
"com.ss.android.ugc.aweme:id/h3r"):
|
||||||
|
if d(resourceId=rid).exists:
|
||||||
|
return True
|
||||||
|
# 所有特征元素都没找到 → 非正常视频页(广告/直播/异常)
|
||||||
|
_log.info(f"_is_normal_video=False: 未找到点赞/评论特征元素")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"_is_normal_video 异常: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@register_task
|
||||||
|
class DouyinNurtureTask(BaseTask):
|
||||||
|
"""抖音养号:看视频 + 按配置执行操作(点赞/评论等),被退出自动重连。"""
|
||||||
|
task_type = "douyin_nurture"
|
||||||
|
name = "抖音养号"
|
||||||
|
description = "自动观看抖音视频,按配置执行点赞/评论等操作,被退出自动重连"
|
||||||
|
default_params = dict(DEFAULT_PARAMS)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def list_action_types(cls):
|
||||||
|
"""返回本任务支持的抖音操作列表。"""
|
||||||
|
return list_action_types()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_action_class(cls, action_type):
|
||||||
|
"""按 action_type 取抖音操作类。"""
|
||||||
|
return get_action_class(action_type)
|
||||||
|
|
||||||
|
def create_worker(self, stf, serial, params):
|
||||||
|
merged = {**DEFAULT_PARAMS, **(params or {})}
|
||||||
|
# actions 字段深合并(参数级,保留前端没传的操作默认值)
|
||||||
|
# 确保即使前端只传 actions.like.enabled,like.params.rate 也能拿到默认 0.3
|
||||||
|
default_actions = DEFAULT_PARAMS["actions"]
|
||||||
|
merged_actions = params.get("actions", {}) if params else {}
|
||||||
|
for atype, dflt in default_actions.items():
|
||||||
|
if atype not in merged_actions:
|
||||||
|
# 前端完全没传这个操作 → 用默认
|
||||||
|
merged_actions[atype] = dflt
|
||||||
|
else:
|
||||||
|
# 前端传了 → 参数级深合并
|
||||||
|
cfg = merged_actions[atype]
|
||||||
|
merged_cfg = {}
|
||||||
|
for k in ("enabled", "params"):
|
||||||
|
merged_cfg[k] = cfg.get(k, dflt.get(k))
|
||||||
|
# params 再深合并一层
|
||||||
|
merged_params = dict(dflt.get("params", {}))
|
||||||
|
merged_params.update(cfg.get("params", {}))
|
||||||
|
merged_cfg["params"] = merged_params
|
||||||
|
merged_actions[atype] = merged_cfg
|
||||||
|
merged["actions"] = merged_actions
|
||||||
|
return DouyinNurtureWorker(stf, serial, params=merged)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>登录 · 设备自动化后台</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:-apple-system,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;
|
||||||
|
background:linear-gradient(135deg,#1e293b 0%,#334155 100%);min-height:100vh;
|
||||||
|
display:flex;justify-content:center;align-items:center}
|
||||||
|
.box{background:#fff;padding:40px;border-radius:16px;box-shadow:0 20px 60px rgba(0,0,0,.3);width:380px}
|
||||||
|
.logo{width:56px;height:56px;background:linear-gradient(135deg,#3b82f6,#2563eb);border-radius:14px;
|
||||||
|
margin:0 auto 20px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:28px;font-weight:700}
|
||||||
|
h2{text-align:center;margin:0 0 8px;color:#1e293b;font-size:22px;font-weight:700}
|
||||||
|
.sub{text-align:center;color:#64748b;font-size:13px;margin-bottom:28px}
|
||||||
|
input{width:100%;padding:12px 14px;margin-bottom:16px;border:1px solid #e2e8f0;border-radius:8px;
|
||||||
|
font-size:14px;transition:all .15s;background:#f8fafc}
|
||||||
|
input:focus{outline:none;border-color:#3b82f6;background:#fff;box-shadow:0 0 0 3px rgba(59,130,246,.1)}
|
||||||
|
button{width:100%;padding:12px;background:linear-gradient(135deg,#3b82f6,#2563eb);color:#fff;
|
||||||
|
border:none;border-radius:8px;cursor:pointer;font-size:14px;font-weight:600;transition:all .15s}
|
||||||
|
button:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(59,130,246,.3)}
|
||||||
|
.err{color:#ef4444;font-size:13px;margin-bottom:14px;padding:10px 14px;background:#fee2e2;
|
||||||
|
border-radius:8px;border-left:3px solid #ef4444}
|
||||||
|
.hint{color:#94a3b8;font-size:12px;margin-top:20px;text-align:center;padding-top:20px;border-top:1px solid #e2e8f0}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="box">
|
||||||
|
<div class="logo">A</div>
|
||||||
|
<h2>设备自动化后台</h2>
|
||||||
|
<div class="sub">Automation Management System</div>
|
||||||
|
{% if error %}<div class="err">{{ error }}</div>{% endif %}
|
||||||
|
<form method="post">
|
||||||
|
<input name="username" placeholder="用户名" autofocus>
|
||||||
|
<input name="password" type="password" placeholder="密码">
|
||||||
|
<button type="submit">登 录</button>
|
||||||
|
</form>
|
||||||
|
<div class="hint">默认账号 admin / admin123,登录后请及时修改密码</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN"><head><meta charset="UTF-8"><title>设备监控大屏</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box} body{font-family:-apple-system,"Segoe UI",Roboto,sans-serif;margin:0;background:#f0f2f5;color:#2c3e50}
|
||||||
|
.header{background:#fff;padding:14px 24px;box-shadow:0 1px 4px rgba(0,0,0,.08);display:flex;align-items:center;gap:16px}
|
||||||
|
.header h1{font-size:18px;margin:0} .header .ts{color:#95a5a6;font-size:12px;margin-left:auto}
|
||||||
|
.header a{color:#1890ff;text-decoration:none;font-size:13px}
|
||||||
|
.header a:hover{text-decoration:underline}
|
||||||
|
.content{padding:20px 24px;max-width:1400px;margin:0 auto}
|
||||||
|
.bar{display:flex;gap:8px;margin-bottom:16px;align-items:center;flex-wrap:wrap}
|
||||||
|
button{padding:7px 16px;border:1px solid #d9d9d9;border-radius:4px;cursor:pointer;font-size:13px;background:#fff;color:#595959}
|
||||||
|
button:hover{border-color:#40a9ff;color:#40a9ff} button.primary{background:#1890ff;color:#fff;border-color:#1890ff}
|
||||||
|
button.primary:hover{background:#40a9ff} button.danger{color:#ff4d4f;border-color:#ff4d4f}
|
||||||
|
button.danger:hover{background:#ff4d4f;color:#fff} button.sm{padding:3px 10px;font-size:12px}
|
||||||
|
table{width:100%;border-collapse:collapse;background:#fff;border-radius:6px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
||||||
|
th,td{padding:9px 10px;text-align:left;border-bottom:1px solid #f0f0f0;font-size:13px}
|
||||||
|
th{background:#fafafa;font-weight:600;color:#595959} tr:hover td{background:#fafafa}
|
||||||
|
.badge{padding:2px 8px;border-radius:10px;font-size:11px;color:#fff}
|
||||||
|
.b-running{background:#52c41a}.b-connecting{background:#faad14}.b-error{background:#ff4d4f}
|
||||||
|
.b-done{background:#1890ff}.b-idle{background:#bfbfbf;color:#595959}.b-released{background:#8c8c8c}.b-failed{background:#ff4d4f}
|
||||||
|
.ok{color:#52c41a}.no{color:#ff4d4f}.muted{color:#8c8c8c;font-size:12px}
|
||||||
|
.err{color:#ff4d4f;font-size:12px;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.progress{width:70px;height:5px;background:#f0f0f0;border-radius:3px;overflow:hidden;display:inline-block;vertical-align:middle}
|
||||||
|
.progress>div{height:100%;background:#1890ff}
|
||||||
|
.tag{display:inline-block;background:#e6f7ff;color:#1890ff;padding:1px 8px;border-radius:3px;font-size:11px;margin:1px}
|
||||||
|
.kv{display:inline-block;background:#f5f5f5;border-radius:3px;padding:0 6px;margin:1px;font-size:11px;color:#595959}
|
||||||
|
.stats{display:flex;gap:16px;margin-bottom:16px}
|
||||||
|
.stat-card{background:#fff;padding:14px 20px;border-radius:6px;box-shadow:0 1px 3px rgba(0,0,0,.08);min-width:120px}
|
||||||
|
.stat-card .num{font-size:24px;font-weight:600;color:#1890ff}
|
||||||
|
.stat-card .lbl{font-size:12px;color:#8c8c8c;margin-top:2px}
|
||||||
|
</style></head><body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>设备监控大屏</h1>
|
||||||
|
<span class="muted" id="devCount"></span>
|
||||||
|
<span class="ts" id="srvTime"></span>
|
||||||
|
<a href="/admin" target="_blank">管理后台 →</a>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="stats" id="stats"></div>
|
||||||
|
<div class="bar">
|
||||||
|
<button class="primary" onclick="doPost('/api/jobs/_quick/run')" title="快速启动:对全部空闲设备跑默认抖音养号">快速启动全部</button>
|
||||||
|
<button class="danger" onclick="doPost('/api/stop_all')">停止全部</button>
|
||||||
|
<button onclick="doPost('/api/release')">强制释放占用</button>
|
||||||
|
<button onclick="loadStatus()">刷新</button>
|
||||||
|
<span class="muted">每 5 秒自动刷新 · 分组/任务管理请到 <a href="/admin" target="_blank">管理后台</a></span>
|
||||||
|
</div>
|
||||||
|
<table><thead><tr>
|
||||||
|
<th>设备</th><th>型号</th><th>STF</th><th>任务状态</th><th>抖音</th><th>进度</th>
|
||||||
|
<th>任务名称</th><th>当前动作</th><th>操作计数</th><th>最近错误</th><th>操作</th>
|
||||||
|
</tr></thead><tbody id="tb-devices"></tbody></table>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const ACTION_LABELS={like:'点赞',comment:'评论',follow:'关注',share:'分享'};
|
||||||
|
function doPost(url,body){return fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):'{}'}).then(r=>r.json()).then(d=>{alert(JSON.stringify(d,null,1));loadStatus();}).catch(e=>alert(e));}
|
||||||
|
function fmtActionCounts(c){
|
||||||
|
if(!c||!Object.keys(c).length)return '<span class="muted">-</span>';
|
||||||
|
const parts=Object.entries(c).filter(([_,v])=>v>0).map(([k,v])=>'<span class="kv">'+(ACTION_LABELS[k]||k)+v+'</span>');
|
||||||
|
return parts.length?parts.join(''):'<span class="muted">-</span>';
|
||||||
|
}
|
||||||
|
async function loadStatus(){
|
||||||
|
try{
|
||||||
|
const r=await fetch('/api/status');const d=await r.json();if(!d.ok)return;
|
||||||
|
document.getElementById('devCount').textContent='设备 '+d.devices.length;
|
||||||
|
document.getElementById('srvTime').textContent=new Date(d.server_time*1000).toLocaleTimeString();
|
||||||
|
// 统计卡片
|
||||||
|
const running=d.devices.filter(x=>x.worker_status==='running').length;
|
||||||
|
const idle=d.devices.filter(x=>x.worker_status==='idle').length;
|
||||||
|
const error=d.devices.filter(x=>['error','failed'].includes(x.worker_status)).length;
|
||||||
|
const occupied=d.devices.filter(x=>x.stf_occupied).length;
|
||||||
|
document.getElementById('stats').innerHTML=
|
||||||
|
'<div class="stat-card"><div class="num" style="color:#52c41a">'+running+'</div><div class="lbl">运行中</div></div>'+
|
||||||
|
'<div class="stat-card"><div class="num" style="color:#1890ff">'+occupied+'</div><div class="lbl">STF占用</div></div>'+
|
||||||
|
'<div class="stat-card"><div class="num" style="color:#bfbfbf">'+idle+'</div><div class="lbl">空闲</div></div>'+
|
||||||
|
'<div class="stat-card"><div class="num" style="color:#ff4d4f">'+error+'</div><div class="lbl">异常</div></div>';
|
||||||
|
// 设备表格
|
||||||
|
document.getElementById('tb-devices').innerHTML=d.devices.map(dev=>{
|
||||||
|
const stf=dev.stf_occupied?'<span class="badge b-running">占用</span>':'<span class="badge b-idle">空闲</span>';
|
||||||
|
if(!dev.present)stf+=' <span class="no">离线</span>';
|
||||||
|
const ws=dev.worker_status;
|
||||||
|
const wb='<span class="badge b-'+ws+'">'+({idle:'未启动',connecting:'连接中',running:'运行中',done:'完成',error:'异常',released:'已释放',failed:'失败'}[ws]||ws)+'</span>';
|
||||||
|
const dy=dev.douyin_running?'<span class="ok">●</span>':'<span class="no">○</span>';
|
||||||
|
const pct=Math.round(dev.videos_watched/80*100);
|
||||||
|
const prog='<span class="progress"><div style="width:'+pct+'%"></div></span> '+dev.videos_watched;
|
||||||
|
const tname=dev.task_job?'<span class="tag">'+dev.task_job+'</span>':'<span class="muted">-</span>';
|
||||||
|
const retry=dev.attempt>0?' <span class="muted">'+dev.attempt+'次</span>':'';
|
||||||
|
const btn=dev.worker_status==='running'||dev.worker_status==='connecting'?'<button class="danger sm" onclick="doPost(\'/api/stop_device\',{serial:\''+dev.serial+'\'})">停止</button>':'';
|
||||||
|
return '<tr><td>'+dev.serial+'</td><td>'+dev.model+'</td><td>'+stf+'</td><td>'+wb+'</td><td>'+dy+'</td><td>'+prog+'</td><td>'+tname+retry+'</td><td class="muted">'+(dev.current_action||'-')+'</td><td>'+fmtActionCounts(dev.action_counts)+'</td><td class="err" title="'+(dev.last_error||'')+'">'+(dev.last_error||'-')+'</td><td>'+btn+'</td></tr>';
|
||||||
|
}).join('');
|
||||||
|
}catch(e){console.error(e);}
|
||||||
|
}
|
||||||
|
loadStatus();
|
||||||
|
setInterval(loadStatus,5000);
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
+426
@@ -0,0 +1,426 @@
|
|||||||
|
"""设备自动化管理后台(Flask + Flask-Login,单页应用)。
|
||||||
|
|
||||||
|
启动:python web_server.py
|
||||||
|
访问:http://localhost:5000
|
||||||
|
|
||||||
|
页面结构:
|
||||||
|
/ — 单页应用(监控/任务/分组/日志/用户 Tab 切换,需登录)
|
||||||
|
/login /logout — 用户登录/登出
|
||||||
|
/api/* — JSON API
|
||||||
|
|
||||||
|
用户系统:
|
||||||
|
首次启动自动创建默认管理员 admin/admin123(请及时改密码)。
|
||||||
|
用户数据存 data/users.db(SQLite)。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, request, redirect, url_for, render_template
|
||||||
|
from flask_login import (LoginManager, login_user, logout_user,
|
||||||
|
login_required, current_user)
|
||||||
|
|
||||||
|
from core.stf_client import STFClient
|
||||||
|
from core.task_manager import TaskManager
|
||||||
|
from core.logger import get_logger, _LOG_DIR, _MODULE_FILES
|
||||||
|
from core.models import db, init_db, User, DeviceGroup, TaskJob
|
||||||
|
from core.apk_manager import ApkManager
|
||||||
|
from core.adb_helper import identify_device
|
||||||
|
from tasks import list_task_types, get_task_class
|
||||||
|
|
||||||
|
_log = get_logger("web")
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config["SECRET_KEY"] = "dev-secret-key-change-in-production"
|
||||||
|
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)), "data", "users.db")
|
||||||
|
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||||
|
|
||||||
|
login_manager = LoginManager(app)
|
||||||
|
login_manager.login_view = "login"
|
||||||
|
|
||||||
|
# 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB)
|
||||||
|
init_db(app)
|
||||||
|
stf = STFClient()
|
||||||
|
mgr = TaskManager(stf, app=app)
|
||||||
|
apk_mgr = ApkManager(stf, app=app)
|
||||||
|
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id):
|
||||||
|
return User.query.get(int(user_id))
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 页面路由 ==================
|
||||||
|
@app.route("/")
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
"""单页应用首页。"""
|
||||||
|
return render_template("admin/monitor.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username", "")
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
user = User.query.filter_by(username=username).first()
|
||||||
|
if user and user.check_password(password):
|
||||||
|
login_user(user)
|
||||||
|
_log.info(f"用户 {username} 登录")
|
||||||
|
return redirect(request.args.get("next") or url_for("index"))
|
||||||
|
return render_template("admin/login.html", error="用户名或密码错误")
|
||||||
|
return render_template("admin/login.html", error=None)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/logout")
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
_log.info(f"用户 {current_user.username} 登出")
|
||||||
|
logout_user()
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
|
||||||
|
# ================== API:状态(监控大屏用)==================
|
||||||
|
@app.route("/api/status")
|
||||||
|
@login_required
|
||||||
|
def api_status():
|
||||||
|
devices, err = mgr.get_status()
|
||||||
|
if err:
|
||||||
|
return jsonify({"ok": False, "error": err}), 500
|
||||||
|
return jsonify({
|
||||||
|
"ok": True, "devices": devices, "server_time": time.time(),
|
||||||
|
"fg_scanning": mgr._fg_scanner.is_scanning,
|
||||||
|
"fg_last_scan": mgr._fg_scanner.last_scan_time,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/scan_foreground", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_scan_foreground():
|
||||||
|
"""手动触发前台 App 扫描(不打扰设备)。"""
|
||||||
|
started = mgr._fg_scanner.scan_once()
|
||||||
|
if started:
|
||||||
|
return jsonify({"ok": True, "msg": "扫描已启动"})
|
||||||
|
return jsonify({"ok": False, "error": "已有扫描在进行中"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/task_types")
|
||||||
|
@login_required
|
||||||
|
def api_task_types():
|
||||||
|
return jsonify({"ok": True, "task_types": list_task_types()})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/actions")
|
||||||
|
@login_required
|
||||||
|
def api_actions():
|
||||||
|
"""返回指定任务类型支持的专属操作。"""
|
||||||
|
task_type = request.args.get("task_type", "")
|
||||||
|
task_cls = get_task_class(task_type)
|
||||||
|
if not task_cls:
|
||||||
|
return jsonify({"ok": False, "error": "未知任务类型", "actions": []})
|
||||||
|
actions = task_cls.list_action_types()
|
||||||
|
return jsonify({"ok": True, "actions": actions})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/devices")
|
||||||
|
@login_required
|
||||||
|
def api_devices():
|
||||||
|
"""返回所有在线设备 serial(供分组表单勾选用)。"""
|
||||||
|
try:
|
||||||
|
return jsonify({"ok": True, "devices": mgr.list_all_serials()})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"ok": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ================== API:任务计划 CRUD ==================
|
||||||
|
@app.route("/api/jobs")
|
||||||
|
@login_required
|
||||||
|
def api_jobs_list():
|
||||||
|
return jsonify({"ok": True, "jobs": [j.to_dict() for j in mgr.jobs.values()],
|
||||||
|
"task_types": list_task_types()})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/jobs", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_jobs_create():
|
||||||
|
data = request.json or {}
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify({"ok": False, "error": "任务名不能为空"}), 400
|
||||||
|
task_type = data.get("task_type", "douyin_nurture")
|
||||||
|
if not get_task_class(task_type):
|
||||||
|
return jsonify({"ok": False, "error": f"未知任务类型: {task_type}"}), 400
|
||||||
|
job = mgr.add_job(
|
||||||
|
name=name, task_type=task_type,
|
||||||
|
target=data.get("target", {"mode": "all"}),
|
||||||
|
params=data.get("params", {}),
|
||||||
|
schedule=data.get("schedule", {"mode": "once"}),
|
||||||
|
retry=data.get("retry", {"max_attempts": 1, "delay": 60}),
|
||||||
|
enabled=data.get("enabled", True))
|
||||||
|
return jsonify({"ok": True, "msg": "任务已创建", "job": job.to_dict()})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/jobs/<job_id>", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
def api_jobs_update(job_id):
|
||||||
|
job = mgr.jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return jsonify({"ok": False, "error": "任务不存在"}), 404
|
||||||
|
data = request.json or {}
|
||||||
|
fields = {}
|
||||||
|
for k in ("name", "task_type", "target", "params", "schedule", "retry", "enabled"):
|
||||||
|
if k in data:
|
||||||
|
fields[k] = data[k]
|
||||||
|
if "task_type" in fields and not get_task_class(fields["task_type"]):
|
||||||
|
return jsonify({"ok": False, "error": f"未知任务类型: {fields['task_type']}"}), 400
|
||||||
|
mgr.update_job(job_id, **fields)
|
||||||
|
return jsonify({"ok": True, "msg": "任务已更新", "job": job.to_dict()})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/jobs/<job_id>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
def api_jobs_delete(job_id):
|
||||||
|
if mgr.delete_job(job_id):
|
||||||
|
return jsonify({"ok": True, "msg": "任务已删除"})
|
||||||
|
return jsonify({"ok": False, "error": "任务不存在"}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/jobs/<job_id>/run", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_jobs_run(job_id):
|
||||||
|
return jsonify(mgr.run_job_now(job_id))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/jobs/<job_id>/toggle", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_jobs_toggle(job_id):
|
||||||
|
enabled = (request.json or {}).get("enabled", True)
|
||||||
|
job = mgr.toggle_job(job_id, enabled)
|
||||||
|
if not job:
|
||||||
|
return jsonify({"ok": False, "error": "任务不存在"}), 404
|
||||||
|
return jsonify({"ok": True, "msg": f"任务已{'启用' if enabled else '停用'}"})
|
||||||
|
|
||||||
|
|
||||||
|
# ================== API:设备分组 CRUD ==================
|
||||||
|
@app.route("/api/groups")
|
||||||
|
@login_required
|
||||||
|
def api_groups_list():
|
||||||
|
return jsonify({"ok": True, "groups": [g.to_dict() for g in mgr.groups.values()]})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/groups", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_groups_create():
|
||||||
|
data = request.json or {}
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify({"ok": False, "error": "分组名不能为空"}), 400
|
||||||
|
if name in mgr.groups:
|
||||||
|
return jsonify({"ok": False, "error": "分组名已存在"}), 400
|
||||||
|
mgr.add_group(name, data.get("serials", []), data.get("description", ""))
|
||||||
|
return jsonify({"ok": True, "msg": "分组已创建"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/groups/<name>", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
def api_groups_update(name):
|
||||||
|
if name not in mgr.groups:
|
||||||
|
return jsonify({"ok": False, "error": "分组不存在"}), 404
|
||||||
|
data = request.json or {}
|
||||||
|
mgr.update_group(name,
|
||||||
|
serials=data.get("serials"),
|
||||||
|
description=data.get("description"))
|
||||||
|
return jsonify({"ok": True, "msg": "分组已更新"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/groups/<name>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
def api_groups_delete(name):
|
||||||
|
if mgr.delete_group(name):
|
||||||
|
return jsonify({"ok": True, "msg": "分组已删除"})
|
||||||
|
return jsonify({"ok": False, "error": "分组不存在"}), 404
|
||||||
|
|
||||||
|
|
||||||
|
# ================== API:用户管理 CRUD ==================
|
||||||
|
@app.route("/api/users")
|
||||||
|
@login_required
|
||||||
|
def api_users_list():
|
||||||
|
users = [{"id": u.id, "username": u.username, "is_admin": u.is_admin} for u in User.query.all()]
|
||||||
|
return jsonify({"ok": True, "users": users})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/users", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_users_create():
|
||||||
|
data = request.json or {}
|
||||||
|
username = (data.get("username") or "").strip()
|
||||||
|
password = data.get("password", "")
|
||||||
|
if not username or not password:
|
||||||
|
return jsonify({"ok": False, "error": "用户名和密码不能为空"}), 400
|
||||||
|
if User.query.filter_by(username=username).first():
|
||||||
|
return jsonify({"ok": False, "error": "用户名已存在"}), 400
|
||||||
|
u = User(username=username, is_admin=data.get("is_admin", True))
|
||||||
|
u.set_password(password)
|
||||||
|
db.session.add(u)
|
||||||
|
db.session.commit()
|
||||||
|
_log.info(f"创建用户 {username}")
|
||||||
|
return jsonify({"ok": True, "msg": "用户已创建"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/users/<int:uid>", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
def api_users_update(uid):
|
||||||
|
u = User.query.get(uid)
|
||||||
|
if not u:
|
||||||
|
return jsonify({"ok": False, "error": "用户不存在"}), 404
|
||||||
|
data = request.json or {}
|
||||||
|
if "password" in data and data["password"]:
|
||||||
|
u.set_password(data["password"])
|
||||||
|
if "is_admin" in data:
|
||||||
|
u.is_admin = bool(data["is_admin"])
|
||||||
|
db.session.commit()
|
||||||
|
_log.info(f"更新用户 {u.username}")
|
||||||
|
return jsonify({"ok": True, "msg": "用户已更新"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/users/<int:uid>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
def api_users_delete(uid):
|
||||||
|
u = User.query.get(uid)
|
||||||
|
if not u:
|
||||||
|
return jsonify({"ok": False, "error": "用户不存在"}), 404
|
||||||
|
if u.username == "admin":
|
||||||
|
return jsonify({"ok": False, "error": "不能删除默认管理员"}), 400
|
||||||
|
if u.id == current_user.id:
|
||||||
|
return jsonify({"ok": False, "error": "不能删除当前登录用户"}), 400
|
||||||
|
db.session.delete(u)
|
||||||
|
db.session.commit()
|
||||||
|
_log.info(f"删除用户 {u.username}")
|
||||||
|
return jsonify({"ok": True, "msg": "用户已删除"})
|
||||||
|
|
||||||
|
|
||||||
|
# ================== API:日志查看 ==================
|
||||||
|
@app.route("/api/logs")
|
||||||
|
@login_required
|
||||||
|
def api_logs():
|
||||||
|
files = list(_MODULE_FILES.values())
|
||||||
|
current = request.args.get("file", "core.log")
|
||||||
|
lines = int(request.args.get("lines", 300))
|
||||||
|
content = ""
|
||||||
|
path = os.path.join(_LOG_DIR, current)
|
||||||
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
content = "".join(f.readlines()[-lines:])
|
||||||
|
except Exception as e:
|
||||||
|
content = f"读取失败: {e}"
|
||||||
|
return jsonify({"ok": True, "content": content, "file": current, "files": files})
|
||||||
|
|
||||||
|
|
||||||
|
# ================== API:运行控制 ==================
|
||||||
|
@app.route("/api/stop_device", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_stop_device():
|
||||||
|
serial = (request.json or {}).get("serial", "")
|
||||||
|
if mgr.stop_device(serial):
|
||||||
|
return jsonify({"ok": True, "msg": f"已发送停止信号给 {serial}"})
|
||||||
|
return jsonify({"ok": False, "error": f"{serial} 没有运行中的任务"}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/stop_all", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_stop_all():
|
||||||
|
stopped = mgr.stop_all()
|
||||||
|
return jsonify({"ok": True, "stopped": stopped})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/release", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_release():
|
||||||
|
released = stf.release_all_mine()
|
||||||
|
return jsonify({"ok": True, "released": released})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/device/identify", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_device_identify():
|
||||||
|
"""让设备响铃以供识别(直连 serial,不经过 STF)。"""
|
||||||
|
serial = (request.json or {}).get("serial", "")
|
||||||
|
if not serial:
|
||||||
|
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||||||
|
ok, msg = identify_device(serial)
|
||||||
|
if ok:
|
||||||
|
return jsonify({"ok": True, "msg": msg})
|
||||||
|
return jsonify({"ok": False, "error": msg}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ================== API:应用管理(APK 上传/安装)==================
|
||||||
|
@app.route("/api/apks")
|
||||||
|
@login_required
|
||||||
|
def api_apks_list():
|
||||||
|
"""列出所有已上传的 APK。"""
|
||||||
|
return jsonify({"ok": True, "apks": apk_mgr.list_all()})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/apks/upload", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_apks_upload():
|
||||||
|
"""上传 APK 文件(multipart/form-data,字段名 file)。"""
|
||||||
|
file = request.files.get("file")
|
||||||
|
if not file or not file.filename:
|
||||||
|
return jsonify({"ok": False, "error": "未选择文件"}), 400
|
||||||
|
info = apk_mgr.upload(file)
|
||||||
|
if info:
|
||||||
|
return jsonify({"ok": True, "apk": info,
|
||||||
|
"msg": f"上传成功: {info['display_name']}"})
|
||||||
|
return jsonify({"ok": False, "error": "上传失败,请检查文件格式"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/apks/<apk_id>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
def api_apks_delete(apk_id):
|
||||||
|
"""删除 APK 文件和记录。"""
|
||||||
|
ok, msg = apk_mgr.delete(apk_id)
|
||||||
|
if ok:
|
||||||
|
return jsonify({"ok": True, "msg": msg})
|
||||||
|
return jsonify({"ok": False, "error": msg}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/apks/install", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_apks_install():
|
||||||
|
"""批量安装 APK 到指定设备。参数: {apk_id, serials:[]}"""
|
||||||
|
data = request.json or {}
|
||||||
|
apk_id = data.get("apk_id", "")
|
||||||
|
serials = data.get("serials", [])
|
||||||
|
ok, msg = apk_mgr.install(apk_id, serials)
|
||||||
|
if ok:
|
||||||
|
return jsonify({"ok": True, "msg": msg})
|
||||||
|
return jsonify({"ok": False, "error": msg}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/apks/install/status")
|
||||||
|
@login_required
|
||||||
|
def api_apks_install_status():
|
||||||
|
"""获取安装任务实时状态。"""
|
||||||
|
status = apk_mgr.get_install_status()
|
||||||
|
return jsonify({"ok": True, "status": status})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from config import WEB_HOST, WEB_PORT
|
||||||
|
_log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)")
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
app.run(host=WEB_HOST, port=WEB_PORT, debug=False, threaded=True)
|
||||||
|
except OSError as e:
|
||||||
|
# 0.0.0.0 在 Windows 非管理员下会触发 WinError 10013
|
||||||
|
# 降级到 127.0.0.1 保证本机至少能访问,局域网访问需用管理员启动
|
||||||
|
if WEB_HOST == "0.0.0.0":
|
||||||
|
_log.warning(f"绑定 {WEB_HOST} 失败({e}),降级到 127.0.0.1(仅本机可访问)")
|
||||||
|
_log.warning("如需局域网访问,请用管理员身份运行 start_web.bat")
|
||||||
|
app.run(host="127.0.0.1", port=WEB_PORT, debug=False, threaded=True)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
mgr.shutdown()
|
||||||
Reference in New Issue
Block a user