Files
auto_control/web_server.py
T

427 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""设备自动化管理后台(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()