From 342b4c0065cb8eb4872393a59a76152034775ebe Mon Sep 17 00:00:00 2001 From: Liu Date: Fri, 1 May 2026 10:49:38 +0800 Subject: [PATCH 01/34] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=BC=B1=E5=AF=86=E7=A0=81=E6=8F=90=E7=A4=BA=E8=AF=AF=E5=88=A4?= =?UTF-8?q?=E5=B9=B6=E6=81=A2=E5=A4=8Dserver.py=E7=BC=96=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 修复数据库账号存在时仍回退 config.toml 判断,导致每次登录重复提示弱密码的问题。 2. 补齐默认管理员密码从旧配置迁移到数据库的同步逻辑,兼容历史部署。 3. 恢复 server.py 为可读 UTF-8 中文版本,并补充后台登录与弱密码判定的回归测试。 --- admin/dashboard/blueprints/auth.py | 134 ++++++------ admin/dashboard/server.py | 325 +++++++++++++++-------------- db/admin_account_db.py | 88 ++++---- test/test_dashboard_auth_logic.py | 171 +++++++++++++++ 4 files changed, 467 insertions(+), 251 deletions(-) create mode 100644 test/test_dashboard_auth_logic.py diff --git a/admin/dashboard/blueprints/auth.py b/admin/dashboard/blueprints/auth.py index 659aedd..2e2aba5 100644 --- a/admin/dashboard/blueprints/auth.py +++ b/admin/dashboard/blueprints/auth.py @@ -1,10 +1,11 @@ -from flask import Blueprint, render_template, request, redirect, url_for, session, current_app, jsonify -from functools import wraps -from loguru import logger import time +from functools import wraps -# 创建认证蓝图 -auth_bp = Blueprint('auth', __name__) +from flask import Blueprint, current_app, jsonify, redirect, render_template, request, session, url_for +from loguru import logger + +# 创建认证蓝图。 +auth_bp = Blueprint("auth", __name__) def _is_ajax_request() -> bool: @@ -14,36 +15,38 @@ def _is_ajax_request() -> bool: return requested_with == "xmlhttprequest" or "application/json" in accept_header -# 登录检查装饰器 def login_required(f): + """登录检查装饰器。""" + @wraps(f) def decorated_function(*args, **kwargs): - if not session.get('logged_in'): - return redirect(url_for('auth.login')) + if not session.get("logged_in"): + return redirect(url_for("auth.login")) + server = current_app.dashboard_server session_timeout_seconds = int(server.get_auth_policy().get("session_timeout_minutes", 480) * 60) last_activity_at = float(session.get("last_activity_at") or 0.0) if last_activity_at > 0 and (time.time() - last_activity_at) > session_timeout_seconds: # 会话超时后统一清理登录态,避免长期闲置会话继续可用。 session.clear() - return redirect(url_for('auth.login')) + return redirect(url_for("auth.login")) + session["last_activity_at"] = time.time() return f(*args, **kwargs) return decorated_function -# 登录页面 -@auth_bp.route('/login', methods=['GET', 'POST']) +@auth_bp.route("/login", methods=["GET", "POST"]) def login(): + """后台登录页及登录提交入口。""" error = None - if request.method == 'POST': - # 使用 strip 规避用户误输入首尾空格导致的误判。 - username = str(request.form.get('username', '') or '').strip() - password = str(request.form.get('password', '') or '') + if request.method == "POST": + # 用户名做 strip,避免首尾空格导致的误判;密码保留原样,避免改变真实口令。 + username = str(request.form.get("username", "") or "").strip() + password = str(request.form.get("password", "") or "") remote_ip = request.remote_addr or "" - # 从应用上下文获取服务器实例,而不是从蓝图对象 server = current_app.dashboard_server admin_db = getattr(server, "admin_account_db", None) lock_status = server.get_login_lock_status(username, remote_ip) @@ -52,82 +55,89 @@ def login(): error = f"登录失败次数过多,请在 {wait_seconds} 秒后再试" if _is_ajax_request(): return jsonify({"success": False, "error": error}), 429 - return render_template('login.html', error=error) + return render_template("login.html", error=error) - # 优先使用数据库账号体系鉴权;若不可用则回退旧配置模式,保证兼容存量部署。 + # 认证边界说明: + # 1. 只要数据库里已经存在该管理员账号,就必须以数据库密码为准; + # 2. 否则用户在后台改密后,config.toml 里的旧密码仍然可能继续生效; + # 3. 仅当数据库不可用,或者该账号尚未迁移进数据库时,才回退到旧配置模式。 login_ok = False + should_use_config_fallback = not bool(admin_db) if admin_db: try: - login_ok = admin_db.verify_admin_password(username, password) + admin_row = admin_db.get_admin_by_username(username) + should_use_config_fallback = not bool(admin_row) + if admin_row and int(admin_row.get("status") or 0) == 1: + login_ok = admin_db.verify_password(password, str(admin_row.get("password_hash") or "")) if login_ok: - admin_db.mark_login_success(username, request.remote_addr or "") + admin_db.mark_login_success(username, remote_ip) except Exception as e: logger.error(f"数据库账号登录校验异常,回退配置模式: {e}") login_ok = False + should_use_config_fallback = True - if not login_ok: - login_ok = (username == server.username and password == server.password) + if not login_ok and should_use_config_fallback: + login_ok = username == server.username and password == server.password if login_ok: server.clear_login_failures(username, remote_ip) - session['logged_in'] = True + session["logged_in"] = True session.permanent = True - session['username'] = username # 存储用户名到session - session['last_activity_at'] = time.time() - # 若账号仍在使用默认/弱口令,则登录后强提示修改密码。 - session['force_password_change'] = bool(server.should_force_password_change(username)) + session["username"] = username + session["last_activity_at"] = time.time() + # 登录成功后再读取当前账号的安全状态,决定是否强制弹出改密提示。 + session["force_password_change"] = bool(server.should_force_password_change(username)) logger.debug(f"Login successful. Session after login: {dict(session)}") if _is_ajax_request(): - return jsonify({"success": True, "redirect_url": url_for('main.index')}) - return redirect(url_for('main.index')) + return jsonify({"success": True, "redirect_url": url_for("main.index")}) + return redirect(url_for("main.index")) + + lock_status = server.mark_login_failure(username, remote_ip) + if lock_status.get("locked"): + wait_seconds = int(lock_status.get("remaining_seconds") or 0) + error = f"登录失败次数过多,请在 {wait_seconds} 秒后再试" else: - lock_status = server.mark_login_failure(username, remote_ip) - if lock_status.get("locked"): - wait_seconds = int(lock_status.get("remaining_seconds") or 0) - error = f"登录失败次数过多,请在 {wait_seconds} 秒后再试" - else: - remaining = max(0, int(server.get_auth_policy().get("max_failed_attempts", 5)) - int(lock_status.get("failed_count") or 0)) - error = '用户名或密码错误' - if remaining > 0: - error = f"{error},再失败 {remaining} 次将暂时锁定" - if _is_ajax_request(): - return jsonify({"success": False, "error": error}), 400 + remaining = max( + 0, + int(server.get_auth_policy().get("max_failed_attempts", 5)) - int(lock_status.get("failed_count") or 0), + ) + error = "用户名或密码错误" + if remaining > 0: + error = f"{error},再失败 {remaining} 次将暂时锁定" + if _is_ajax_request(): + return jsonify({"success": False, "error": error}), 400 - return render_template('login.html', error=error) + return render_template("login.html", error=error) -# 登出 -@auth_bp.route('/logout') +@auth_bp.route("/logout") def logout(): + """退出登录。""" session.clear() - return redirect(url_for('auth.login')) + return redirect(url_for("auth.login")) -@auth_bp.route('/api/auth/security_status', methods=['GET']) +@auth_bp.route("/api/auth/security_status", methods=["GET"]) @login_required def get_security_status(): """返回当前登录管理员的安全状态。""" - return jsonify({ - "success": True, - "data": { - "force_password_change": bool(session.get("force_password_change", False)), - "session_timeout_minutes": int(current_app.dashboard_server.get_auth_policy().get("session_timeout_minutes", 480)), + return jsonify( + { + "success": True, + "data": { + "force_password_change": bool(session.get("force_password_change", False)), + "session_timeout_minutes": int( + current_app.dashboard_server.get_auth_policy().get("session_timeout_minutes", 480) + ), + }, } - }) + ) -@auth_bp.route('/api/auth/change_password', methods=['POST']) +@auth_bp.route("/api/auth/change_password", methods=["POST"]) @login_required def change_password(): - """修改当前登录管理员密码。 - - 前端请求参数: - { - "old_password": "旧密码", - "new_password": "新密码", - "confirm_password": "确认新密码" - } - """ + """修改当前登录管理员密码。""" server = current_app.dashboard_server admin_db = getattr(server, "admin_account_db", None) if not admin_db: @@ -163,7 +173,7 @@ def change_password(): if not updated: return jsonify({"success": False, "error": "密码更新失败,请稍后重试"}), 500 - # 密码修改成功后,移除“必须修改密码”的会话提示。 + # 改密成功后立刻移除“必须修改密码”的会话提示,避免用户当前会话继续被反复打断。 session["force_password_change"] = False return jsonify({"success": True, "message": "密码修改成功"}) except Exception as e: diff --git a/admin/dashboard/server.py b/admin/dashboard/server.py index 40bf19f..ebe739e 100644 --- a/admin/dashboard/server.py +++ b/admin/dashboard/server.py @@ -1,43 +1,52 @@ # -*- coding: utf-8 -*- """ -统计看板服务器 - 使用Flask蓝图重构版 +统计看板服务器。 + +这里负责: +1. 读取 Dashboard 配置并创建 Flask 应用; +2. 初始化后台依赖对象、蓝图和静态资源路由; +3. 提供后台登录限流、弱密码提醒等通用安全能力。 """ + import os +import secrets import sys import threading import time -import secrets from datetime import timedelta import toml from flask import Flask, send_from_directory from loguru import logger -from db.contacts_db import ContactsDBOperator from db.admin_account_db import AdminAccountDBOperator +from db.contacts_db import ContactsDBOperator from db.emoji_asset_db import EmojiAssetDB +from db.fun_command_rule_db import FunCommandRuleDBOperator from db.member_context_db import MemberContextDBOperator from db.message_storage import MessageStorageDB from db.stats_db import StatsDBOperator from db.task_db import TaskDBOperator -from db.fun_command_rule_db import FunCommandRuleDBOperator from utils.fun_command_rule_service import FunCommandRuleService from wechat_ipad import WechatAPIClient -# 添加项目根目录到系统路径,确保可以导入项目模块 -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) +# 添加项目根目录到系统路径,确保可以导入项目模块。 +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) class DashboardServer: - """统计看板服务器""" + """统计看板服务器。""" - def __init__(self, host: str = None, port: int = None, - username: str = None, password: str = None, - robot_instance=None): - # 加载配置文件 + def __init__( + self, + host: str = None, + port: int = None, + username: str = None, + password: str = None, + robot_instance=None, + ): + # 先加载配置文件,再用传入参数覆盖,保证 CLI/外部调用具备最高优先级。 self.config = self._load_dashboard_config() - - # 优先使用传入的参数,其次使用配置文件中的参数 self.host = host or self.config.get("server", {}).get("host", "0.0.0.0") self.port = port or self.config.get("server", {}).get("port", 8888) self.username = username or self.config.get("auth", {}).get("username", "admin") @@ -45,105 +54,108 @@ class DashboardServer: self.LOG = logger self.LOG.info(f"Dashboard配置加载完成: 服务器将运行在 {self.host}:{self.port}") - # 登录失败限流兜底缓存: - # 1. 优先尝试 Redis,但为了兼容 Redis 暂不可用的场景,这里保留进程内兜底; - # 2. 字典内容只保存短期登录失败窗口,不用于持久化; - # 3. 线程化 WSGI 会并发访问,因此需要显式加锁。 + + # 登录失败限流的进程内兜底缓存: + # 1. 优先用 Redis 做共享限流; + # 2. 如果 Redis 暂时不可用,至少本进程内仍有基本防护; + # 3. 这里会被并发访问,因此需要配合线程锁使用。 self._auth_runtime_lock = threading.Lock() self._auth_failures = {} - # 如果提供了robot实例,则使用其对象 - if robot_instance: - self.db_manager = robot_instance.db_manager - self.stats_db = StatsDBOperator(self.db_manager) - # Dashboard 启动可能早于 iPad 登录完成: - # 1. 此时 Robot 上的 message_storage 还没来得及绑定真实 bot; - # 2. 但后台很多页面仍然依赖消息存储与表情资产库; - # 3. 因此这里优先复用 Robot 已初始化的 message_storage,没有则再安全回退到 DB 层对象。 - self.message_storage = getattr(robot_instance, "message_storage", None) or MessageStorageDB(self.db_manager) - self.emoji_asset_db = getattr(self.message_storage, "emoji_asset_db", None) or EmojiAssetDB(self.db_manager) - self.contact_db: ContactsDBOperator = ContactsDBOperator(self.db_manager) - self.member_context_db = MemberContextDBOperator(self.db_manager) - self.task_db: TaskDBOperator = TaskDBOperator(self.db_manager) - # 后台管理员账号数据层:用于登录鉴权与修改密码。 - self.admin_account_db = AdminAccountDBOperator(self.db_manager) - self.system_job_db = robot_instance.system_job_db - self.system_job_loader = robot_instance.system_job_loader - self.plugin_schedule_db = robot_instance.plugin_schedule_db - self.plugin_schedule_manager = robot_instance.plugin_schedule_manager - self.group_plugin_config_db = robot_instance.group_plugin_config_db - self.llm_catalog_db = robot_instance.llm_catalog_db - self.group_plugin_config_service = robot_instance.group_plugin_config_service - # 趣味指令规则服务:用于“文案/事件触发多媒体玩法回复”后台配置与缓存。 - # 这里统一在 Dashboard 启动时初始化,保证管理端可直接读写规则。 - self.fun_command_rule_db = FunCommandRuleDBOperator(self.db_manager) - self.fun_command_rule_service = FunCommandRuleService( - db_operator=self.fun_command_rule_db, - redis_client=self.db_manager.get_redis_connection(), - local_ttl_seconds=30, - ) - self.fun_command_rule_service.init_tables() - self.fun_command_rule_service.refresh_cache() - # 获取联系人管理器实例 - self.contact_manager = robot_instance.contact_manager - self.plugin_manager = robot_instance.plugin_manager - self.plugin_registry = robot_instance.plugin_registry - self.client: WechatAPIClient = robot_instance.ipad_bot - self.robot = robot_instance - self.member_context_plugin = self.plugin_manager.plugins.get("成员交互摘要") - self.member_context_service = getattr(self.member_context_plugin, "service", None) - self.LOG.info("使用Robot实例的对象进行初始化") - - # 初始化后台管理员账号表,并将旧配置中的默认账号平滑迁移进数据库。 - try: - table_ok = self.admin_account_db.init_tables() - if not table_ok: - self.LOG.warning("初始化后台账号表失败,将回退旧配置账号模式") - else: - seed_ok = self.admin_account_db.ensure_default_admin(self.username, self.password, "系统管理员") - if seed_ok: - self.LOG.info("后台账号体系初始化完成(数据库账号模式已可用)") - else: - self.LOG.warning("后台账号种子初始化失败,请检查配置中的默认账号信息") - except Exception as e: - self.LOG.error(f"初始化后台账号体系失败,将回退旧配置账号模式: {e}") - else: + if not robot_instance: self.LOG.error("未提供Robot实例,Dashboard无法正常工作") raise ValueError("必须提供Robot实例") + self.db_manager = robot_instance.db_manager + self.stats_db = StatsDBOperator(self.db_manager) + + # Dashboard 启动可能早于 iPad 登录完成: + # 1. 此时 Robot 上的 message_storage 可能尚未绑定完成; + # 2. 但后台页面仍然依赖消息存储与表情资产库; + # 3. 因此优先复用 Robot 现成对象,没有时再安全回退到 DB 层对象。 + self.message_storage = getattr(robot_instance, "message_storage", None) or MessageStorageDB(self.db_manager) + self.emoji_asset_db = getattr(self.message_storage, "emoji_asset_db", None) or EmojiAssetDB(self.db_manager) + self.contact_db: ContactsDBOperator = ContactsDBOperator(self.db_manager) + self.member_context_db = MemberContextDBOperator(self.db_manager) + self.task_db: TaskDBOperator = TaskDBOperator(self.db_manager) + + # 后台管理员账号数据层:用于登录鉴权、弱密码判断和在线改密。 + self.admin_account_db = AdminAccountDBOperator(self.db_manager) + self.system_job_db = robot_instance.system_job_db + self.system_job_loader = robot_instance.system_job_loader + self.plugin_schedule_db = robot_instance.plugin_schedule_db + self.plugin_schedule_manager = robot_instance.plugin_schedule_manager + self.group_plugin_config_db = robot_instance.group_plugin_config_db + self.llm_catalog_db = robot_instance.llm_catalog_db + self.group_plugin_config_service = robot_instance.group_plugin_config_service + + # 趣味指令规则服务:用于“文案/事件触发多媒体玩法回复”的后台配置与缓存。 + self.fun_command_rule_db = FunCommandRuleDBOperator(self.db_manager) + self.fun_command_rule_service = FunCommandRuleService( + db_operator=self.fun_command_rule_db, + redis_client=self.db_manager.get_redis_connection(), + local_ttl_seconds=30, + ) + self.fun_command_rule_service.init_tables() + self.fun_command_rule_service.refresh_cache() + + # 其余运行时对象直接复用 Robot 已初始化实例,避免重复构造。 + self.contact_manager = robot_instance.contact_manager + self.plugin_manager = robot_instance.plugin_manager + self.plugin_registry = robot_instance.plugin_registry + self.client: WechatAPIClient = robot_instance.ipad_bot + self.robot = robot_instance + self.member_context_plugin = self.plugin_manager.plugins.get("成员交互摘要") + self.member_context_service = getattr(self.member_context_plugin, "service", None) + + self.LOG.info("使用Robot实例的对象进行初始化") + + # 初始化后台管理员账号表,并将旧配置中的默认账号平滑迁移进数据库。 + try: + table_ok = self.admin_account_db.init_tables() + if not table_ok: + self.LOG.warning("初始化后台账号表失败,将回退旧配置账号模式") + else: + seed_ok = self.admin_account_db.ensure_default_admin(self.username, self.password, "系统管理员") + if seed_ok: + self.LOG.info("后台账号体系初始化完成(数据库账号模式已可用)") + else: + self.LOG.warning("后台账号种子初始化失败,请检查配置中的默认账号信息") + except Exception as e: + self.LOG.error(f"初始化后台账号体系失败,将回退旧配置账号模式: {e}") + self.app = self._create_app() self._stop_event = threading.Event() - self._server = None # 存储服务器实例 + self._server = None # Werkzeug 服务实例会在 run() 时写入这里。 def _load_dashboard_config(self): - """加载Dashboard配置文件""" + """加载 Dashboard 配置文件。""" try: - config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.toml') + config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.toml") if os.path.exists(config_path): - with open(config_path, 'r', encoding='utf-8') as f: + with open(config_path, "r", encoding="utf-8") as f: return toml.load(f) - else: - # 如果配置文件不存在,创建默认配置 - default_config = { - "server": {"host": "0.0.0.0", "port": 8888}, - "auth": {"username": "admin", "password": "admin123"} - } - with open(config_path, 'w', encoding='utf-8') as f: - toml.dump(default_config, f) - return default_config + + # 如果配置文件不存在,则创建默认配置,方便本地快速启动。 + default_config = { + "server": {"host": "0.0.0.0", "port": 8888}, + "auth": {"username": "admin", "password": "admin123"}, + } + with open(config_path, "w", encoding="utf-8") as f: + toml.dump(default_config, f) + return default_config except Exception as e: self.LOG.error(f"加载Dashboard配置文件失败: {e}") - # 返回默认配置 return { "server": {"host": "0.0.0.0", "port": 8888}, - "auth": {"username": "admin", "password": "admin123"} + "auth": {"username": "admin", "password": "admin123"}, } def _create_app(self) -> Flask: - """创建Flask应用""" - # 指定模板文件夹路径 - template_folder = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'templates') + """创建 Flask 应用。""" + template_folder = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") app = Flask(__name__, template_folder=template_folder) + auth_config = self.config.get("auth", {}) or {} session_timeout_minutes = int(auth_config.get("session_timeout_minutes", 480) or 480) cookie_secure = bool(auth_config.get("cookie_secure", False)) @@ -152,57 +164,54 @@ class DashboardServer: or auth_config.get("secret_key", "") or "" ).strip() + if configured_secret: app.secret_key = configured_secret else: - # 若未显式配置 secret_key,则每次进程启动生成随机值: - # 1. 这比固定硬编码密钥安全得多; - # 2. 代价是服务重启后旧 session 会失效,作为安全兜底是可接受的; - # 3. 同时输出 warning,提醒后续最好通过配置或环境变量固定注入。 + # 未显式配置 secret_key 时,用进程级随机密钥兜底: + # 1. 安全性优于硬编码固定值; + # 2. 服务重启后旧 session 会失效,但这是可接受的安全代价; + # 3. 同时输出 warning,提醒后续通过配置或环境变量固定注入。 app.secret_key = secrets.token_hex(32) self.LOG.warning("未配置 Dashboard secret_key,已使用进程级随机密钥,重启后现有登录会失效") - # 禁用模板缓存,使修改HTML文件后立即生效 False =重启才生效 - app.config['TEMPLATES_AUTO_RELOAD'] = True - app.config['SESSION_COOKIE_HTTPONLY'] = True - app.config['SESSION_COOKIE_SAMESITE'] = str(auth_config.get("cookie_samesite", "Lax") or "Lax") - app.config['SESSION_COOKIE_SECURE'] = cookie_secure - app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(minutes=max(15, session_timeout_minutes)) + # 关闭模板缓存,便于开发时实时看到页面修改结果。 + app.config["TEMPLATES_AUTO_RELOAD"] = True + app.config["SESSION_COOKIE_HTTPONLY"] = True + app.config["SESSION_COOKIE_SAMESITE"] = str(auth_config.get("cookie_samesite", "Lax") or "Lax") + app.config["SESSION_COOKIE_SECURE"] = cookie_secure + app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=max(15, session_timeout_minutes)) - # 设置Werkzeug日志级别为DEBUG import logging - logging.getLogger('werkzeug').setLevel(logging.ERROR) - # 将dashboard_server实例设置为app的属性 + logging.getLogger("werkzeug").setLevel(logging.ERROR) + + # 将 DashboardServer 实例挂到 app 上,方便蓝图在请求期取用。 app.dashboard_server = self - # 配置静态文件访问 - static_folder = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static') + static_folder = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") - @app.route('/static/') + @app.route("/static/") def serve_static(filename): return send_from_directory(static_folder, filename) - # 获取项目根目录下的static/images目录 project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) images_dir = os.path.join(project_root, "static", "images") - - # 确保目录存在 os.makedirs(images_dir, exist_ok=True) - @app.route('/static/images/') + @app.route("/static/images/") def serve_images(filename): return send_from_directory(images_dir, filename) - # 添加一个路由处理favicon请求 - @app.route('/favicon.ico') + @app.route("/favicon.ico") def favicon(): - return send_from_directory(os.path.join(app.root_path, 'static'), - 'favicon.ico', mimetype='image/vnd.microsoft.icon') + return send_from_directory( + os.path.join(app.root_path, "static"), + "favicon.ico", + mimetype="image/vnd.microsoft.icon", + ) - # 注册蓝图 self._register_blueprints(app) - return app def get_auth_policy(self) -> dict: @@ -239,12 +248,19 @@ class DashboardServer: if expire_at > 0 and expire_at <= time.time(): self._auth_failures.pop(guard_key, None) record = {} + return { "count": int(record.get("count") or 0), "first_failed_at": float(record.get("first_failed_at") or 0.0), } - def _save_login_failure_record(self, guard_key: str, count: int, first_failed_at: float, ttl_seconds: int) -> None: + def _save_login_failure_record( + self, + guard_key: str, + count: int, + first_failed_at: float, + ttl_seconds: int, + ) -> None: """保存登录失败记录,优先 Redis,失败时回退到进程内缓存。""" payload = f"{int(count)}|{float(first_failed_at)}" try: @@ -306,41 +322,48 @@ class DashboardServer: def should_force_password_change(self, username: str) -> bool: """判断当前管理员是否应该被强制提示修改密码。""" + normalized_username = str(username or "").strip() admin_db = getattr(self, "admin_account_db", None) - if admin_db and admin_db.is_using_risky_password(username): - return True + if admin_db: + try: + admin_row = admin_db.get_admin_by_username(normalized_username) + # 只要数据库里已经存在该账号,就完全以数据库中的当前密码状态为准。 + # 这样可以避免用户已经在后台把密码改强后,仍被 config.toml 中的旧默认密码反复误判。 + if admin_row: + return bool(admin_db.is_using_risky_password(normalized_username)) + except Exception as e: + self.LOG.error(f"判断后台弱密码状态时出现异常,将回退配置判断: {e}") - # 数据库体系不可用时,再回退配置值判断,至少把默认 admin/admin123 识别出来。 + # 数据库体系不可用或该账号尚未迁移进数据库时,再回退配置值判断。 fallback_username = str(self.username or "").strip() fallback_password = str(self.password or "").strip() return ( - str(username or "").strip() == fallback_username + normalized_username == fallback_username and fallback_password in getattr(admin_db, "RISKY_PASSWORDS", {"admin123", "admin"}) ) def _register_blueprints(self, app): - """注册所有蓝图""" - # 在函数内部导入蓝图,避免循环导入 + """注册所有蓝图。""" + # 在函数内部导入蓝图,避免循环导入。 from admin.dashboard.blueprints.auth import auth_bp from admin.dashboard.blueprints.contacts import contacts_bp - from admin.dashboard.blueprints.robot import robot_bp + from admin.dashboard.blueprints.file_browser import file_browser_bp + from admin.dashboard.blueprints.friend_circle import friend_circle_bp + from admin.dashboard.blueprints.fun_command_rules import fun_command_rules_bp + from admin.dashboard.blueprints.group_plugin_config import group_plugin_config_bp + from admin.dashboard.blueprints.main import main_bp + from admin.dashboard.blueprints.message_push import message_push_bp from admin.dashboard.blueprints.messages import messages_bp + from admin.dashboard.blueprints.plugin_routes import plugin_routes + from admin.dashboard.blueprints.plugin_schedules import plugin_schedules_bp + from admin.dashboard.blueprints.robot import robot_bp from admin.dashboard.blueprints.stats import stats_bp from admin.dashboard.blueprints.system import system_bp - from admin.dashboard.blueprints.main import main_bp - from admin.dashboard.blueprints.plugin_routes import plugin_routes - from admin.dashboard.blueprints.virtual_group import virtual_group_bp - from admin.dashboard.blueprints.file_browser import file_browser_bp - from admin.dashboard.blueprints.message_push import message_push_bp - from admin.dashboard.blueprints.friend_circle import friend_circle_bp from admin.dashboard.blueprints.system_jobs import system_jobs_bp - from admin.dashboard.blueprints.plugin_schedules import plugin_schedules_bp - from admin.dashboard.blueprints.group_plugin_config import group_plugin_config_bp - from admin.dashboard.blueprints.fun_command_rules import fun_command_rules_bp from admin.dashboard.blueprints.trendradar_webhook import trendradar_webhook_bp + from admin.dashboard.blueprints.virtual_group import virtual_group_bp - # 在app.register_blueprint部分添加 - app.register_blueprint(virtual_group_bp, url_prefix='/virtual_group') + app.register_blueprint(virtual_group_bp, url_prefix="/virtual_group") app.register_blueprint(auth_bp) app.register_blueprint(main_bp) app.register_blueprint(contacts_bp) @@ -361,16 +384,16 @@ class DashboardServer: self.LOG.info("所有蓝图已注册") def run(self): - """运行服务器""" + """运行服务器。""" from werkzeug.serving import make_server - # 设置Werkzeug日志级别为DEBUG import logging - logging.getLogger('werkzeug').setLevel(logging.ERROR) + + logging.getLogger("werkzeug").setLevel(logging.ERROR) self.LOG.info(f"启动服务器: {self.host}:{self.port}") try: - # Dashboard 存在文件浏览、统计查询等慢请求,单线程 WSGI 一旦被占住会导致整个后台无响应。 - # 改为 threaded server,避免某个接口阻塞后拖死所有页面访问。 + # Dashboard 存在文件浏览、统计查询等慢请求,单线程 WSGI 一旦被占住会拖死整个后台。 + # 改为 threaded server 可以避免某个接口阻塞时所有页面一起无响应。 self._server = make_server(self.host, self.port, self.app, threaded=True) self._server.serve_forever() except Exception as e: @@ -378,26 +401,22 @@ class DashboardServer: self._stop_event.set() def stop(self): - """停止服务器""" + """停止服务器。""" self.LOG.info("正在停止服务器...") self._stop_event.set() - # 使用werkzeug服务器的关闭方法 if self._server: self._server.shutdown() self.LOG.info("服务器已停止") def get_current_user_info(self): - """获取当前登录的微信用户信息""" + """获取当前登录的微信用户信息。""" try: if not self.client: self.LOG.error("client实例不可用,无法获取当前用户信息") return {"success": False, "message": "实例不可用"} - # 获取当前登录的微信ID - - # 从新的resp格式中获取用户信息 try: if self.robot is None: raise ValueError("机器人对象未初始化") @@ -406,7 +425,7 @@ class DashboardServer: "nickName": getattr(self.robot, "nickname", ""), "mobile": getattr(self.robot, "phone", ""), "smallHeadImgUrl": getattr(self.robot, "head_image", ""), - "signature": getattr(self.robot, "signature", "") + "signature": getattr(self.robot, "signature", ""), } except (AttributeError, ValueError) as e: print(f"获取用户信息出错: {str(e)}") @@ -415,7 +434,7 @@ class DashboardServer: "nickName": self.robot.nickname, "mobile": self.robot.phone, "smallHeadImgUrl": self.robot.head_image, - "signature": self.robot.signature + "signature": self.robot.signature, } if not user_data: @@ -426,11 +445,11 @@ class DashboardServer: "data": { "wx_id": user_data.get("wxid", ""), "nickname": user_data.get("nickName", "未知用户"), - "avatar": user_data.get("smallHeadImgUrl", "logo.png"), # 使用小头像URL + "avatar": user_data.get("smallHeadImgUrl", "logo.png"), # 使用小头像 URL。 "mobile": user_data.get("mobile", ""), - "home": f"{user_data.get('province', '')}-{user_data.get('city', '')}", # 组合省市信息 - "signature": user_data.get("signature", "")[:10] - } + "home": f"{user_data.get('province', '')}-{user_data.get('city', '')}", # 组合省市信息。 + "signature": user_data.get("signature", "")[:10], + }, } except Exception as e: self.LOG.error(f"获取当前用户信息失败: {e}") diff --git a/db/admin_account_db.py b/db/admin_account_db.py index 86b6a17..5fdab6d 100644 --- a/db/admin_account_db.py +++ b/db/admin_account_db.py @@ -3,9 +3,10 @@ 后台管理员账号数据访问层。 设计目标: -1. 用数据库表承载后台账号,替代“固定配置文件账号密码”; -2. 提供安全的密码散列存储与校验能力; -3. 支持登录成功后的登录信息回写与在线修改密码。 +1. 用数据库表承载后台账号,逐步替代固定配置文件中的账号密码; +2. 提供安全的密码哈希存储与校验能力; +3. 支持登录成功后的登录信息回写,以及在线修改密码; +4. 兼容历史部署,把旧配置中的管理员密码平滑迁移到数据库体系。 """ import base64 @@ -21,14 +22,14 @@ from db.base import BaseDBOperator class AdminAccountDBOperator(BaseDBOperator): """后台管理员账号数据访问对象。""" - # 口令哈希算法版本前缀,便于将来平滑升级算法。 + # 口令哈希算法前缀,便于后续平滑升级算法。 HASH_SCHEME = "pbkdf2_sha256" - # PBKDF2 迭代次数:在安全性与计算开销之间做平衡。 + # PBKDF2 迭代次数,在安全性和计算开销之间做平衡。 HASH_ITERATIONS = 150_000 - # 风险口令清单: - # 1. 这里优先覆盖系统默认口令和常见极弱口令; - # 2. 后台安全判断只需要识别“明显危险”的情况,不追求做成完整密码字典; - # 3. 统一放在数据层,便于登录鉴权、修改密码、首登提醒共用。 + # 已知高风险密码列表: + # 1. 这里只覆盖默认密码和常见弱密码,不做完整字典; + # 2. 后台安全提醒只需要识别“明显高风险”的情况即可; + # 3. 统一收敛在数据层,方便登录、改密、首次提醒共用。 RISKY_PASSWORDS = { "admin", "admin123", @@ -40,10 +41,7 @@ class AdminAccountDBOperator(BaseDBOperator): } def init_tables(self) -> bool: - """初始化后台管理员表。 - - 表名使用 t_admin_ 前缀,满足后台账号体系命名约定。 - """ + """初始化后台管理员账号表。""" sql = """ CREATE TABLE IF NOT EXISTS t_admin_accounts ( id BIGINT AUTO_INCREMENT PRIMARY KEY, @@ -74,12 +72,17 @@ class AdminAccountDBOperator(BaseDBOperator): ) def ensure_default_admin(self, username: str, password: str, display_name: str = "系统管理员") -> bool: - """确保默认管理员存在。 + """确保默认管理员存在,并在安全前提下补齐旧配置密码迁移。 - 行为约束: - 1. 若用户名已存在,不覆盖既有密码; - 2. 仅在“表里不存在该账号”时创建初始账号; - 3. 方便从旧配置平滑迁移到数据库账号体系。 + 这里的迁移策略分三层: + 1. 数据库里还没有管理员账号时,按当前配置创建初始账号; + 2. 数据库里已经有账号,且密码本来就和当前配置一致时,直接视为已同步; + 3. 数据库里仍是历史默认弱密码,但配置里已经换成了更强密码时, + 自动把强密码同步进数据库,避免登录和弱密码提示长期错位。 + + 注意: + 只有在“数据库当前密码仍然是已知弱密码,而传入配置密码不是弱密码”时, + 才允许用配置回写数据库;如果用户已经在后台主动改过密码,则继续以数据库为准。 """ normalized_username = str(username or "").strip() normalized_password = str(password or "").strip() @@ -87,17 +90,25 @@ class AdminAccountDBOperator(BaseDBOperator): return False existing = self.get_admin_by_username(normalized_username) - if existing: + if not existing: + password_hash = self.hash_password(normalized_password) + return self.execute_update( + """ + INSERT INTO t_admin_accounts (username, password_hash, display_name, status) + VALUES (%s, %s, %s, 1) + """, + (normalized_username, password_hash, str(display_name or "").strip() or normalized_username), + ) + + stored_hash = str(existing.get("password_hash") or "") + if stored_hash and self.verify_password(normalized_password, stored_hash): return True - password_hash = self.hash_password(normalized_password) - return self.execute_update( - """ - INSERT INTO t_admin_accounts (username, password_hash, display_name, status) - VALUES (%s, %s, %s, 1) - """, - (normalized_username, password_hash, str(display_name or "").strip() or normalized_username), - ) + current_password_is_risky = self.is_password_hash_using_risky_password(stored_hash) + incoming_password_is_risky = normalized_password.lower() in self.RISKY_PASSWORDS + if current_password_is_risky and not incoming_password_is_risky: + return self.update_password(normalized_username, normalized_password) + return True def verify_admin_password(self, username: str, password: str) -> bool: """校验账号口令是否正确。""" @@ -135,17 +146,22 @@ class AdminAccountDBOperator(BaseDBOperator): ) def is_using_risky_password(self, username: str) -> bool: - """判断指定管理员是否仍在使用风险口令。""" + """判断指定管理员是否仍在使用已知弱密码。""" row = self.get_admin_by_username(username) if not row: return False stored_hash = str(row.get("password_hash") or "") + return self.is_password_hash_using_risky_password(stored_hash) + + @classmethod + def is_password_hash_using_risky_password(cls, stored_hash: str) -> bool: + """判断一个已存储密码哈希是否仍然对应已知弱密码。""" if not stored_hash: return False - # 口令是哈希存储的,因此只能把风险候选集逐个比对。 - for candidate in self.RISKY_PASSWORDS: - if self.verify_password(candidate, stored_hash): + # 哈希无法反解,这里只能把已知高风险候选值逐个比对。 + for candidate in cls.RISKY_PASSWORDS: + if cls.verify_password(candidate, stored_hash): return True return False @@ -154,10 +170,10 @@ class AdminAccountDBOperator(BaseDBOperator): """校验密码强度,返回错误提示;通过时返回 None。""" password_text = str(raw_password or "") if len(password_text) < 8: - return "新密码长度不能少于8位" + return "新密码长度不能少于 8 位" if password_text.lower() in cls.RISKY_PASSWORDS: - return "新密码过于简单,请避免使用默认口令或常见弱口令" + return "新密码过于简单,请避免使用默认口令或常见弱密码" score = 0 if re.search(r"[A-Za-z]", password_text): @@ -167,7 +183,7 @@ class AdminAccountDBOperator(BaseDBOperator): if re.search(r"[^A-Za-z0-9]", password_text): score += 1 - # 至少满足两类字符,既兼顾安全性,也避免把规则设得过于苛刻。 + # 至少满足两类字符,既兼顾安全性,也避免把规则设置得过于苛刻。 if score < 2: return "新密码需至少包含字母、数字、符号中的两类" return None @@ -177,7 +193,7 @@ class AdminAccountDBOperator(BaseDBOperator): """生成口令哈希。 存储格式: - pbkdf2_sha256$迭代次数$盐(HEX)$哈希(base64) + pbkdf2_sha256$迭代次数$盐值HEX$摘要base64 """ password_text = str(raw_password or "") salt_bytes = secrets.token_bytes(16) @@ -197,7 +213,7 @@ class AdminAccountDBOperator(BaseDBOperator): 安全细节: 1. 使用 hmac.compare_digest,避免时序侧信道问题; - 2. 对格式异常统一返回 False,避免抛错打断登录流程。 + 2. 对格式异常统一返回 False,避免异常中断登录流程。 """ try: scheme, iterations_text, salt_hex, digest_b64 = str(stored_hash or "").split("$", 3) diff --git a/test/test_dashboard_auth_logic.py b/test/test_dashboard_auth_logic.py new file mode 100644 index 0000000..b83acb1 --- /dev/null +++ b/test/test_dashboard_auth_logic.py @@ -0,0 +1,171 @@ +import unittest + +from flask import Blueprint, Flask + +from admin.dashboard.blueprints.auth import auth_bp +from admin.dashboard.server import DashboardServer +from db.admin_account_db import AdminAccountDBOperator + + +class DummyAdminDB: + """用于回归测试后台登录与弱密码判定的最小桩对象。""" + + RISKY_PASSWORDS = AdminAccountDBOperator.RISKY_PASSWORDS + + def __init__(self, row_exists: bool, db_password: str, risky: bool): + self.row_exists = row_exists + self.db_password = db_password + self.risky = risky + self.login_success_marked = False + + def get_admin_by_username(self, username: str): + if not self.row_exists: + return None + return { + "username": username, + "status": 1, + "password_hash": self.db_password, + } + + def verify_password(self, raw_password: str, stored_hash: str) -> bool: + # 这里不引入真实哈希算法,直接把“数据库里当前有效的密码”抽象成 db_password, + # 只验证登录流程是否仍然错误地回退到了 config.toml。 + return raw_password == self.db_password + + def is_using_risky_password(self, username: str) -> bool: + return self.risky + + def mark_login_success(self, username: str, login_ip: str = "") -> bool: + self.login_success_marked = True + return True + + +class DummyServer: + """为 auth 蓝图提供最小运行依赖,避免把整套 Dashboard 初始化起来。""" + + def __init__(self, username: str, password: str, admin_db: DummyAdminDB): + self.username = username + self.password = password + self.admin_account_db = admin_db + + def get_login_lock_status(self, username: str, remote_ip: str) -> dict: + return {"locked": False, "remaining_seconds": 0, "failed_count": 0} + + def get_auth_policy(self) -> dict: + return {"max_failed_attempts": 5, "lock_seconds": 900, "session_timeout_minutes": 480} + + def clear_login_failures(self, username: str, remote_ip: str) -> None: + return None + + def mark_login_failure(self, username: str, remote_ip: str) -> dict: + return {"locked": False, "remaining_seconds": 0, "failed_count": 1} + + def should_force_password_change(self, username: str) -> bool: + temp_server = DashboardServer.__new__(DashboardServer) + temp_server.username = self.username + temp_server.password = self.password + temp_server.admin_account_db = self.admin_account_db + temp_server.LOG = None + return DashboardServer.should_force_password_change(temp_server, username) + + +class FakeAdminAccountDBOperator(AdminAccountDBOperator): + """只覆盖 ensure_default_admin 测试所需的方法,避免真实数据库依赖。""" + + def __init__(self, existing_row=None): + super().__init__(db_manager=None) + self.existing_row = existing_row + self.updated_password = None + self.inserted = False + + def get_admin_by_username(self, username: str): + return self.existing_row + + @classmethod + def verify_password(cls, raw_password: str, stored_hash: str) -> bool: + # 测试里直接把 password_hash 当成明文占位值,重点验证迁移分支是否被正确触发。 + return raw_password == stored_hash + + def update_password(self, username: str, new_password: str) -> bool: + self.updated_password = (username, new_password) + return True + + def execute_update(self, sql: str, params=None) -> bool: + self.inserted = True + return True + + +class DashboardAuthLogicTestCase(unittest.TestCase): + def create_app(self, server: DummyServer) -> Flask: + app = Flask(__name__) + app.secret_key = "test-secret" + app.dashboard_server = server + + main_bp = Blueprint("main", __name__) + + @main_bp.route("/") + def index(): + return "ok" + + app.register_blueprint(auth_bp) + app.register_blueprint(main_bp) + return app + + def test_should_force_password_change_ignores_legacy_config_when_db_password_is_strong(self): + server = DummyServer( + username="admin", + password="admin123", + admin_db=DummyAdminDB(row_exists=True, db_password="StrongPass!2026", risky=False), + ) + + self.assertFalse(server.should_force_password_change("admin")) + + def test_login_rejects_legacy_config_password_after_db_password_changed(self): + server = DummyServer( + username="admin", + password="admin123", + admin_db=DummyAdminDB(row_exists=True, db_password="StrongPass!2026", risky=False), + ) + app = self.create_app(server) + + with app.test_client() as client: + response = client.post( + "/login", + data={"username": "admin", "password": "admin123"}, + headers={"X-Requested-With": "XMLHttpRequest"}, + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse(server.admin_account_db.login_success_marked) + + def test_login_keeps_legacy_config_fallback_when_db_account_missing(self): + server = DummyServer( + username="admin", + password="admin123", + admin_db=DummyAdminDB(row_exists=False, db_password="", risky=False), + ) + app = self.create_app(server) + + with app.test_client() as client: + response = client.post( + "/login", + data={"username": "admin", "password": "admin123"}, + headers={"X-Requested-With": "XMLHttpRequest"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertTrue(response.get_json()["success"]) + + def test_ensure_default_admin_syncs_strong_config_password_over_risky_seed(self): + operator = FakeAdminAccountDBOperator( + existing_row={"username": "admin", "password_hash": "admin123", "status": 1} + ) + + result = operator.ensure_default_admin("admin", "StrongPass!2026") + + self.assertTrue(result) + self.assertEqual(operator.updated_password, ("admin", "StrongPass!2026")) + + +if __name__ == "__main__": + unittest.main() From 1b6da6db1f10d049998bac5595d1ab707da03157 Mon Sep 17 00:00:00 2001 From: Liu Date: Fri, 1 May 2026 11:08:49 +0800 Subject: [PATCH 02/34] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E8=B6=85=E6=97=B6=E6=8B=96=E6=85=A2=E4=B8=BB?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 为菜单图片发送增加独立的同步等待预算,避免单次菜单命令长时间占用消息处理协程。 2. 调整菜单插件外层处理超时与文本回退空间,避免内外层超时重合导致降级逻辑来不及执行。 3. 修复 md2img 专用运行时在超时/取消时未显式取消后台任务的问题,减少渲染残留任务堆积。 --- admin/dashboard/blueprints/plugin_routes.py | 1 + plugins/robot_menu/config.toml | 6 +++++- plugins/robot_menu/main.py | 13 ++++++++++++ plugins/robot_menu/menu_render_tool.py | 19 ++++++++++++++--- utils/markdown_to_image.py | 23 ++++++++++++++------- 5 files changed, 51 insertions(+), 11 deletions(-) diff --git a/admin/dashboard/blueprints/plugin_routes.py b/admin/dashboard/blueprints/plugin_routes.py index 40d3dde..a270c16 100644 --- a/admin/dashboard/blueprints/plugin_routes.py +++ b/admin/dashboard/blueprints/plugin_routes.py @@ -20,6 +20,7 @@ _command_catalog_tool = RobotMenuRenderTool( image_fallback_to_text=True, image_render_timeout_seconds=30, image_render_retries=1, + sync_send_timeout_seconds=10, image_template_path="plugins/robot_menu/templates/menu_cards.html", log=LOG, ) diff --git a/plugins/robot_menu/config.toml b/plugins/robot_menu/config.toml index 0e08ac3..8301164 100644 --- a/plugins/robot_menu/config.toml +++ b/plugins/robot_menu/config.toml @@ -8,7 +8,11 @@ output_mode = "image" # 图片生成失败时是否回退文本菜单: # - false:严格按图片模式,不发送完整菜单文本 # - true:优先保证可达,失败后改发文本 -image_fallback_to_text = false +image_fallback_to_text = true +# 菜单命令是即时交互,不允许长时间占住主消息链路: +# - 这里控制“同步等待图片发送完成”的最长时长; +# - 超过后会尽快回退文本或失败提示,避免把整个插件处理流程拖慢。 +sync_send_timeout_seconds = 18 # md2image 渲染参数:可按服务器性能调整 image_render_timeout_seconds = 45 image_render_retries = 1 diff --git a/plugins/robot_menu/main.py b/plugins/robot_menu/main.py index ee9a880..66b4cfe 100644 --- a/plugins/robot_menu/main.py +++ b/plugins/robot_menu/main.py @@ -87,6 +87,18 @@ class RobotMenuPlugin(MessagePluginInterface): self.image_render_retries = int( self._config.get("RobotMenu", {}).get("image_render_retries", 1) ) + # 菜单命令属于强交互型消息: + # 1. 用户输入“菜单”后,不能允许单次渲染长期霸占消息处理协程; + # 2. 因此这里单独定义“主链路同步等待预算”,超出后立即由渲染工具降级; + # 3. 该预算默认比底层图片渲染超时短很多,优先保障机器人整体吞吐稳定。 + self.sync_send_timeout_seconds = int( + self._config.get("RobotMenu", {}).get("sync_send_timeout_seconds", 18) + ) + # 对外层插件保护显式声明一个更合适的总超时: + # 1. 内层菜单发送会在 sync_send_timeout_seconds 内决定“成功发图 / 回退文本 / 返回失败提示”; + # 2. 外层 wait_for 必须比内层稍长,给降级发送文本留出缓冲; + # 3. 这样可以避免过去“内外层都卡在 55 秒”时,外层先打断,导致降级逻辑来不及执行。 + self.plugin_process_timeout_seconds = max(12, self.sync_send_timeout_seconds + 8) # 菜单图片模板文件路径(相对仓库根目录): # 调整样式和布局时只改模板,不改 Python 逻辑。 self.image_template_path = str( @@ -101,6 +113,7 @@ class RobotMenuPlugin(MessagePluginInterface): image_fallback_to_text=self.image_fallback_to_text, image_render_timeout_seconds=self.image_render_timeout_seconds, image_render_retries=self.image_render_retries, + sync_send_timeout_seconds=self.sync_send_timeout_seconds, image_template_path=self.image_template_path, log=self.LOG, ) diff --git a/plugins/robot_menu/menu_render_tool.py b/plugins/robot_menu/menu_render_tool.py index b6286c6..2e8bfab 100644 --- a/plugins/robot_menu/menu_render_tool.py +++ b/plugins/robot_menu/menu_render_tool.py @@ -30,6 +30,7 @@ class RobotMenuRenderTool: image_fallback_to_text: bool, image_render_timeout_seconds: int, image_render_retries: int, + sync_send_timeout_seconds: int, image_template_path: str, log=default_logger, ): @@ -40,6 +41,11 @@ class RobotMenuRenderTool: # 渲染超时与重试参数,统一集中在工具层处理。 self.image_render_timeout_seconds = int(image_render_timeout_seconds) self.image_render_retries = int(image_render_retries) + # 同步发送超时预算: + # 1. “菜单”属于即时交互命令,不能像离线任务一样长时间占住消息处理协程; + # 2. 因此这里单独维护一个“主链路最多等多久”的预算,超时后立即进入降级逻辑; + # 3. 图片渲染器即便本身还能继续尝试,也不允许把主消息链路拖成几十秒假死。 + self.sync_send_timeout_seconds = max(8, int(sync_send_timeout_seconds or 18)) # 注入日志对象,便于主插件统一控制日志风格与输出目标。 self.log = log or default_logger # 菜单图片模板路径(相对仓库根目录),支持仅改模板文件完成 UI 更新。 @@ -487,7 +493,14 @@ class RobotMenuRenderTool: md_content = (markdown_content or "").strip() or f"```text\n{text_content}\n```" output_image = f"robot_menu_{int(time.time() * 1000)}.png" try: - total_timeout = max(30, self.image_render_timeout_seconds * max(1, self.image_render_retries) + 10) + # 这里故意不再使用“渲染超时 * 重试次数 + 缓冲”的长预算: + # 1. 菜单是即时命令,用户更在意“尽快拿到结果或降级结果”,而不是死等图片; + # 2. 若沿用 45~55 秒预算,多个菜单命令会持续占住机器人并发槽位,放大成“整条插件链路卡住”; + # 3. 因此统一按 sync_send_timeout_seconds 控制主链路等待时间,超时后快速回退。 + total_timeout = max(8, int(self.sync_send_timeout_seconds or 18)) + # Markdown 转图内部也要用更短预算,避免内外层超时完全重合,导致降级逻辑来不及执行。 + html_budget_seconds = max(5, min(10, total_timeout - 4)) + render_budget_seconds = max(6, total_timeout - 2) output_dir = Path(os.getcwd()) / "temp" / "md2image" output_dir.mkdir(parents=True, exist_ok=True) output_path = output_dir / output_image @@ -503,8 +516,8 @@ class RobotMenuRenderTool: md_content, output_image, max_retries=max(1, self.image_render_retries), - render_timeout_seconds=max(10, self.image_render_timeout_seconds), - html_timeout_seconds=min(30, max(10, self.image_render_timeout_seconds)), + render_timeout_seconds=render_budget_seconds, + html_timeout_seconds=html_budget_seconds, ), timeout=total_timeout, ) diff --git a/utils/markdown_to_image.py b/utils/markdown_to_image.py index 9185109..b10c16c 100644 --- a/utils/markdown_to_image.py +++ b/utils/markdown_to_image.py @@ -864,13 +864,22 @@ async def _run_in_md2img_runtime(coro, timeout_seconds: Optional[int] = None): future = runtime.submit(coro) awaitable_future = asyncio.wrap_future(future) - if timeout_seconds is not None: - return await asyncio.wait_for(awaitable_future, timeout=max(1, int(timeout_seconds))) - # 关键修复: - # 之前这里直接 return Future 对象,调用方 await 后只拿到 Future 本身, - # 导致业务层误以为截图已完成,实际截图仍在后台执行,出现“先判失败后截图”的时序错乱。 - # 这里必须等待 Future 完成并返回真实结果,保证调用链严格串行。 - return await awaitable_future + try: + if timeout_seconds is not None: + return await asyncio.wait_for(awaitable_future, timeout=max(1, int(timeout_seconds))) + # 关键修复: + # 之前这里直接 return Future 对象,调用方 await 后只拿到 Future 本身, + # 导致业务层误以为截图已完成,实际截图仍在后台执行,出现“先判失败后截图”的时序错乱。 + # 这里必须等待 Future 完成并返回真实结果,保证调用链严格串行。 + return await awaitable_future + except (asyncio.TimeoutError, asyncio.CancelledError): + # 这里要把“当前调用方已放弃等待”的状态显式同步给 md2img 专用事件循环: + # 1. 菜单、总结这类交互命令一旦在主链路超时,若不取消专用 loop 里的任务, + # 浏览器截图仍可能继续跑完,白白占着浏览器与事件循环资源; + # 2. 多次超时后,残留任务会在后台堆积,看起来像“插件流程整体卡住”; + # 3. 因此这里在超时/取消时主动 future.cancel(),让下游尽快停止当前截图任务。 + future.cancel() + raise def _get_browser_manager() -> _PersistentBrowser: From 0d1362f97e96c7ca25c017faf1a24a13f1df0e45 Mon Sep 17 00:00:00 2001 From: Liu Date: Fri, 1 May 2026 11:23:52 +0800 Subject: [PATCH 03/34] =?UTF-8?q?=E4=B8=BA=E9=95=BF=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E6=8E=A5=E5=85=A5=E5=90=8E=E5=8F=B0=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 为消息插件新增可配置的前台/后台分发模式,并在 robot 主链路中加入独立后台任务池,避免长任务长期占用前台 20 个消息处理槽位。 2. 放宽插件执行超时上限到 1800 秒,支持 200 秒以上长任务,同时保留熔断、统计和异常记录。 3. 为群聊总结和 AI 绘图启用后台执行配置,并将菜单插件默认输出改回文本模式。 --- .../plugin_common/message_plugin_interface.py | 28 ++ config.yaml | 7 + plugins/ai_gen_image/config.toml | 8 + plugins/message_summary/config.toml | 8 + plugins/robot_menu/config.toml | 2 +- plugins/robot_menu/main.py | 13 +- robot.py | 361 ++++++++++++++---- 7 files changed, 350 insertions(+), 77 deletions(-) diff --git a/base/plugin_common/message_plugin_interface.py b/base/plugin_common/message_plugin_interface.py index aa71579..102bc63 100644 --- a/base/plugin_common/message_plugin_interface.py +++ b/base/plugin_common/message_plugin_interface.py @@ -7,6 +7,20 @@ from utils.robot_cmd.robot_command import Feature class MessagePluginInterface(PluginInterface): """消息处理插件接口""" + @staticmethod + def normalize_message_dispatch_mode(raw_mode: Any) -> str: + """把插件声明的消息分发模式标准化为 `sync` 或 `background`。 + + 设计说明: + 1. `sync` 表示沿用当前主链路同步执行,插件会占用当前消息处理协程直到完成; + 2. `background` 表示命中后立即转入后台任务池,主消息链路尽快释放,不再占用前台并发槽位; + 3. 这里集中做别名兼容,后续插件只需要写 `background/async/queue` 这类语义值即可。 + """ + mode = str(raw_mode or "").strip().lower() + if mode in {"background", "async", "queued", "queue", "detached"}: + return "background" + return "sync" + @property def command_prefix(self) -> Optional[str]: """命令前缀,如 '/'""" @@ -73,6 +87,20 @@ class MessagePluginInterface(PluginInterface): """ raise NotImplementedError("子类必须实现此方法") + def get_message_dispatch_mode(self, message: Dict[str, Any]) -> str: + """返回当前消息应采用的执行模式。 + + 默认行为: + 1. 优先读取插件自身 `config.toml` 里的 `[runtime] message_dispatch_mode`; + 2. 若未配置,则回退为 `sync`,保持历史行为不变; + 3. 长任务插件如果需要“按命令动态切换前后台”,可以在子类中覆盖本方法。 + """ + plugin_config = getattr(self, "_config", {}) or {} + runtime_config = plugin_config.get("runtime", {}) if isinstance(plugin_config, dict) else {} + runtime_config = runtime_config if isinstance(runtime_config, dict) else {} + raw_mode = runtime_config.get("message_dispatch_mode") or runtime_config.get("dispatch_mode") or "sync" + return self.normalize_message_dispatch_mode(raw_mode) + # ---------------- 插件定时调度能力(可选实现) ---------------- def get_schedule_actions(self) -> List[Dict[str, Any]]: """返回插件支持的可调度动作定义列表。 diff --git a/config.yaml b/config.yaml index 3b60420..42c18dd 100644 --- a/config.yaml +++ b/config.yaml @@ -1,6 +1,13 @@ environment: "${ABOT_ENVIRONMENT:development}" plugin_dir: "${ABOT_PLUGIN_DIR:plugins}" +runtime: + # 后台长任务插件专用并发池大小: + # 1. 当前前台消息处理仍然保留固定 20 并发槽位; + # 2. 配置为 background 的长任务插件会转入这个独立池执行,不再长期占住前台槽位; + # 3. 如果模型/截图/报表类任务较多,可以适当调大;若机器较弱,建议保守一些。 + background_plugin_max_concurrency: "${ABOT_BACKGROUND_PLUGIN_MAX_CONCURRENCY:6}" + db_config: pool_name: "${ABOT_DB_POOL_NAME:wechat_boot_pool}" pool_size: "${ABOT_DB_POOL_SIZE:10}" diff --git a/plugins/ai_gen_image/config.toml b/plugins/ai_gen_image/config.toml index 653246c..7742419 100644 --- a/plugins/ai_gen_image/config.toml +++ b/plugins/ai_gen_image/config.toml @@ -36,3 +36,11 @@ legacy_model = "turbo" [AIGenImage.llm] scene = "image.generate" image_endpoint = "images/generations" + +[runtime] +# AI 绘图属于长耗时命令: +# 1. 上游模型生成、下载与落盘都可能持续几十秒到数分钟; +# 2. 这里启用 background,让它在后台任务池里跑,避免长期占住前台消息并发槽位; +# 3. 超时时间放宽到 7 分钟,兼容慢模型或高峰期图像生成延迟。 +message_dispatch_mode = "background" +plugin_process_timeout_seconds = 420 diff --git a/plugins/message_summary/config.toml b/plugins/message_summary/config.toml index 52a0763..fa94019 100644 --- a/plugins/message_summary/config.toml +++ b/plugins/message_summary/config.toml @@ -26,3 +26,11 @@ summary_image_template_path = "plugins/message_summary/templates/gemini_summary_ template_viewport_width = 580 template_viewport_height = 960 template_device_scale_factor = 2.0 + +[runtime] +# 群总结属于典型长任务: +# 1. 生成期间会查库、调 LLM、渲染图片,整体耗时明显高于普通命令; +# 2. 因此这里改为 background,让命令命中后转入后台任务池,不再占住前台 20 个消息处理槽位; +# 3. 同时把插件总超时放宽到 10 分钟,避免长总结被全局保护策略过早截断。 +message_dispatch_mode = "background" +plugin_process_timeout_seconds = 600 diff --git a/plugins/robot_menu/config.toml b/plugins/robot_menu/config.toml index 8301164..d632971 100644 --- a/plugins/robot_menu/config.toml +++ b/plugins/robot_menu/config.toml @@ -4,7 +4,7 @@ command = ["菜单", "功能菜单"] # 菜单输出模式: # - text:发送文本菜单(历史行为) # - image:先用 md2image 将 Markdown 渲染为图片后发送 -output_mode = "image" +output_mode = "text" # 图片生成失败时是否回退文本菜单: # - false:严格按图片模式,不发送完整菜单文本 # - true:优先保证可达,失败后改发文本 diff --git a/plugins/robot_menu/main.py b/plugins/robot_menu/main.py index 66b4cfe..8f4720d 100644 --- a/plugins/robot_menu/main.py +++ b/plugins/robot_menu/main.py @@ -94,11 +94,14 @@ class RobotMenuPlugin(MessagePluginInterface): self.sync_send_timeout_seconds = int( self._config.get("RobotMenu", {}).get("sync_send_timeout_seconds", 18) ) - # 对外层插件保护显式声明一个更合适的总超时: - # 1. 内层菜单发送会在 sync_send_timeout_seconds 内决定“成功发图 / 回退文本 / 返回失败提示”; - # 2. 外层 wait_for 必须比内层稍长,给降级发送文本留出缓冲; - # 3. 这样可以避免过去“内外层都卡在 55 秒”时,外层先打断,导致降级逻辑来不及执行。 - self.plugin_process_timeout_seconds = max(12, self.sync_send_timeout_seconds + 8) + # 只有在图片模式下,菜单插件才需要声明更紧的外层超时预算: + # 1. 文本模式本身非常快,没有必要覆盖全局默认超时; + # 2. 图片模式下才需要给“渲染 + 文本回退”预留一个略大于内层预算的缓冲区; + # 3. 这样默认文本模式下不会额外引入无意义的超时特化逻辑。 + if RobotMenuRenderTool.normalize_output_mode(output_mode) == "image": + self.plugin_process_timeout_seconds = max(12, self.sync_send_timeout_seconds + 8) + elif hasattr(self, "plugin_process_timeout_seconds"): + delattr(self, "plugin_process_timeout_seconds") # 菜单图片模板文件路径(相对仓库根目录): # 调整样式和布局时只改模板,不改 Python 逻辑。 self.image_template_path = str( diff --git a/robot.py b/robot.py index 87e5d3a..e5b007e 100644 --- a/robot.py +++ b/robot.py @@ -36,7 +36,7 @@ from utils.trace_context import set_current_trace_id, reset_current_trace_id from wechat_ipad import WechatAPIClient from wechat_ipad.models.message import WxMessage, MessageType -# 定义全局信号量,限制最大并发 10 +# 定义前台消息处理信号量,限制最多 20 条消息同时进入主处理链路 sem = asyncio.Semaphore(20) @@ -144,6 +144,18 @@ class Robot: # 通过类属性设置 admin_list,而不是实例属性 GroupBotManager.admin_list = self.config.wx_config.get("admin", []) self.recent_msg_ids = deque(maxlen=20) + # 长任务插件需要与“前台消息并发槽位”解耦: + # 1. 当前 `sem=20` 保护的是“正在处理消息的协程数”,并不适合被 200 秒级长任务长期占住; + # 2. 因此这里额外引入一个后台插件任务池,让长任务在独立并发池里运行; + # 3. 这样消息接收与轻量命令仍然能继续流动,后台长任务则在单独池子里排队执行。 + runtime_config = self.config.resolved_config.get("runtime", {}) if isinstance(self.config.resolved_config, dict) else {} + runtime_config = runtime_config if isinstance(runtime_config, dict) else {} + self.background_plugin_max_concurrency = self._safe_positive_int( + runtime_config.get("background_plugin_max_concurrency"), + 6, + ) + self.background_plugin_semaphore = asyncio.Semaphore(self.background_plugin_max_concurrency) + self.background_plugin_tasks = set() def apply_runtime_config(self, reload_catalog: bool = False) -> None: """把最新全局配置应用到当前运行中的关键对象。 @@ -164,6 +176,18 @@ class Robot: # 管理员列表走 GroupBotManager 的类级缓存;只 reload Config 不会自动回写到这里。 GroupBotManager.admin_list = self.config.wx_config.get("admin", []) + # 后台插件任务池允许从全局配置热刷新: + # 1. 新值只影响后续新入队的后台任务,不会粗暴中断已在运行中的长任务; + # 2. 这里直接替换为新的 Semaphore,逻辑简单且足够符合“后续生效”的预期; + # 3. 若后续需要做到“平滑缩容”,再在这里补更细的迁移策略。 + runtime_config = self.config.resolved_config.get("runtime", {}) if isinstance(self.config.resolved_config, dict) else {} + runtime_config = runtime_config if isinstance(runtime_config, dict) else {} + self.background_plugin_max_concurrency = self._safe_positive_int( + runtime_config.get("background_plugin_max_concurrency"), + self.background_plugin_max_concurrency, + ) + self.background_plugin_semaphore = asyncio.Semaphore(self.background_plugin_max_concurrency) + # system_context 中保存的是 config 对象引用,reload 后插件读取到的是最新字段。 # 但 LLMRegistry 自己还有一层短 TTL 缓存,因此保存全局 LLM 配置后需要显式清掉。 if reload_catalog: @@ -174,7 +198,8 @@ class Robot: "运行时配置已应用: " f"admin_count={len(GroupBotManager.admin_list)}, " f"email_sender={'ready' if self.email_sender else 'missing'}, " - f"llm_cache_reloaded={reload_catalog}" + f"llm_cache_reloaded={reload_catalog}, " + f"background_plugin_max_concurrency={self.background_plugin_max_concurrency}" ) def _cleanup_migrated_system_jobs(self): @@ -668,84 +693,73 @@ class Robot: ) continue - processed, _ = await asyncio.wait_for( - plugin.process_message(plugin_msg), - timeout=protection_policy["process_timeout_seconds"], - ) - self.plugin_manager.record_plugin_execution_success( - plugin, - process_time_ms=self._elapsed_ms(started_at), - ) - self._record_plugin_call_result( - plugin=plugin, - msg=msg, - command_name=command_name, - # 这里把“无异常执行完成”视为统计意义上的成功: - # 1. 很多插件返回 False 只是表示“本次不拦截”或“异步排队后继续放行”; - # 2. 若直接把 processed=False 记成失败,会把成功率统计严重拉低; - # 3. 真正的失败已经会走异常分支,因此统计层这里按“未抛错即成功”更合理。 - process_result=True, - process_time_ms=self._elapsed_ms(started_at), - ) - if processed: + dispatch_mode = self._resolve_message_plugin_dispatch_mode(plugin, plugin_msg) + if dispatch_mode == "background": + self._schedule_background_plugin_execution( + plugin=plugin, + plugin_msg=plugin_msg, + msg=msg, + command_name=command_name, + protection_policy=protection_policy, + ) self.LOG.info( self._trace_message( msg, - f"插件命中 plugin={plugin.name} command={command_name} " - f"cost_ms={self._elapsed_ms(started_at)}" + f"插件后台排队 plugin={plugin.name} command={command_name} " + f"timeout={protection_policy['process_timeout_seconds']}s " + f"background_pool={self.background_plugin_max_concurrency} " + f"pending={len(self.background_plugin_tasks)}" ) ) + # 后台模式一旦入队,就视为插件已经“认领”这条消息: + # 1. 这样可以避免后续插件继续命中同一条命令,造成重复回复; + # 2. 该模式适合命令式、排他式的长任务插件; + # 3. 如果未来需要“后台跑但不拦截”的能力,再单独扩展第三种分发模式。 return True - except asyncio.TimeoutError as timeout_error: - protection_policy = self._build_message_plugin_protection_policy(plugin) - failure_record = self.plugin_manager.record_plugin_execution_failure( - plugin, - failure_type="timeout", - error_message=( - f"插件执行超时,超过 {protection_policy['process_timeout_seconds']} 秒仍未完成。" - ), - process_time_ms=self._elapsed_ms(started_at), - timeout_seconds=protection_policy["process_timeout_seconds"], - failure_threshold=protection_policy["failure_threshold"], - recovery_seconds=protection_policy["circuit_recovery_seconds"], - ) - self._record_plugin_call_error( - plugin=plugin, - msg=msg, - command_name=command_name, - error=timeout_error, - ) - self.LOG.error( - self._trace_message( - msg, - f"插件 {plugin.name} 执行超时,timeout={protection_policy['process_timeout_seconds']}s " - f"circuit_state={failure_record.get('circuit_state')} " - f"consecutive_failures={failure_record.get('consecutive_failures')}" - ) - ) + + try: + processed, _ = await asyncio.wait_for( + plugin.process_message(plugin_msg), + timeout=protection_policy["process_timeout_seconds"], + ) + process_time_ms = self._record_plugin_execution_success( + plugin=plugin, + msg=msg, + command_name=command_name, + started_at=started_at, + ) + if processed: + self.LOG.info( + self._trace_message( + msg, + f"插件命中 plugin={plugin.name} command={command_name} " + f"cost_ms={process_time_ms}" + ) + ) + return True + except asyncio.TimeoutError as timeout_error: + self._handle_plugin_execution_timeout( + plugin=plugin, + msg=msg, + command_name=command_name, + timeout_error=timeout_error, + started_at=started_at, + protection_policy=protection_policy, + ) + except Exception as e: + self._handle_plugin_execution_exception( + plugin=plugin, + msg=msg, + command_name=command_name, + error=e, + started_at=started_at, + protection_policy=protection_policy, + ) except Exception as e: - protection_policy = self._build_message_plugin_protection_policy(plugin) - failure_record = self.plugin_manager.record_plugin_execution_failure( - plugin, - failure_type="error", - error_message=str(e), - process_time_ms=self._elapsed_ms(started_at), - timeout_seconds=0, - failure_threshold=protection_policy["failure_threshold"], - recovery_seconds=protection_policy["circuit_recovery_seconds"], - ) - self._record_plugin_call_error( - plugin=plugin, - msg=msg, - command_name=command_name, - error=e, - ) self.LOG.error( self._trace_message( msg, - f"插件 {plugin.name} 处理消息失败: {e} " - f"circuit_state={failure_record.get('circuit_state')} " - f"consecutive_failures={failure_record.get('consecutive_failures')}" + f"插件 {plugin.name} 预处理失败: {e}" ) ) @@ -855,11 +869,216 @@ class Robot: ) return { - "process_timeout_seconds": max(10, min(int(resolved_timeout), 180)), + # 这里把上限放宽到 1800 秒: + # 1. 用户已经有 200 秒级别的长任务插件,原先 180 秒硬上限会被无条件截断; + # 2. 现在是否“长时间占住前台消息槽位”主要由 dispatch_mode 决定,而不是靠硬砍超时; + # 3. 仍然保留上限,避免误配置成无限等待把整个系统拖成不可控状态。 + "process_timeout_seconds": max(10, min(int(resolved_timeout), 1800)), "failure_threshold": max(2, min(int(failure_threshold), 10)), "circuit_recovery_seconds": max(30, min(int(circuit_recovery_seconds), 900)), } + def _resolve_message_plugin_dispatch_mode(self, plugin, plugin_msg: dict) -> str: + """解析插件当前消息的分发模式。""" + try: + if hasattr(plugin, "get_message_dispatch_mode"): + return plugin.get_message_dispatch_mode(plugin_msg) + except Exception as e: + self.LOG.warning( + self._trace_message( + plugin_msg.get("full_wx_msg"), + f"读取插件分发模式失败,已回退 sync: plugin={plugin.name}, error={e}" + ) + ) + return "sync" + + def _schedule_background_plugin_execution( + self, + *, + plugin, + plugin_msg: dict, + msg: WxMessage, + command_name: str, + protection_policy: dict, + ) -> None: + """把插件执行转入后台任务池。""" + task_name = f"plugin-bg:{plugin.name}:{self._get_trace_id(msg) or 'no-trace'}" + task = asyncio.create_task( + self._run_background_plugin_execution( + plugin=plugin, + plugin_msg=plugin_msg, + msg=msg, + command_name=command_name, + protection_policy=protection_policy, + ), + name=task_name, + ) + self.background_plugin_tasks.add(task) + task.add_done_callback(self.background_plugin_tasks.discard) + + async def _run_background_plugin_execution( + self, + *, + plugin, + plugin_msg: dict, + msg: WxMessage, + command_name: str, + protection_policy: dict, + ) -> None: + """在独立后台任务池中执行长任务插件。""" + trace_token = set_current_trace_id(self._get_trace_id(msg)) + queued_started_at = time.perf_counter() + try: + async with self.background_plugin_semaphore: + started_at = time.perf_counter() + queue_wait_ms = round((started_at - queued_started_at) * 1000, 2) + try: + processed, _ = await asyncio.wait_for( + plugin.process_message(plugin_msg), + timeout=protection_policy["process_timeout_seconds"], + ) + process_time_ms = self._record_plugin_execution_success( + plugin=plugin, + msg=msg, + command_name=command_name, + started_at=started_at, + ) + if processed: + self.LOG.info( + self._trace_message( + msg, + f"后台插件完成 plugin={plugin.name} command={command_name} " + f"cost_ms={process_time_ms} queue_wait_ms={queue_wait_ms}" + ) + ) + else: + self.LOG.warning( + self._trace_message( + msg, + f"后台插件执行完成但未拦截消息 plugin={plugin.name} command={command_name} " + f"cost_ms={process_time_ms} queue_wait_ms={queue_wait_ms}" + ) + ) + except asyncio.TimeoutError as timeout_error: + self._handle_plugin_execution_timeout( + plugin=plugin, + msg=msg, + command_name=command_name, + timeout_error=timeout_error, + started_at=started_at, + protection_policy=protection_policy, + ) + except Exception as e: + self._handle_plugin_execution_exception( + plugin=plugin, + msg=msg, + command_name=command_name, + error=e, + started_at=started_at, + protection_policy=protection_policy, + ) + finally: + reset_current_trace_id(trace_token) + + def _record_plugin_execution_success( + self, + *, + plugin, + msg: WxMessage, + command_name: str, + started_at: float, + ) -> float: + """统一记录插件成功执行结果,并返回耗时。""" + process_time_ms = self._elapsed_ms(started_at) + self.plugin_manager.record_plugin_execution_success( + plugin, + process_time_ms=process_time_ms, + ) + self._record_plugin_call_result( + plugin=plugin, + msg=msg, + command_name=command_name, + # 这里把“无异常执行完成”视为统计意义上的成功: + # 1. 很多插件返回 False 只是表示“本次不拦截”或“异步排队后继续放行”; + # 2. 若直接把 processed=False 记成失败,会把成功率统计严重拉低; + # 3. 真正的失败已经会走异常分支,因此统计层这里按“未抛错即成功”更合理。 + process_result=True, + process_time_ms=process_time_ms, + ) + return process_time_ms + + def _handle_plugin_execution_timeout( + self, + *, + plugin, + msg: WxMessage, + command_name: str, + timeout_error: Exception, + started_at: float, + protection_policy: dict, + ) -> None: + """统一处理插件执行超时。""" + failure_record = self.plugin_manager.record_plugin_execution_failure( + plugin, + failure_type="timeout", + error_message=( + f"插件执行超时,超过 {protection_policy['process_timeout_seconds']} 秒仍未完成。" + ), + process_time_ms=self._elapsed_ms(started_at), + timeout_seconds=protection_policy["process_timeout_seconds"], + failure_threshold=protection_policy["failure_threshold"], + recovery_seconds=protection_policy["circuit_recovery_seconds"], + ) + self._record_plugin_call_error( + plugin=plugin, + msg=msg, + command_name=command_name, + error=timeout_error, + ) + self.LOG.error( + self._trace_message( + msg, + f"插件 {plugin.name} 执行超时,timeout={protection_policy['process_timeout_seconds']}s " + f"circuit_state={failure_record.get('circuit_state')} " + f"consecutive_failures={failure_record.get('consecutive_failures')}" + ) + ) + + def _handle_plugin_execution_exception( + self, + *, + plugin, + msg: WxMessage, + command_name: str, + error: Exception, + started_at: float, + protection_policy: dict, + ) -> None: + """统一处理插件执行异常。""" + failure_record = self.plugin_manager.record_plugin_execution_failure( + plugin, + failure_type="error", + error_message=str(error), + process_time_ms=self._elapsed_ms(started_at), + timeout_seconds=0, + failure_threshold=protection_policy["failure_threshold"], + recovery_seconds=protection_policy["circuit_recovery_seconds"], + ) + self._record_plugin_call_error( + plugin=plugin, + msg=msg, + command_name=command_name, + error=error, + ) + self.LOG.error( + self._trace_message( + msg, + f"插件 {plugin.name} 处理消息失败: {error} " + f"circuit_state={failure_record.get('circuit_state')} " + f"consecutive_failures={failure_record.get('consecutive_failures')}" + ) + ) + def _get_stats_collector_plugin(self): """获取运行中的统计收集插件实例。""" # 统计插件已经从“事件订阅”切到“主链路直接回调”, From adbf4471cf3c77c1613f31604c45d345e6b312f8 Mon Sep 17 00:00:00 2001 From: Liu Date: Fri, 1 May 2026 11:37:25 +0800 Subject: [PATCH 04/34] =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E6=A8=A1=E5=BC=8F=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=85=A8=E7=90=83=E6=96=B0=E9=97=BB=E5=90=8E=E5=8F=B0=E7=BA=BF?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 为消息插件新增按消息动态超时能力,并让机器人侧按当前命令读取超时策略。 2. 将斗鱼日报、身价关系图/重算、百科问答出题判题切到后台执行。 3. 将系统更新、黑丝视频、猛男视频、成员锐评默认配置为后台模式并放宽超时。 4. 修复全球新闻插件在线程中直接挂协程导致任务不真正执行的问题。 --- .../plugin_common/message_plugin_interface.py | 10 +++++ plugins/douyu/main.py | 44 +++++++++++++++++++ plugins/game_task/main.py | 21 +++++++++ plugins/global_news/main.py | 38 ++++++++++------ plugins/member_roast/config.toml | 8 ++++ plugins/system_updater/config.toml | 10 ++++- plugins/value_rank/main.py | 25 +++++++++++ plugins/video/config.toml | 10 ++++- plugins/video_man/config.toml | 10 ++++- robot.py | 26 +++++++++-- 10 files changed, 183 insertions(+), 19 deletions(-) diff --git a/base/plugin_common/message_plugin_interface.py b/base/plugin_common/message_plugin_interface.py index 102bc63..bab2996 100644 --- a/base/plugin_common/message_plugin_interface.py +++ b/base/plugin_common/message_plugin_interface.py @@ -101,6 +101,16 @@ class MessagePluginInterface(PluginInterface): raw_mode = runtime_config.get("message_dispatch_mode") or runtime_config.get("dispatch_mode") or "sync" return self.normalize_message_dispatch_mode(raw_mode) + def get_message_process_timeout_seconds(self, message: Dict[str, Any]) -> Optional[int]: + """返回当前消息建议使用的插件总超时秒数。 + + 默认行为: + 1. 返回 `None`,表示继续沿用插件配置或机器人侧的自动推断逻辑; + 2. 适合“同一个插件里既有轻命令,也有重命令”的场景,避免所有命令共用同一个超时; + 3. 子类若需要按命令动态放宽超时,可覆盖本方法并返回正整数秒数。 + """ + return None + # ---------------- 插件定时调度能力(可选实现) ---------------- def get_schedule_actions(self) -> List[Dict[str, Any]]: """返回插件支持的可调度动作定义列表。 diff --git a/plugins/douyu/main.py b/plugins/douyu/main.py index bb6f71a..c1ea004 100644 --- a/plugins/douyu/main.py +++ b/plugins/douyu/main.py @@ -591,6 +591,20 @@ class DouyuPlugin(MessagePluginInterface): self._status_check_retry_count = 3 self._status_check_retry_delay_seconds = 1 self._daily_report_llm_client: Optional[UnifiedLLMClient] = None + # 斗鱼插件是典型“快命令 + 慢命令”混合体: + # 1. 订阅/列表类命令基本都是 Redis 读写,应该继续走前台同步,保证即时反馈; + # 2. 日报类命令会拉历史弹幕、调 LLM、渲染图片,天然属于长任务; + # 3. 因此这里把“慢命令名单”集中收口,供分发模式与超时策略共同复用。 + self._background_report_commands = { + "#斗鱼弹幕日报", + "斗鱼弹幕日报", + "#强制斗鱼弹幕日报", + "强制斗鱼弹幕日报", + "#斗鱼粉丝日报", + "斗鱼粉丝日报", + "#强制斗鱼粉丝日报", + "强制斗鱼粉丝日报", + } # 直播间语义画像: # 1. 允许按房间号补充“主播职业生涯、圈内关系、常见梗来源”等背景; # 2. 这些信息不会直接替代真实弹幕,只用于帮助 LLM 更准确理解圈内黑话; @@ -624,6 +638,36 @@ class DouyuPlugin(MessagePluginInterface): except Exception: return False, day_text + @staticmethod + def _extract_command_token(message: Dict[str, Any]) -> str: + """从消息里提取首个命令词。""" + content = str(message.get("content", "") or "").strip() + return content.split()[0] if content else "" + + def get_message_dispatch_mode(self, message: Dict[str, Any]) -> str: + """按命令决定斗鱼插件走前台还是后台。 + + 设计说明: + 1. 订阅、取消订阅、列表查询都很轻,继续前台执行能保证手感; + 2. 日报命令一旦命中,后面会进入“查素材 -> 调模型 -> 渲染图片”的长链路; + 3. 因此只有日报相关命令切后台,避免它们把前台 20 个消息槽位长期占住。 + """ + command = self._extract_command_token(message) + if command in self._background_report_commands: + return self.normalize_message_dispatch_mode("background") + return super().get_message_dispatch_mode(message) + + def get_message_process_timeout_seconds(self, message: Dict[str, Any]) -> Optional[int]: + """只为日报命令放宽总超时,普通命令继续走默认保护值。""" + command = self._extract_command_token(message) + if command in self._background_report_commands: + # 用户已经明确存在 200 秒级长任务: + # 1. 斗鱼日报除了 LLM,还包含素材整理与图片渲染; + # 2. 这里放宽到 15 分钟,足够覆盖补发历史日报或高峰期模型排队; + # 3. 非日报命令不受影响,仍保持原有更紧的保护策略。 + return 900 + return None + @staticmethod def _normalize_text_list(values: Any) -> List[str]: """ diff --git a/plugins/game_task/main.py b/plugins/game_task/main.py index cb8e997..f836964 100644 --- a/plugins/game_task/main.py +++ b/plugins/game_task/main.py @@ -59,6 +59,11 @@ class GameTaskPlugin(MessagePluginInterface): self.LOG = logger # 注册功能权限 self.feature = self.register_feature() + # 百科问答里只有“出题 / 判题”会真正走 LLM: + # 1. `/t` 需要模型随机生成题目; + # 2. `/a` 需要模型判分并给理由; + # 3. `/s /r /l /h` 主要是本地 DB 读写,保持前台即可。 + self._background_commands = {"/t", "/a"} def initialize(self, context: Dict[str, Any]) -> bool: """初始化插件""" @@ -118,6 +123,22 @@ class GameTaskPlugin(MessagePluginInterface): return command in self._commands + def get_message_dispatch_mode(self, message: Dict[str, Any]) -> str: + """只把 LLM 型命令切到后台,避免百科插件拖慢前台消息链。""" + content = str(message.get("content", "") or "").strip() + command = content.split()[0] if content else "" + if command in self._background_commands: + return self.normalize_message_dispatch_mode("background") + return super().get_message_dispatch_mode(message) + + def get_message_process_timeout_seconds(self, message: Dict[str, Any]) -> Optional[int]: + """为出题/判题命令单独放宽超时,兼容慢模型或排队场景。""" + content = str(message.get("content", "") or "").strip() + command = content.split()[0] if content else "" + if command in self._background_commands: + return 120 + return None + def calculate_game_points(self, message: Dict[str, Any], success: bool, response: str) -> int: """计算游戏积分""" if not success: diff --git a/plugins/global_news/main.py b/plugins/global_news/main.py index 90a1c51..03dc85a 100644 --- a/plugins/global_news/main.py +++ b/plugins/global_news/main.py @@ -1,6 +1,6 @@ import asyncio import threading -import time # 添加这一行 +import time from typing import Dict, Any, List, Optional, Tuple from base.plugin_common.message_plugin_interface import MessagePluginInterface @@ -146,31 +146,43 @@ class GlobalNewsPlugin(MessagePluginInterface): self._news_tasks[task_id] = thread self.LOG.info(f"启动新闻获取任务: {task_id}") - async def _fetch_news_thread(self, task_id: str, sender: str, roomid: str): - """在单独的线程中运行异步新闻获取任务""" + def _fetch_news_thread(self, task_id: str, sender: str, roomid: str): + """在单独线程里执行新闻抓取主流程。 + + 这里必须保持为同步函数: + 1. `threading.Thread(target=...)` 只能直接执行普通可调用对象; + 2. 之前把协程函数直接塞给 `target`,线程里只会得到一个未执行的 coroutine,任务实际上不会跑; + 3. 现在在线程内部显式创建事件循环,再把异步抓取和发消息协程跑完,才能真正脱离主链路执行。 + """ + loop = asyncio.new_event_loop() try: - loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) news_result = loop.run_until_complete(self._fetch_news_async()) - loop.close() # 处理结果 + receiver = roomid if roomid else sender if news_result: - # 发送新闻图片 - receiver = roomid if roomid else sender - await self.bot.send_image_message(receiver, news_result) - await self.bot.send_text_message("🌍全球新闻获取完成!", receiver, sender) + # 在线程自有事件循环里把图片和完成提示真正发出去, + # 避免这里只拿到 coroutine 对象却没有执行。 + loop.run_until_complete(self.bot.send_image_message(receiver, news_result)) + loop.run_until_complete(self.bot.send_text_message(receiver, "🌍全球新闻获取完成!", sender)) else: - await self.bot.send_text_message( - (roomid if roomid else sender), "❌获取新闻失败,请稍后再试", sender) + loop.run_until_complete(self.bot.send_text_message(receiver, "❌获取新闻失败,请稍后再试", sender)) except Exception as e: self.LOG.error(f"新闻获取任务出错: {e}") - await self.bot.send_text_message((roomid if roomid else sender), f"❌获取新闻出错: {str(e)}", - sender) + try: + receiver = roomid if roomid else sender + loop.run_until_complete(self.bot.send_text_message(receiver, f"❌获取新闻出错: {str(e)}", sender)) + except Exception as send_error: + self.LOG.error(f"新闻获取失败后的通知发送异常: {send_error}") finally: # 清理任务 if task_id in self._news_tasks: del self._news_tasks[task_id] + try: + loop.close() + except Exception: + pass async def _fetch_news_async(self) -> str: """异步获取所有新闻源的新闻""" diff --git a/plugins/member_roast/config.toml b/plugins/member_roast/config.toml index 039ae98..4c34e40 100644 --- a/plugins/member_roast/config.toml +++ b/plugins/member_roast/config.toml @@ -48,3 +48,11 @@ history_group_summary_limit = 10 max_output_chars = 320 min_output_chars = 140 sharpness_level = "high" + +[runtime] +# 成员锐评命令需要同时查画像、拉最近消息,再走一次 LLM 生成: +# 1. 这条链路比普通查询明显更重,而且用户已经接受“先提示处理中,再稍后出结果”的交互; +# 2. 默认切到后台后,就不会因为某次模型慢响应把前台消息槽位卡住; +# 3. 总超时放宽到 4 分钟,兼容群画像较大或模型排队的情况。 +message_dispatch_mode = "background" +plugin_process_timeout_seconds = 240 diff --git a/plugins/system_updater/config.toml b/plugins/system_updater/config.toml index 4fcbf3e..21d0bb4 100644 --- a/plugins/system_updater/config.toml +++ b/plugins/system_updater/config.toml @@ -3,4 +3,12 @@ enable = true commands = ["更新系统", "系统更新", "重启系统", "更新重启"] wait_time = 5 # 设置管理员微信ID,只有这些ID可以执行更新操作 -shell_path= "/home/liuwei/abot/restart.sh" \ No newline at end of file +shell_path= "/home/liuwei/abot/restart.sh" + +[runtime] +# 系统更新属于典型后台维护任务: +# 1. 命令命中后会执行重启脚本,整个过程可能持续几十秒到数分钟; +# 2. 这类任务不应该长期占住前台消息并发槽位,否则会影响其他插件收消息; +# 3. 因此默认切到后台执行,并把总超时放宽到 10 分钟。 +message_dispatch_mode = "background" +plugin_process_timeout_seconds = 600 diff --git a/plugins/value_rank/main.py b/plugins/value_rank/main.py index 9ce3e8b..438e420 100644 --- a/plugins/value_rank/main.py +++ b/plugins/value_rank/main.py @@ -571,6 +571,11 @@ class ValueRankPlugin(MessagePluginInterface): self.mention_batch_size = 200 self.mention_window_start_minutes = 20 self.mention_window_end_minutes = 10 + # 身价排行里只有少数命令是真正的长任务: + # 1. `社交关系图` 需要拼 HTML 再截图渲染; + # 2. `重算身价` 会扫描整群候选成员并重写快照; + # 3. 其他榜单/说明类命令基本是读库拼文本,不值得全部切到后台。 + self._background_commands = {"社交关系图", "重算身价"} def initialize(self, context: Dict[str, Any]) -> bool: """初始化插件与配置。""" @@ -639,6 +644,26 @@ class ValueRankPlugin(MessagePluginInterface): command = content.split(" ")[0] return command in self._commands + def get_message_dispatch_mode(self, message: Dict[str, Any]) -> str: + """按命令决定是否切入后台任务池。""" + content = str(message.get("content", "") or "").strip() + command = content.split()[0] if content else "" + if command in self._background_commands: + # 这两个命令明显比普通查询重很多: + # 1. `社交关系图` 的瓶颈主要在模板渲染与截图; + # 2. `重算身价` 会遍历群成员并批量回写快照; + # 3. 改成后台后,轻量榜单查询就不会再被这类维护型命令拖住。 + return self.normalize_message_dispatch_mode("background") + return super().get_message_dispatch_mode(message) + + def get_message_process_timeout_seconds(self, message: Dict[str, Any]) -> Optional[int]: + """为重渲染/重算命令单独放宽总超时。""" + content = str(message.get("content", "") or "").strip() + command = content.split()[0] if content else "" + if command in self._background_commands: + return 240 + return None + @plugin_stats_decorator(plugin_name="身价排行") async def process_message(self, message: Dict[str, Any]) -> Tuple[bool, Optional[str]]: """处理用户命令入口。""" diff --git a/plugins/video/config.toml b/plugins/video/config.toml index 631a3d2..14f8a10 100644 --- a/plugins/video/config.toml +++ b/plugins/video/config.toml @@ -4,4 +4,12 @@ command = ["黑丝视频", "黑丝", "来个黑丝", "搞个黑丝"] command-format = """ 🎬视频指令: 黑丝 -""" \ No newline at end of file +""" + +[runtime] +# 视频插件会经历“拉取接口 -> 下载文件 -> 抽首帧 -> 发送视频”整条链路: +# 1. 任一环节抖动都可能让处理时间明显长于普通文本命令; +# 2. 切到后台后,慢下载不会再卡住前台消息处理; +# 3. 总超时放宽到 4 分钟,兼容网络波动和大一点的视频文件。 +message_dispatch_mode = "background" +plugin_process_timeout_seconds = 240 diff --git a/plugins/video_man/config.toml b/plugins/video_man/config.toml index 8d55de2..e76b84b 100644 --- a/plugins/video_man/config.toml +++ b/plugins/video_man/config.toml @@ -6,4 +6,12 @@ command-format = """ 猛男 肌肉 帅哥 -""" \ No newline at end of file +""" + +[runtime] +# 猛男视频和普通视频插件的耗时结构基本一致: +# 1. 需要先拉接口,再下载视频文件,并额外抽首帧做封面; +# 2. 这些 IO/编解码步骤不适合长期占住前台并发槽位; +# 3. 因此同样默认走后台模式,并保留 4 分钟总超时。 +message_dispatch_mode = "background" +plugin_process_timeout_seconds = 240 diff --git a/robot.py b/robot.py index e5b007e..cc9fa49 100644 --- a/robot.py +++ b/robot.py @@ -674,7 +674,7 @@ class Robot: # 检查插件是否可以处理该消息 if plugin.can_process(plugin_msg): - protection_policy = self._build_message_plugin_protection_policy(plugin) + protection_policy = self._build_message_plugin_protection_policy(plugin, plugin_msg) acquire_result = self.plugin_manager.try_acquire_plugin_execution( plugin, recovery_seconds=protection_policy["circuit_recovery_seconds"], @@ -819,7 +819,7 @@ class Robot: except (TypeError, ValueError): return default - def _build_message_plugin_protection_policy(self, plugin) -> dict: + def _build_message_plugin_protection_policy(self, plugin, plugin_msg: dict = None) -> dict: """构建消息插件执行保护策略。""" plugin_config = getattr(plugin, "_config", {}) or {} runtime_config = plugin_config.get("runtime", {}) if isinstance(plugin_config, dict) else {} @@ -827,12 +827,32 @@ class Robot: breaker_config = runtime_config.get("circuit_breaker", {}) if isinstance(runtime_config, dict) else {} breaker_config = breaker_config if isinstance(breaker_config, dict) else {} + dynamic_timeout = 0 + if plugin_msg and hasattr(plugin, "get_message_process_timeout_seconds"): + try: + # 允许插件按“当前消息内容”给出更精细的超时建议: + # 1. 同一个插件里,日报/渲染/重算命令往往比普通查询慢很多; + # 2. 以前只能给整个插件统一配一个超时,容易出现“轻命令超时过大、重命令超时不够”的两难; + # 3. 这里把粒度放到单条消息,便于插件只给真正的长任务放宽保护时间。 + dynamic_timeout = self._safe_positive_int( + plugin.get_message_process_timeout_seconds(plugin_msg), + 0, + ) + except Exception as e: + self.LOG.warning( + self._trace_message( + plugin_msg.get("full_wx_msg"), + f"读取插件动态超时失败,已回退默认策略: plugin={plugin.name}, error={e}" + ) + ) + # 超时策略尽量遵循“显式配置优先,已有内部超时参数兜底”的思路: # 1. 新插件如果有特殊需求,只需要在 runtime / circuit_breaker 下声明自己的超时; # 2. 老插件不改代码也能自动复用现有的 request / llm / render 超时字段; # 3. 最终统一加一个缓冲区,避免外层 wait_for 比插件内部自己的超时还更早打断。 explicit_timeout = ( - runtime_config.get("plugin_process_timeout_seconds") + dynamic_timeout + or runtime_config.get("plugin_process_timeout_seconds") or runtime_config.get("message_timeout_seconds") or breaker_config.get("timeout_seconds") or getattr(plugin, "plugin_process_timeout_seconds", 0) From c0a6ee6c21712ba568846925606bbcf5cca0459a Mon Sep 17 00:00:00 2001 From: Liu Date: Fri, 1 May 2026 11:45:23 +0800 Subject: [PATCH 05/34] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A1=B5=E5=B1=95=E7=A4=BA=E5=89=8D=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E6=89=A7=E8=A1=8C=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 在插件治理快照中新增消息插件分发方式摘要,区分前台同步、后台任务、混合模式与非消息插件。 2. 插件详情接口统一复用完整治理快照,避免列表和详情字段不一致。 3. 插件管理页列表、移动端卡片和详情弹窗新增执行方式展示,并支持命令级分发预览。 --- admin/dashboard/templates/plugins_manage.html | 86 +++++++++ base/plugin_common/plugin_manager.py | 182 +++++++++++++++++- 2 files changed, 264 insertions(+), 4 deletions(-) diff --git a/admin/dashboard/templates/plugins_manage.html b/admin/dashboard/templates/plugins_manage.html index 69cc002..f57f9ac 100644 --- a/admin/dashboard/templates/plugins_manage.html +++ b/admin/dashboard/templates/plugins_manage.html @@ -253,6 +253,18 @@ + + + - - - + + + - - - - - - - - - - - - - - - - - - - + @@ -230,25 +197,6 @@ new Vue({ if (status === 'running') return 'warning' return 'info' }, - healthTag(status) { - if (status === 'healthy') return 'success' - if (status === 'running') return 'warning' - if (status === 'failed') return 'danger' - if (status === 'degraded') return 'warning' - if (status === 'disabled') return 'info' - return '' - }, - healthLabel(status) { - const mapping = { - healthy: '健康', - running: '执行中', - failed: '异常', - degraded: '有告警', - disabled: '停用', - idle: '待运行' - } - return mapping[status] || '待运行' - }, formatDateTime(value) { // 统一清洗时间展示:去掉 ISO 'T',并兼容字符串与日期对象。 if (!value) return '' @@ -382,25 +330,6 @@ new Vue({ } await this.loadSchedules() }, - async toggleEnabled(row) { - const payload = { - action_name: row.action_name, - description: row.description, - enabled: !row.enabled, - trigger_type: row.trigger_type, - trigger_config: row.trigger_config, - target_scope: row.target_scope, - target_config: row.target_config, - payload: row.payload || {} - } - const resp = await axios.put(`/plugin_schedules/api/schedules/${row.id}`, payload) - if (resp.data.success) { - this.$message.success(row.enabled ? '已停用' : '已启用') - await this.loadSchedules() - } else { - this.$message.error(resp.data.message || '更新失败') - } - }, async viewLogs(row) { const resp = await axios.get(`/plugin_schedules/api/schedules/${row.id}/logs`) if (resp.data.success) { @@ -423,10 +352,5 @@ new Vue({ .page-hero-copy p{color:#64748b;font-size:14px} .action-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap} .detail-pre{white-space:pre-wrap;word-break:break-word;background:rgba(248,250,252,.85);border:1px solid rgba(148,163,184,.12);border-radius:14px;padding:10px;color:#334155} -.cell-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#475569} -.history-metrics{display:flex;align-items:center;justify-content:center;gap:8px} -.metric-success{color:#16a34a;font-weight:600} -.metric-fail{color:#dc2626;font-weight:600} -.history-total{margin-top:4px;color:#64748b;font-size:12px} {% endblock %} diff --git a/admin/dashboard/templates/system_jobs.html b/admin/dashboard/templates/system_jobs.html index af46ddd..35d3de9 100644 --- a/admin/dashboard/templates/system_jobs.html +++ b/admin/dashboard/templates/system_jobs.html @@ -33,28 +33,6 @@ {% raw %}{{ scope.row.last_status || 'never' }}{% endraw %} - - - - - - - - - -