# -*- coding: utf-8 -*- """ 统计看板服务器 - 使用Flask蓝图重构版 """ import os 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.emoji_asset_db import EmojiAssetDB from db.member_context_db import MemberContextDBOperator from db.message_storage import MessageStorageDB from db.message_summary_db import MessageSummaryDBOperator 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__), '..', '..'))) class DashboardServer: """统计看板服务器""" def __init__(self, host: str = None, port: int = None, username: str = None, password: str = None, robot_instance=None): # 加载配置文件 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") self.password = password or self.config.get("auth", {}).get("password", "admin123") self.LOG = logger self.LOG.info(f"Dashboard配置加载完成: 服务器将运行在 {self.host}:{self.port}") # 登录失败限流兜底缓存: # 1. 优先尝试 Redis,但为了兼容 Redis 暂不可用的场景,这里保留进程内兜底; # 2. 字典内容只保存短期登录失败窗口,不用于持久化; # 3. 线程化 WSGI 会并发访问,因此需要显式加锁。 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) # 群运营分析 2.0 会直接复用群消息总结表: # 1. 这类数据已经由现有插件产出,不需要另起一套采集逻辑; # 2. 统一在 DashboardServer 上挂载,便于多个后台蓝图复用; # 3. 即使对应插件未在当前请求时运行,数据库读能力也应保持可用。 self.message_summary_db = MessageSummaryDBOperator(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: self.LOG.error("未提供Robot实例,Dashboard无法正常工作") raise ValueError("必须提供Robot实例") self.app = self._create_app() self._stop_event = threading.Event() self._server = None # 存储服务器实例 def _load_dashboard_config(self): """加载Dashboard配置文件""" try: 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: 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 except Exception as e: self.LOG.error(f"加载Dashboard配置文件失败: {e}") # 返回默认配置 return { "server": {"host": "0.0.0.0", "port": 8888}, "auth": {"username": "admin", "password": "admin123"} } def _create_app(self) -> Flask: """创建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)) configured_secret = str( os.environ.get("ABOT_DASHBOARD_SECRET_KEY") or auth_config.get("secret_key", "") or "" ).strip() if configured_secret: app.secret_key = configured_secret else: # 若未显式配置 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)) # 设置Werkzeug日志级别为DEBUG import logging logging.getLogger('werkzeug').setLevel(logging.ERROR) # 将dashboard_server实例设置为app的属性 app.dashboard_server = self # 配置静态文件访问 static_folder = os.path.join(os.path.dirname(os.path.abspath(__file__)), '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/') def serve_images(filename): return send_from_directory(images_dir, filename) # 添加一个路由处理favicon请求 @app.route('/favicon.ico') def favicon(): 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: """读取后台认证策略配置。""" auth_config = self.config.get("auth", {}) or {} return { "max_failed_attempts": max(3, int(auth_config.get("max_failed_attempts", 5) or 5)), "lock_seconds": max(60, int(auth_config.get("lock_seconds", 900) or 900)), "session_timeout_minutes": max(15, int(auth_config.get("session_timeout_minutes", 480) or 480)), } @staticmethod def _build_login_guard_key(username: str, remote_ip: str) -> str: """构建登录限流键。""" return f"{str(username or '').strip().lower()}::{str(remote_ip or '').strip() or 'unknown'}" def _get_login_failure_record(self, guard_key: str) -> dict: """读取登录失败记录,优先 Redis,失败时回退到进程内缓存。""" try: redis_conn = self.db_manager.get_redis_connection() raw_value = redis_conn.get(f"dashboard:auth:fail:{guard_key}") if raw_value: parts = str(raw_value).split("|", 1) return { "count": int(parts[0] or 0), "first_failed_at": float(parts[1] or 0) if len(parts) > 1 else 0.0, } except Exception: pass with self._auth_runtime_lock: record = dict(self._auth_failures.get(guard_key, {}) or {}) expire_at = float(record.get("expire_at") or 0.0) 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: """保存登录失败记录,优先 Redis,失败时回退到进程内缓存。""" payload = f"{int(count)}|{float(first_failed_at)}" try: redis_conn = self.db_manager.get_redis_connection() redis_conn.setex(f"dashboard:auth:fail:{guard_key}", ttl_seconds, payload) return except Exception: pass expire_at = time.time() + ttl_seconds with self._auth_runtime_lock: self._auth_failures[guard_key] = { "count": int(count), "first_failed_at": float(first_failed_at), "expire_at": expire_at, } def clear_login_failures(self, username: str, remote_ip: str) -> None: """清理登录失败记录。""" guard_key = self._build_login_guard_key(username, remote_ip) try: redis_conn = self.db_manager.get_redis_connection() redis_conn.delete(f"dashboard:auth:fail:{guard_key}") except Exception: pass with self._auth_runtime_lock: self._auth_failures.pop(guard_key, None) def get_login_lock_status(self, username: str, remote_ip: str) -> dict: """获取当前账号/IP 组合的登录锁定状态。""" policy = self.get_auth_policy() record = self._get_login_failure_record(self._build_login_guard_key(username, remote_ip)) count = int(record.get("count") or 0) first_failed_at = float(record.get("first_failed_at") or 0.0) if count < policy["max_failed_attempts"] or first_failed_at <= 0: return {"locked": False, "remaining_seconds": 0, "failed_count": count} elapsed = max(0, int(time.time() - first_failed_at)) remaining_seconds = max(0, policy["lock_seconds"] - elapsed) if remaining_seconds <= 0: self.clear_login_failures(username, remote_ip) return {"locked": False, "remaining_seconds": 0, "failed_count": 0} return {"locked": True, "remaining_seconds": remaining_seconds, "failed_count": count} def mark_login_failure(self, username: str, remote_ip: str) -> dict: """记录一次登录失败,并返回最新锁定状态。""" policy = self.get_auth_policy() guard_key = self._build_login_guard_key(username, remote_ip) record = self._get_login_failure_record(guard_key) count = int(record.get("count") or 0) first_failed_at = float(record.get("first_failed_at") or 0.0) now_ts = time.time() if first_failed_at <= 0: first_failed_at = now_ts count += 1 self._save_login_failure_record(guard_key, count, first_failed_at, policy["lock_seconds"]) return self.get_login_lock_status(username, remote_ip) def should_force_password_change(self, username: str) -> bool: """判断当前管理员是否应该被强制提示修改密码。""" admin_db = getattr(self, "admin_account_db", None) normalized_username = str(username or "").strip() if not normalized_username: return False # 判断弱密码时优先且尽量只相信数据库中的真实账号状态。 # # 之前的问题在于: # 1. 代码先看数据库,再继续拿本地 config.toml 的默认用户名/密码做补充判断; # 2. 如果数据库中的管理员已经改成强密码,但本地配置仍保留 admin/admin123, # 前端仍会被误判成“当前账号在使用弱密码”; # 3. 这会让后台改密提示和真实账号状态脱节。 # # 现在的策略改成: # - 数据库里存在该管理员账号:只按数据库口令哈希判断; # - 数据库里不存在该账号,才允许回退到旧配置模式,兼容尚未迁移完成的老部署。 if admin_db: try: admin_row = admin_db.get_admin_by_username(normalized_username) if admin_row is not None: return admin_db.is_using_risky_password(normalized_username) except Exception as e: # 安全提示属于辅助能力,数据库偶发异常时不应因为误判把用户“锁”在改密弹窗里。 # 因此这里记录 warning,并继续走兼容兜底,而不是直接强制提示弱密码。 self.LOG.warning(f"读取后台账号安全状态失败,将尝试兼容兜底判断: username={normalized_username}, error={e}") fallback_username = str(self.username or "").strip() fallback_password = str(self.password or "").strip() risky_passwords = getattr(admin_db, "RISKY_PASSWORDS", {"admin123", "admin"}) return normalized_username == fallback_username and fallback_password in risky_passwords 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.messages import messages_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 # 在app.register_blueprint部分添加 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) app.register_blueprint(robot_bp) app.register_blueprint(messages_bp) app.register_blueprint(stats_bp) app.register_blueprint(system_bp) app.register_blueprint(plugin_routes) app.register_blueprint(file_browser_bp) app.register_blueprint(message_push_bp) app.register_blueprint(friend_circle_bp) app.register_blueprint(system_jobs_bp) app.register_blueprint(plugin_schedules_bp) app.register_blueprint(group_plugin_config_bp) app.register_blueprint(fun_command_rules_bp) app.register_blueprint(trendradar_webhook_bp) self.LOG.info("所有蓝图已注册") def run(self): """运行服务器""" from werkzeug.serving import make_server # 设置Werkzeug日志级别为DEBUG import logging logging.getLogger('werkzeug').setLevel(logging.ERROR) self.LOG.info(f"启动服务器: {self.host}:{self.port}") try: # Dashboard 存在文件浏览、统计查询等慢请求,单线程 WSGI 一旦被占住会导致整个后台无响应。 # 改为 threaded server,避免某个接口阻塞后拖死所有页面访问。 self._server = make_server(self.host, self.port, self.app, threaded=True) self._server.serve_forever() except Exception as e: self.LOG.error(f"服务器运行失败: {e}") 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("机器人对象未初始化") user_data = { "wxid": getattr(self.robot, "wxid", ""), "nickName": getattr(self.robot, "nickname", ""), "mobile": getattr(self.robot, "phone", ""), "smallHeadImgUrl": getattr(self.robot, "head_image", ""), "signature": getattr(self.robot, "signature", "") } except (AttributeError, ValueError) as e: print(f"获取用户信息出错: {str(e)}") user_data = { "wxid": self.robot.wxid, "nickName": self.robot.nickname, "mobile": self.robot.phone, "smallHeadImgUrl": self.robot.head_image, "signature": self.robot.signature } if not user_data: return {"success": False, "message": "未获取到用户数据"} return { "success": True, "data": { "wx_id": user_data.get("wxid", ""), "nickname": user_data.get("nickName", "未知用户"), "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] } } except Exception as e: self.LOG.error(f"获取当前用户信息失败: {e}") return {"success": False, "message": f"获取用户信息出错: {str(e)}"}