chore: 初始化仓库(排除 adb 二进制文件以解决推送超时)
This commit is contained in:
+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