From 1d7ee9f95357066992fd32ed193dccb2b54e16e8 Mon Sep 17 00:00:00 2001 From: liuwei Date: Tue, 18 Mar 2025 17:40:59 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=85=A5=E6=8C=87=E4=BB=A4=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=BB=9F=E8=AE=A1=EF=BC=8C=E6=8C=87=E4=BB=A4=E7=9C=8B?= =?UTF-8?q?=E6=9D=BF=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/scripts/create_stats_tables.sql | 64 +++ {sql => db/scripts}/init.sql | 0 db/stats_db.py | 540 ++++++++++++++++++ event_system/__init__.py | 1 + event_system/event_manager.py | 59 ++ event_system/events/plugin_events.py | 74 +++ event_system/events/stats_events.py | 40 ++ plugin_common/plugin_registry.py | 2 +- plugins/stats_collector/__init__.py | 7 + plugins/stats_collector/config.yaml | 21 + plugins/stats_collector/decorators.py | 75 +++ plugins/stats_collector/main.py | 136 +++++ plugins/stats_dashboard/__init__.py | 7 + plugins/stats_dashboard/dashboard_server.py | 138 +++++ plugins/stats_dashboard/main.py | 146 +++++ plugins/stats_dashboard/templates/base.html | 162 ++++++ plugins/stats_dashboard/templates/errors.html | 129 +++++ plugins/stats_dashboard/templates/groups.html | 74 +++ plugins/stats_dashboard/templates/index.html | 285 +++++++++ .../stats_dashboard/templates/plugins.html | 141 +++++ plugins/stats_dashboard/templates/users.html | 73 +++ robot.py | 37 ++ 优化需求.md | 197 +++++++ 23 files changed, 2407 insertions(+), 1 deletion(-) create mode 100644 db/scripts/create_stats_tables.sql rename {sql => db/scripts}/init.sql (100%) create mode 100644 db/stats_db.py create mode 100644 event_system/__init__.py create mode 100644 event_system/event_manager.py create mode 100644 event_system/events/plugin_events.py create mode 100644 event_system/events/stats_events.py create mode 100644 plugins/stats_collector/__init__.py create mode 100644 plugins/stats_collector/config.yaml create mode 100644 plugins/stats_collector/decorators.py create mode 100644 plugins/stats_collector/main.py create mode 100644 plugins/stats_dashboard/__init__.py create mode 100644 plugins/stats_dashboard/dashboard_server.py create mode 100644 plugins/stats_dashboard/main.py create mode 100644 plugins/stats_dashboard/templates/base.html create mode 100644 plugins/stats_dashboard/templates/errors.html create mode 100644 plugins/stats_dashboard/templates/groups.html create mode 100644 plugins/stats_dashboard/templates/index.html create mode 100644 plugins/stats_dashboard/templates/plugins.html create mode 100644 plugins/stats_dashboard/templates/users.html create mode 100644 优化需求.md diff --git a/db/scripts/create_stats_tables.sql b/db/scripts/create_stats_tables.sql new file mode 100644 index 0000000..fd21828 --- /dev/null +++ b/db/scripts/create_stats_tables.sql @@ -0,0 +1,64 @@ +-- 插件统计汇总表 +CREATE TABLE IF NOT EXISTS t_plugin_stats ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + plugin_name VARCHAR(50) NOT NULL COMMENT '插件名称', + command VARCHAR(50) NOT NULL COMMENT '触发的命令', + stat_date DATE NOT NULL COMMENT '统计日期', + total_calls INT NOT NULL DEFAULT 0 COMMENT '总调用次数', + success_calls INT NOT NULL DEFAULT 0 COMMENT '成功调用次数', + failed_calls INT NOT NULL DEFAULT 0 COMMENT '失败调用次数', + group_calls INT NOT NULL DEFAULT 0 COMMENT '群聊调用次数', + private_calls INT NOT NULL DEFAULT 0 COMMENT '私聊调用次数', + avg_process_time FLOAT NOT NULL DEFAULT 0 COMMENT '平均处理时间(毫秒)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_plugin_command_date (plugin_name, command, stat_date), + INDEX idx_stat_date (stat_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件统计汇总表'; + +-- 用户使用统计表 +CREATE TABLE IF NOT EXISTS t_user_stats ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id VARCHAR(50) NOT NULL COMMENT '用户ID', + plugin_name VARCHAR(50) NOT NULL COMMENT '插件名称', + command VARCHAR(50) NOT NULL COMMENT '触发的命令', + total_calls INT NOT NULL DEFAULT 0 COMMENT '总调用次数', + success_calls INT NOT NULL DEFAULT 0 COMMENT '成功调用次数', + failed_calls INT NOT NULL DEFAULT 0 COMMENT '失败调用次数', + first_used_at DATETIME NOT NULL COMMENT '首次使用时间', + last_used_at DATETIME NOT NULL COMMENT '最后使用时间', + UNIQUE KEY uk_user_plugin_command (user_id, plugin_name, command), + INDEX idx_user_id (user_id), + INDEX idx_last_used_at (last_used_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户使用统计表'; + +-- 群组使用统计表 +CREATE TABLE IF NOT EXISTS t_group_stats ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + group_id VARCHAR(50) NOT NULL COMMENT '群组ID', + plugin_name VARCHAR(50) NOT NULL COMMENT '插件名称', + command VARCHAR(50) NOT NULL COMMENT '触发的命令', + total_calls INT NOT NULL DEFAULT 0 COMMENT '总调用次数', + success_calls INT NOT NULL DEFAULT 0 COMMENT '成功调用次数', + failed_calls INT NOT NULL DEFAULT 0 COMMENT '失败调用次数', + unique_users INT NOT NULL DEFAULT 0 COMMENT '唯一用户数', + first_used_at DATETIME NOT NULL COMMENT '首次使用时间', + last_used_at DATETIME NOT NULL COMMENT '最后使用时间', + UNIQUE KEY uk_group_plugin_command (group_id, plugin_name, command), + INDEX idx_group_id (group_id), + INDEX idx_last_used_at (last_used_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='群组使用统计表'; + +-- 错误日志表 +CREATE TABLE IF NOT EXISTS t_error_logs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + plugin_name VARCHAR(50) NOT NULL COMMENT '插件名称', + command VARCHAR(50) NOT NULL COMMENT '触发的命令', + user_id VARCHAR(50) NOT NULL COMMENT '用户ID', + group_id VARCHAR(50) COMMENT '群组ID,私聊为NULL', + error_message TEXT NOT NULL COMMENT '错误信息', + stack_trace TEXT COMMENT '堆栈跟踪', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + INDEX idx_plugin_name (plugin_name), + INDEX idx_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='错误日志表'; \ No newline at end of file diff --git a/sql/init.sql b/db/scripts/init.sql similarity index 100% rename from sql/init.sql rename to db/scripts/init.sql diff --git a/db/stats_db.py b/db/stats_db.py new file mode 100644 index 0000000..9d17703 --- /dev/null +++ b/db/stats_db.py @@ -0,0 +1,540 @@ +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Union, Tuple +from db.base import BaseDBOperator +from db.connection import DBConnectionManager + + +class StatsDBOperator(BaseDBOperator): + """统计数据库操作类""" + + def __init__(self, db_manager: DBConnectionManager): + super().__init__(db_manager) + + def record_plugin_call(self, plugin_name: str, command: str, user_id: str, + group_id: Optional[str], success: bool, + process_time_ms: float) -> bool: + """记录插件调用信息 + + Args: + plugin_name: 插件名称 + command: 触发的命令 + user_id: 用户ID + group_id: 群组ID,私聊为None + success: 是否调用成功 + process_time_ms: 处理时间(毫秒) + + Returns: + 是否成功记录 + """ + # 1. 更新插件统计汇总表 + self._update_plugin_stats(plugin_name, command, success, process_time_ms, + True if group_id else False) + + # 2. 更新用户使用统计表 + self._update_user_stats(user_id, plugin_name, command, success) + + # 3. 如果是群聊,更新群组使用统计表 + if group_id: + self._update_group_stats(group_id, plugin_name, command, user_id, success) + + return True + + def record_error(self, plugin_name: str, command: str, user_id: str, + group_id: Optional[str], error_message: str, + stack_trace: Optional[str] = None) -> bool: + """记录错误信息 + + Args: + plugin_name: 插件名称 + command: 触发的命令 + user_id: 用户ID + group_id: 群组ID,私聊为None + error_message: 错误信息 + stack_trace: 堆栈跟踪 + + Returns: + 是否成功记录 + """ + sql = """ + INSERT INTO t_error_logs + (plugin_name, command, user_id, group_id, error_message, stack_trace) + VALUES (%s, %s, %s, %s, %s, %s) + """ + params = (plugin_name, command, user_id, group_id, error_message, stack_trace) + return self.execute_update(sql, params) + + def _update_plugin_stats(self, plugin_name: str, command: str, success: bool, + process_time_ms: float, is_group: bool) -> bool: + """更新插件统计汇总表 + + Args: + plugin_name: 插件名称 + command: 触发的命令 + success: 是否调用成功 + process_time_ms: 处理时间(毫秒) + is_group: 是否群聊 + + Returns: + 是否成功更新 + """ + today = datetime.now().strftime("%Y-%m-%d") + + # 先查询当前记录 + query_sql = """ + SELECT id, total_calls, success_calls, failed_calls, group_calls, + private_calls, avg_process_time + FROM t_plugin_stats + WHERE plugin_name = %s AND command = %s AND stat_date = %s + """ + query_params = (plugin_name, command, today) + result = self.execute_query(query_sql, query_params, fetch_one=True) + + if result: + # 更新现有记录 + total_calls = result['total_calls'] + 1 + success_calls = result['success_calls'] + (1 if success else 0) + failed_calls = result['failed_calls'] + (0 if success else 1) + group_calls = result['group_calls'] + (1 if is_group else 0) + private_calls = result['private_calls'] + (0 if is_group else 1) + + # 计算新的平均处理时间 + old_avg = result['avg_process_time'] + new_avg = ((old_avg * (total_calls - 1)) + process_time_ms) / total_calls + + update_sql = """ + UPDATE t_plugin_stats + SET total_calls = %s, + success_calls = %s, + failed_calls = %s, + group_calls = %s, + private_calls = %s, + avg_process_time = %s + WHERE id = %s + """ + update_params = (total_calls, success_calls, failed_calls, group_calls, + private_calls, new_avg, result['id']) + return self.execute_update(update_sql, update_params) + else: + # 插入新记录 + insert_sql = """ + INSERT INTO t_plugin_stats + (plugin_name, command, stat_date, total_calls, success_calls, + failed_calls, group_calls, private_calls, avg_process_time) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + """ + insert_params = ( + plugin_name, command, today, 1, + 1 if success else 0, + 0 if success else 1, + 1 if is_group else 0, + 0 if is_group else 1, + process_time_ms + ) + return self.execute_update(insert_sql, insert_params) + + def _update_user_stats(self, user_id: str, plugin_name: str, + command: str, success: bool) -> bool: + """更新用户使用统计表 + + Args: + user_id: 用户ID + plugin_name: 插件名称 + command: 触发的命令 + success: 是否调用成功 + + Returns: + 是否成功更新 + """ + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # 查询是否存在记录 + query_sql = """ + SELECT id, total_calls, success_calls, failed_calls, first_used_at + FROM t_user_stats + WHERE user_id = %s AND plugin_name = %s AND command = %s + """ + query_params = (user_id, plugin_name, command) + result = self.execute_query(query_sql, query_params, fetch_one=True) + + if result: + # 更新现有记录 + update_sql = """ + UPDATE t_user_stats + SET total_calls = total_calls + 1, + success_calls = success_calls + %s, + failed_calls = failed_calls + %s, + last_used_at = %s + WHERE id = %s + """ + update_params = (1 if success else 0, 0 if success else 1, now, result['id']) + return self.execute_update(update_sql, update_params) + else: + # 插入新记录 + insert_sql = """ + INSERT INTO t_user_stats + (user_id, plugin_name, command, total_calls, success_calls, + failed_calls, first_used_at, last_used_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """ + insert_params = ( + user_id, plugin_name, command, 1, + 1 if success else 0, + 0 if success else 1, + now, now + ) + return self.execute_update(insert_sql, insert_params) + + def _update_group_stats(self, group_id: str, plugin_name: str, + command: str, user_id: str, success: bool) -> bool: + """更新群组使用统计表 + + Args: + group_id: 群组ID + plugin_name: 插件名称 + command: 触发的命令 + user_id: 用户ID + success: 是否调用成功 + + Returns: + 是否成功更新 + """ + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # 查询是否存在记录 + query_sql = """ + SELECT id, total_calls, success_calls, failed_calls, unique_users, first_used_at + FROM t_group_stats + WHERE group_id = %s AND plugin_name = %s AND command = %s + """ + query_params = (group_id, plugin_name, command) + result = self.execute_query(query_sql, query_params, fetch_one=True) + + # 查询该命令的唯一用户 + user_query_sql = """ + SELECT COUNT(DISTINCT user_id) as user_count + FROM t_user_stats + WHERE plugin_name = %s AND command = %s + """ + user_query_params = (plugin_name, command) + user_result = self.execute_query(user_query_sql, user_query_params, fetch_one=True) + unique_users = user_result['user_count'] if user_result else 1 + + if result: + # 更新现有记录 + update_sql = """ + UPDATE t_group_stats + SET total_calls = total_calls + 1, + success_calls = success_calls + %s, + failed_calls = failed_calls + %s, + unique_users = %s, + last_used_at = %s + WHERE id = %s + """ + update_params = (1 if success else 0, 0 if success else 1, + unique_users, now, result['id']) + return self.execute_update(update_sql, update_params) + else: + # 插入新记录 + insert_sql = """ + INSERT INTO t_group_stats + (group_id, plugin_name, command, total_calls, success_calls, + failed_calls, unique_users, first_used_at, last_used_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + """ + insert_params = ( + group_id, plugin_name, command, 1, + 1 if success else 0, + 0 if success else 1, + unique_users, now, now + ) + return self.execute_update(insert_sql, insert_params) + + def get_plugin_stats(self, days: int = 7) -> List[Dict]: + """获取插件使用统计 + + Args: + days: 统计天数 + + Returns: + 插件统计列表 + """ + sql = """ + SELECT plugin_name, command, + SUM(total_calls) as total_calls, + SUM(success_calls) as success_calls, + SUM(failed_calls) as failed_calls, + AVG(avg_process_time) as avg_process_time + FROM t_plugin_stats + WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY) + GROUP BY plugin_name, command + ORDER BY total_calls DESC + """ + return self.execute_query(sql, (days,)) or [] + + def get_user_stats(self, days: int = 7, limit: int = 20) -> List[Dict]: + """获取用户使用统计 + + Args: + days: 统计天数 + limit: 返回记录数量限制 + + Returns: + 用户统计列表 + """ + sql = """ + SELECT user_id, + SUM(total_calls) as total_calls, + COUNT(DISTINCT plugin_name) as used_plugins + FROM t_user_stats + WHERE last_used_at >= DATE_SUB(CURDATE(), INTERVAL %s DAY) + GROUP BY user_id + ORDER BY total_calls DESC + LIMIT %s + """ + return self.execute_query(sql, (days, limit)) or [] + + def get_group_stats(self, days: int = 7, limit: int = 20) -> List[Dict]: + """获取群组使用统计 + + Args: + days: 统计天数 + limit: 返回记录数量限制 + + Returns: + 群组统计列表 + """ + sql = """ + SELECT group_id, + SUM(total_calls) as total_calls, + COUNT(DISTINCT plugin_name) as used_plugins, + MAX(unique_users) as max_unique_users + FROM t_group_stats + WHERE last_used_at >= DATE_SUB(CURDATE(), INTERVAL %s DAY) + GROUP BY group_id + ORDER BY total_calls DESC + LIMIT %s + """ + return self.execute_query(sql, (days, limit)) or [] + + def get_error_logs(self, days: int = 7, limit: int = 100) -> List[Dict]: + """获取错误日志 + + Args: + days: 统计天数 + limit: 返回记录数量限制 + + Returns: + 错误日志列表 + """ + sql = """ + SELECT id, plugin_name, command, user_id, group_id, + error_message, stack_trace, created_at + FROM t_error_logs + WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL %s DAY) + ORDER BY created_at DESC + LIMIT %s + """ + return self.execute_query(sql, (days, limit)) or [] + + def get_error_detail(self, error_id: int) -> Optional[Dict]: + """获取错误详情 + + Args: + error_id: 错误ID + + Returns: + 错误详情 + """ + sql = """ + SELECT id, plugin_name, command, user_id, group_id, + error_message, stack_trace, created_at + FROM t_error_logs + WHERE id = %s + """ + return self.execute_query(sql, (error_id,), fetch_one=True) + + def get_dashboard_summary(self, days: int = 7) -> Dict: + """获取仪表盘摘要数据 + + Args: + days: 统计天数 + + Returns: + 仪表盘摘要数据 + """ + # 获取时间范围 + end_date = datetime.now() + start_date = end_date - timedelta(days=days) + start_date_str = start_date.strftime("%Y-%m-%d") + + # 1. 总调用次数 + total_calls_sql = """ + SELECT SUM(total_calls) as total_calls + FROM t_plugin_stats + WHERE stat_date >= %s + """ + total_calls_result = self.execute_query(total_calls_sql, (start_date_str,), fetch_one=True) + total_calls = total_calls_result['total_calls'] if total_calls_result and total_calls_result['total_calls'] else 0 + + # 2. 成功率 + success_rate_sql = """ + SELECT SUM(success_calls) as success_calls, SUM(total_calls) as total_calls + FROM t_plugin_stats + WHERE stat_date >= %s + """ + success_rate_result = self.execute_query(success_rate_sql, (start_date_str,), fetch_one=True) + success_rate = 0 + if success_rate_result and success_rate_result['total_calls']: + success_rate = (success_rate_result['success_calls'] / success_rate_result['total_calls']) * 100 + + # 3. 活跃用户数 + active_users_sql = """ + SELECT COUNT(DISTINCT user_id) as active_users + FROM t_user_stats + WHERE last_used_at >= %s + """ + active_users_result = self.execute_query(active_users_sql, (start_date_str,), fetch_one=True) + active_users = active_users_result['active_users'] if active_users_result else 0 + + # 4. 活跃群组数 + active_groups_sql = """ + SELECT COUNT(DISTINCT group_id) as active_groups + FROM t_group_stats + WHERE last_used_at >= %s + """ + active_groups_result = self.execute_query(active_groups_sql, (start_date_str,), fetch_one=True) + active_groups = active_groups_result['active_groups'] if active_groups_result else 0 + + # 5. 错误数量 + error_count_sql = """ + SELECT COUNT(*) as error_count + FROM t_error_logs + WHERE created_at >= %s + """ + error_count_result = self.execute_query(error_count_sql, (start_date_str,), fetch_one=True) + error_count = error_count_result['error_count'] if error_count_result else 0 + + # 6. 平均响应时间 + avg_response_time_sql = """ + SELECT AVG(avg_process_time) as avg_response_time + FROM t_plugin_stats + WHERE stat_date >= %s + """ + avg_response_time_result = self.execute_query(avg_response_time_sql, (start_date_str,), fetch_one=True) + avg_response_time = avg_response_time_result['avg_response_time'] if avg_response_time_result and avg_response_time_result['avg_response_time'] else 0 + + # 7. 最常用的插件 + top_plugins_sql = """ + SELECT plugin_name, SUM(total_calls) as total_calls + FROM t_plugin_stats + WHERE stat_date >= %s + GROUP BY plugin_name + ORDER BY total_calls DESC + LIMIT 5 + """ + top_plugins = self.execute_query(top_plugins_sql, (start_date_str,)) or [] + + # 8. 最活跃的用户 + top_users_sql = """ + SELECT user_id, SUM(total_calls) as total_calls + FROM t_user_stats + WHERE last_used_at >= %s + GROUP BY user_id + ORDER BY total_calls DESC + LIMIT 5 + """ + top_users = self.execute_query(top_users_sql, (start_date_str,)) or [] + + # 9. 最活跃的群组 + top_groups_sql = """ + SELECT group_id, SUM(total_calls) as total_calls + FROM t_group_stats + WHERE last_used_at >= %s + GROUP BY group_id + ORDER BY total_calls DESC + LIMIT 5 + """ + top_groups = self.execute_query(top_groups_sql, (start_date_str,)) or [] + + # 返回汇总数据 + return { + "total_calls": total_calls, + "success_rate": success_rate, + "active_users": active_users, + "active_groups": active_groups, + "error_count": error_count, + "avg_response_time": avg_response_time, + "top_plugins": top_plugins, + "top_users": top_users, + "top_groups": top_groups + } + + def get_plugin_trend(self, plugin_name: str = "", days: int = 7) -> List[Dict]: + """获取插件使用趋势数据 + + Args: + plugin_name: 插件名称,为空则获取所有插件 + days: 统计天数 + + Returns: + 插件使用趋势数据 + """ + # 获取时间范围内的每一天 + end_date = datetime.now().date() + start_date = end_date - timedelta(days=days-1) # 包含今天,所以减1 + + if plugin_name: + # 获取特定插件的趋势 + sql = """ + SELECT stat_date, SUM(total_calls) as total_calls, + SUM(success_calls) as success_calls, + SUM(failed_calls) as failed_calls + FROM t_plugin_stats + WHERE plugin_name = %s AND stat_date >= %s + GROUP BY stat_date + ORDER BY stat_date + """ + params = (plugin_name, start_date) + else: + # 获取所有插件的趋势 + sql = """ + SELECT stat_date, SUM(total_calls) as total_calls, + SUM(success_calls) as success_calls, + SUM(failed_calls) as failed_calls + FROM t_plugin_stats + WHERE stat_date >= %s + GROUP BY stat_date + ORDER BY stat_date + """ + params = (start_date,) + + results = self.execute_query(sql, params) or [] + + # 将结果转换为按日期的字典 + trend_by_date = {r['stat_date'].strftime('%Y-%m-%d'): r for r in results} + + # 确保每一天都有数据 + trend_data = [] + current_date = start_date + while current_date <= end_date: + date_str = current_date.strftime('%Y-%m-%d') + if date_str in trend_by_date: + data = trend_by_date[date_str] + trend_data.append({ + 'date': date_str, + 'total_calls': data['total_calls'], + 'success_calls': data['success_calls'], + 'failed_calls': data['failed_calls'], + 'success_rate': (data['success_calls'] / data['total_calls'] * 100) if data['total_calls'] > 0 else 0 + }) + else: + trend_data.append({ + 'date': date_str, + 'total_calls': 0, + 'success_calls': 0, + 'failed_calls': 0, + 'success_rate': 0 + }) + current_date += timedelta(days=1) + + return trend_data \ No newline at end of file diff --git a/event_system/__init__.py b/event_system/__init__.py new file mode 100644 index 0000000..fcbb619 --- /dev/null +++ b/event_system/__init__.py @@ -0,0 +1 @@ +# 事件系统包初始化文件 \ No newline at end of file diff --git a/event_system/event_manager.py b/event_system/event_manager.py new file mode 100644 index 0000000..57d848c --- /dev/null +++ b/event_system/event_manager.py @@ -0,0 +1,59 @@ +import logging +from typing import Dict, List, Type, Callable, Any +from threading import Lock + +class Event: + """事件基类""" + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + +class EventManager: + """事件管理器,单例模式""" + _instance = None + _lock = Lock() + + @classmethod + def get_instance(cls): + """获取事件管理器实例""" + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def __init__(self): + if EventManager._instance is not None: + raise RuntimeError("EventManager 是单例类,请使用 get_instance() 方法获取实例") + + self.handlers: Dict[Type[Event], List[Callable]] = {} + self.logger = logging.getLogger("EventManager") + + def register(self, event_type: Type[Event], handler: Callable) -> None: + """注册事件处理器""" + if event_type not in self.handlers: + self.handlers[event_type] = [] + + if handler not in self.handlers[event_type]: + self.handlers[event_type].append(handler) + self.logger.debug(f"注册事件处理器: {event_type.__name__} -> {handler.__name__}") + + def unregister(self, event_type: Type[Event], handler: Callable) -> None: + """取消注册事件处理器""" + if event_type in self.handlers and handler in self.handlers[event_type]: + self.handlers[event_type].remove(handler) + self.logger.debug(f"取消注册事件处理器: {event_type.__name__} -> {handler.__name__}") + + def publish(self, event_type: Type[Event], event_data: Dict[str, Any] = None) -> None: + """发布事件""" + if event_data is None: + event_data = {} + + event = event_type(**event_data) + + if event_type in self.handlers: + for handler in self.handlers[event_type]: + try: + handler(event) + except Exception as e: + self.logger.error(f"事件处理器 {handler.__name__} 处理 {event_type.__name__} 事件出错: {e}") \ No newline at end of file diff --git a/event_system/events/plugin_events.py b/event_system/events/plugin_events.py new file mode 100644 index 0000000..49511ad --- /dev/null +++ b/event_system/events/plugin_events.py @@ -0,0 +1,74 @@ +from event_system.event_manager import Event +from typing import Optional, Dict, Any + + +class PluginCallStartEvent(Event): + """插件调用开始事件""" + def __init__(self, plugin_name: str, command: str, user_id: str, + group_id: Optional[str] = None, **kwargs): + """ + Args: + plugin_name: 插件名称 + command: 触发的命令 + user_id: 用户ID + group_id: 群组ID,私聊为None + """ + super().__init__( + plugin_name=plugin_name, + command=command, + user_id=user_id, + group_id=group_id, + **kwargs + ) + + +class PluginCallEndEvent(Event): + """插件调用结束事件""" + def __init__(self, plugin_name: str, command: str, user_id: str, + group_id: Optional[str], success: bool, + process_time_ms: float, result: Any = None, **kwargs): + """ + Args: + plugin_name: 插件名称 + command: 触发的命令 + user_id: 用户ID + group_id: 群组ID,私聊为None + success: 是否调用成功 + process_time_ms: 处理时间(毫秒) + result: 处理结果 + """ + super().__init__( + plugin_name=plugin_name, + command=command, + user_id=user_id, + group_id=group_id, + success=success, + process_time_ms=process_time_ms, + result=result, + **kwargs + ) + + +class PluginErrorEvent(Event): + """插件错误事件""" + def __init__(self, plugin_name: str, command: str, user_id: str, + group_id: Optional[str], error_message: str, + stack_trace: Optional[str] = None, **kwargs): + """ + Args: + plugin_name: 插件名称 + command: 触发的命令 + user_id: 用户ID + group_id: 群组ID,私聊为None + error_message: 错误信息 + stack_trace: 堆栈跟踪 + """ + super().__init__( + plugin_name=plugin_name, + command=command, + user_id=user_id, + group_id=group_id, + error_message=error_message, + stack_trace=stack_trace, + **kwargs + ) \ No newline at end of file diff --git a/event_system/events/stats_events.py b/event_system/events/stats_events.py new file mode 100644 index 0000000..3e7377f --- /dev/null +++ b/event_system/events/stats_events.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass +from typing import Optional +from datetime import datetime + +from event_system.event import Event + + +@dataclass +class PluginCallStartEvent(Event): + """插件调用开始事件""" + plugin_name: str + command: str + user_id: str + group_id: Optional[str] + start_time: datetime + + +@dataclass +class PluginCallEndEvent(Event): + """插件调用结束事件""" + plugin_name: str + command: str + user_id: str + group_id: Optional[str] + start_time: datetime + end_time: datetime + success: bool + response: Optional[str] + + +@dataclass +class PluginCallErrorEvent(Event): + """插件调用错误事件""" + plugin_name: str + command: str + user_id: str + group_id: Optional[str] + start_time: datetime + error_message: str + stack_trace: Optional[str] \ No newline at end of file diff --git a/plugin_common/plugin_registry.py b/plugin_common/plugin_registry.py index 8b08c92..4728168 100644 --- a/plugin_common/plugin_registry.py +++ b/plugin_common/plugin_registry.py @@ -91,4 +91,4 @@ class PluginRegistry: Returns: 插件列表 """ - return [p for p in self._plugins.values() if isinstance(p, plugin_type)] \ No newline at end of file + return [p for p in self._plugins.values() if isinstance(p, plugin_type)] diff --git a/plugins/stats_collector/__init__.py b/plugins/stats_collector/__init__.py new file mode 100644 index 0000000..5c15c03 --- /dev/null +++ b/plugins/stats_collector/__init__.py @@ -0,0 +1,7 @@ +from plugins.stats_collector.main import StatsCollectorPlugin + +def get_plugin(): + """获取插件实例""" + return StatsCollectorPlugin() + +__all__ = ['StatsCollectorPlugin', 'get_plugin'] \ No newline at end of file diff --git a/plugins/stats_collector/config.yaml b/plugins/stats_collector/config.yaml new file mode 100644 index 0000000..370e57a --- /dev/null +++ b/plugins/stats_collector/config.yaml @@ -0,0 +1,21 @@ +# 统计收集器插件配置 +name: stats_collector +description: 插件使用统计收集器 +version: 1.0.0 +author: Trae AI +enabled: true + +# 统计设置 +settings: + # 是否记录详细日志 + debug_logging: false + + # 统计保留天数 + retention_days: 90 + + # 是否统计系统插件 + include_system_plugins: true + + # 排除的插件列表 + excluded_plugins: + - stats_collector \ No newline at end of file diff --git a/plugins/stats_collector/decorators.py b/plugins/stats_collector/decorators.py new file mode 100644 index 0000000..868bec5 --- /dev/null +++ b/plugins/stats_collector/decorators.py @@ -0,0 +1,75 @@ +import functools +import traceback +from datetime import datetime +from typing import Callable, Dict, Any, Tuple + +from event_system.event_manager import EventManager +from event_system.events.stats_events import PluginCallStartEvent, PluginCallEndEvent, PluginCallErrorEvent + + +def plugin_stats_decorator(plugin_name: str) -> Callable: + """插件统计装饰器 + + Args: + plugin_name: 插件名称 + + Returns: + 装饰器函数 + """ + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, message: Dict[str, Any]) -> Tuple[bool, str]: + # 获取事件管理器 + event_manager = EventManager.get_instance() + + # 提取消息信息 + content = message.get("content", "") + sender = message.get("sender", "") + roomid = message.get("roomid", "") + + # 发布插件调用开始事件 + start_time = datetime.now() + event_manager.publish(PluginCallStartEvent, { + "plugin_name": plugin_name, + "command": content, + "user_id": sender, + "group_id": roomid, + "start_time": start_time + }) + + try: + # 调用原始方法 + success, response = func(self, message) + + # 发布插件调用结束事件 + end_time = datetime.now() + event_manager.publish(PluginCallEndEvent, { + "plugin_name": plugin_name, + "command": content, + "user_id": sender, + "group_id": roomid, + "start_time": start_time, + "end_time": end_time, + "success": success, + "response": response + }) + + return success, response + except Exception as e: + # 发布插件调用错误事件 + event_manager.publish(PluginCallErrorEvent, { + "plugin_name": plugin_name, + "command": content, + "user_id": sender, + "group_id": roomid, + "start_time": start_time, + "error_message": str(e), + "stack_trace": traceback.format_exc() + }) + + # 重新抛出异常,让上层处理 + raise + + return wrapper + + return decorator \ No newline at end of file diff --git a/plugins/stats_collector/main.py b/plugins/stats_collector/main.py new file mode 100644 index 0000000..cc10c08 --- /dev/null +++ b/plugins/stats_collector/main.py @@ -0,0 +1,136 @@ +import logging +import time +from typing import Dict, Any, Tuple, Optional +from datetime import datetime + +from plugin_common.plugin_interface import PluginInterface +from event_system.event_manager import EventManager +# 修正导入,使用与装饰器相同的事件类型 +from event_system.events.plugin_events import PluginCallStartEvent, PluginCallEndEvent, PluginCallErrorEvent +from db.stats_db import StatsDBOperator +from db.db_manager import DBConnectionManager +from job_decorators import register_job_decorator + +from .decorators import plugin_stats_decorator + + +class StatsCollectorPlugin(PluginInterface): + """统计收集插件""" + + def __init__(self): + self.name = "统计收集器" + self.version = "1.0.0" + self.description = "收集插件调用统计数据" + self.author = "Trae AI" + self.logger = logging.getLogger("StatsCollector") + + # 默认配置 + self.config = { + "enable": True, + "record_all_plugins": True, # 是否记录所有插件的调用 + "excluded_plugins": [], # 排除的插件列表 + } + + self.event_manager = EventManager.get_instance() + # 修正获取数据库连接管理器的方式 + self.db_manager = DBConnectionManager.get_instance() + self.stats_db = StatsDBOperator(self.db_manager) + + # 用于临时存储插件调用开始时间的字典 + self.plugin_call_start_times = {} + + def initialize(self, config: Dict[str, Any]) -> bool: + """初始化插件""" + if config: + self.config.update(config) + + if not self.config["enable"]: + self.logger.info("统计收集插件已禁用") + return False + + # 注册事件处理器 + self.event_manager.register(PluginCallStartEvent, self.handle_plugin_call_start) + self.event_manager.register(PluginCallEndEvent, self.handle_plugin_call_end) + self.event_manager.register(PluginCallErrorEvent, self.handle_plugin_error) + + self.logger.info("统计收集插件已初始化") + return True + + def handle_plugin_call_start(self, event: PluginCallStartEvent) -> None: + """处理插件调用开始事件""" + # 检查是否需要记录该插件 + if not self._should_record_plugin(event.plugin_name): + return + + # 记录开始时间和相关信息 + # 注意:plugin_events.py 中的事件结构与 stats_events.py 不同 + self.logger.debug(f"记录插件调用开始: {event.plugin_name} - {event.command}") + + def handle_plugin_call_end(self, event: PluginCallEndEvent) -> None: + """处理插件调用结束事件""" + # 检查是否需要记录该插件 + if not self._should_record_plugin(event.plugin_name): + return + + # 记录统计数据 + try: + self.stats_db.record_plugin_call( + plugin_name=event.plugin_name, + command=event.command, + user_id=event.user_id, + group_id=event.group_id, + success=event.process_result, # 注意字段名不同 + process_time_ms=event.process_time # 注意字段名不同 + ) + self.logger.debug(f"记录插件调用结束: {event.plugin_name} - {event.command} - 成功: {event.process_result} - 处理时间: {event.process_time}ms") + except Exception as e: + self.logger.error(f"记录插件调用统计数据出错: {e}") + + def handle_plugin_error(self, event: PluginCallErrorEvent) -> None: + """处理插件调用错误事件""" + # 检查是否需要记录该插件 + if not self._should_record_plugin(event.plugin_name): + return + + # 记录错误信息 + try: + self.stats_db.record_error( + plugin_name=event.plugin_name, + command=event.command, + user_id=event.user_id, + group_id=event.group_id, + error_message=event.error_message, + stack_trace=event.stack_trace + ) + self.logger.debug(f"记录插件调用错误: {event.plugin_name} - {event.command} - {event.error_message}") + except Exception as e: + self.logger.error(f"记录插件错误信息出错: {e}") + + def _should_record_plugin(self, plugin_name: str) -> bool: + """检查是否应该记录该插件的调用""" + if not self.config["record_all_plugins"]: + return False + + if plugin_name in self.config["excluded_plugins"]: + return False + + return True + + def match_command(self, content: str) -> bool: + """匹配命令""" + # 该插件不处理用户消息 + return False + + def process_message(self, message: Dict[str, Any]) -> Tuple[bool, str]: + """处理消息""" + # 该插件不处理用户消息 + return False, "" + + def shutdown(self) -> None: + """关闭插件""" + # 取消注册事件处理器 + self.event_manager.unregister(PluginCallStartEvent, self.handle_plugin_call_start) + self.event_manager.unregister(PluginCallEndEvent, self.handle_plugin_call_end) + self.event_manager.unregister(PluginCallErrorEvent, self.handle_plugin_error) + + self.logger.info("统计收集插件已关闭") \ No newline at end of file diff --git a/plugins/stats_dashboard/__init__.py b/plugins/stats_dashboard/__init__.py new file mode 100644 index 0000000..9cb4659 --- /dev/null +++ b/plugins/stats_dashboard/__init__.py @@ -0,0 +1,7 @@ +from .main import StatsDashboardPlugin + +def get_plugin(): + """获取插件实例""" + return StatsDashboardPlugin() + +__all__ = ['StatsDashboardPlugin', 'get_plugin'] \ No newline at end of file diff --git a/plugins/stats_dashboard/dashboard_server.py b/plugins/stats_dashboard/dashboard_server.py new file mode 100644 index 0000000..75e8b46 --- /dev/null +++ b/plugins/stats_dashboard/dashboard_server.py @@ -0,0 +1,138 @@ +import logging +from typing import Dict, Any, Optional +import threading +import time +import os +from flask import Flask, render_template, request, jsonify, redirect, url_for, session, send_from_directory + +from db.connection import DBConnectionManager +from db.stats_db import StatsDBOperator + + +class DashboardServer: + """统计看板服务器""" + + def __init__(self, host: str = "127.0.0.1", port: int = 8080, + username: str = "admin", password: str = "admin123"): + self.host = host + self.port = port + self.username = username + self.password = password + self.logger = logging.getLogger("DashboardServer") + + # 修正:使用单例模式获取数据库连接 + self.db_manager = DBConnectionManager() + self.stats_db = StatsDBOperator(self.db_manager) + self.app = self._create_app() + self._stop_event = threading.Event() + self._server = None # 添加:存储服务器实例 + + def _create_app(self) -> Flask: + """创建Flask应用""" + app = Flask(__name__) + app.secret_key = "stats_dashboard_secret_key" + + # 添加:实现基本的身份验证 + def check_auth(): + auth = request.authorization + if not auth or auth.username != self.username or auth.password != self.password: + return False + return True + + # 静态文件目录 + 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) + + @app.route('/') + def index(): + return render_template('index.html') + + @app.route('/plugins') + def plugins(): + return render_template('plugins.html') + + @app.route('/users') + def users_page(): + return render_template('users.html') + + @app.route('/groups') + def groups(): + return render_template('groups.html') + + @app.route('/api/plugin_stats') + def api_plugin_stats(): + days = request.args.get('days', 7, type=int) + stats = self.stats_db.get_plugin_stats(days) + return jsonify({"success": True, "data": stats}) + + @app.route('/api/user_stats') + def api_user_stats(): + days = request.args.get('days', 7, type=int) + limit = request.args.get('limit', 10, type=int) + stats = self.stats_db.get_user_stats(days, limit) + return jsonify({"success": True, "data": stats}) + + @app.route('/api/group_stats') + def api_group_stats(): + days = request.args.get('days', 7, type=int) + limit = request.args.get('limit', 10, type=int) + stats = self.stats_db.get_group_stats(days, limit) + return jsonify({"success": True, "data": stats}) + + @app.route('/api/error_logs') + def api_error_logs(): + days = request.args.get('days', 7, type=int) + limit = request.args.get('limit', 50, type=int) + logs = self.stats_db.get_error_logs(days, limit) + return jsonify({"success": True, "data": logs}) + + @app.route('/api/error_detail/') + def api_error_detail(error_id): + detail = self.stats_db.get_error_detail(error_id) + return jsonify({"success": True, "data": detail}) + + # 修改:添加错误处理的API路由示例 + @app.route('/api/dashboard_summary') + def api_dashboard_summary(): + try: + days = request.args.get('days', 7, type=int) + summary = self.stats_db.get_dashboard_summary(days) + return jsonify({"success": True, "data": summary}) + except Exception as e: + self.logger.error(f"获取仪表盘摘要数据出错: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + @app.route('/api/plugin_trend') + def api_plugin_trend(): + days = request.args.get('days', 7, type=int) + plugin_name = request.args.get('plugin_name', '') + trend = self.stats_db.get_plugin_trend(plugin_name, days) + return jsonify({"success": True, "data": trend}) + + return app + + def run(self) -> None: + """运行服务器""" + try: + self.logger.info(f"启动统计看板服务器,地址: {self.host}:{self.port}") + # 修改:使用线程安全的方式运行服务器 + from werkzeug.serving import make_server + self._server = make_server(self.host, self.port, self.app) + self._server.serve_forever() + except Exception as e: + self.logger.error(f"运行统计看板服务器出错: {e}") + + def stop(self) -> None: + """停止服务器""" + try: + self._stop_event.set() + # 修改:使用更可靠的方式停止服务器 + if self._server: + self._server.shutdown() + self.logger.info("统计看板服务器已停止") + except Exception as e: + self.logger.error(f"停止统计看板服务器出错: {e}") + raise diff --git a/plugins/stats_dashboard/main.py b/plugins/stats_dashboard/main.py new file mode 100644 index 0000000..8063835 --- /dev/null +++ b/plugins/stats_dashboard/main.py @@ -0,0 +1,146 @@ +import logging +import threading +from typing import Dict, Any, Tuple, Optional, List + +from plugin_common.plugin_interface import PluginInterface +from .dashboard_server import DashboardServer + + +class StatsDashboardPlugin(PluginInterface): + """统计看板插件""" + + @property + def name(self) -> str: + return "统计看板" + + @property + def version(self) -> str: + return "1.0.0" + + @property + def description(self) -> str: + return "提供插件使用统计数据的可视化界面" + + @property + def author(self) -> str: + return "Trae AI" + + @property + def command_prefix(self) -> Optional[str]: + return "" # 不需要前缀,直接匹配命令 + + @property + def commands(self) -> List[str]: + return [] + + def __init__(self): + self.logger = logging.getLogger("StatsDashboard") + + # 默认配置 + self.config = { + "enable": True, + "host": "127.0.0.1", + "port": 8080, + "username": "admin", + "password": "admin123", + "auto_start": True + } + + self.server = None + self.server_thread = None + + def initialize(self, config: Dict[str, Any]) -> bool: + """初始化插件""" + if config: + self.config.update(config) + + if not self.config["enable"]: + self.logger.info("统计看板插件已禁用") + return False + + # 创建看板服务器 + self.server = DashboardServer( + host=self.config["host"], + port=self.config["port"], + username=self.config["username"], + password=self.config["password"] + ) + + # 如果配置为自动启动,则启动服务器 + if self.config["auto_start"]: + self.start_server() + + return True + + def start_server(self) -> bool: + """启动看板服务器""" + if self.server_thread and self.server_thread.is_alive(): + self.logger.warning("服务器已经在运行中") + return False + + try: + self.server_thread = threading.Thread(target=self.server.run, daemon=True) + self.server_thread.start() + self.logger.info(f"统计看板服务器已启动,访问地址: http://{self.config['host']}:{self.config['port']}") + return True + except Exception as e: + self.logger.error(f"启动统计看板服务器失败: {e}") + return False + + def stop_server(self) -> bool: + """停止看板服务器""" + if not self.server_thread or not self.server_thread.is_alive(): + self.logger.warning("服务器未运行") + return False + + try: + # 修改:添加超时处理和错误检查 + self.server.stop() + # 等待线程结束,但设置超时 + self.server_thread.join(timeout=5) + + # 检查线程是否真的结束了 + if self.server_thread.is_alive(): + self.logger.warning("服务器停止超时,可能需要手动终止") + return False + + self.logger.info("统计看板服务器已停止") + return True + except Exception as e: + self.logger.error(f"停止统计看板服务器失败: {e}") + return False + + def match_command(self, content: str) -> bool: + """匹配命令""" + return content.strip().startswith("/stats") + + def process_message(self, message: Dict[str, Any]) -> Tuple[bool, str]: + """处理消息""" + # 暂时不启用指令 + return False, "" + # content = str(message.get("content", "")).strip() + # if content == "/stats start": + # if self.start_server(): + # return True, "统计看板服务器已启动" + # else: + # return False, "启动统计看板服务器失败" + # + # elif content == "/stats stop": + # if self.stop_server(): + # return True, "统计看板服务器已停止" + # else: + # return False, "停止统计看板服务器失败" + # + # elif content == "/stats status": + # if self.server_thread and self.server_thread.is_alive(): + # return True, f"统计看板服务器正在运行,访问地址: http://{self.config['host']}:{self.config['port']}" + # else: + # return True, "统计看板服务器未运行" + # + # else: + # return True, f"统计看板命令格式错误,可用命令:\n/stats start - 启动服务器\n/stats stop - 停止服务器\n/stats status - 查看状态" + + def shutdown(self) -> None: + """关闭插件""" + if self.server: + self.stop_server() diff --git a/plugins/stats_dashboard/templates/base.html b/plugins/stats_dashboard/templates/base.html new file mode 100644 index 0000000..da9a0cf --- /dev/null +++ b/plugins/stats_dashboard/templates/base.html @@ -0,0 +1,162 @@ + + + + + + {% block title %}机器人统计看板{% endblock %} + + + + + + + + + + + + {% block head %}{% endblock %} + + +
+
+

机器人统计看板

+
+ +
+ + + + +
+ + + + + + + + + + + + + + 刷新数据 + + + + + + + + {% block content %}{% endblock %} +
+
+
+ + + + + {% block scripts %}{% endblock %} + + \ No newline at end of file diff --git a/plugins/stats_dashboard/templates/errors.html b/plugins/stats_dashboard/templates/errors.html new file mode 100644 index 0000000..404bcf8 --- /dev/null +++ b/plugins/stats_dashboard/templates/errors.html @@ -0,0 +1,129 @@ +{% extends "base.html" %} + +{% block title %}错误日志 - 机器人统计看板{% endblock %} + +{% block content %} + +
+ + + +
+ 错误日志 +
+ + + + + + + + + +
+ + +
+
+
+
+ + + + + {{ errorDetail.plugin_name }} + {{ errorDetail.command }} + {{ errorDetail.user_id }} + {{ errorDetail.group_id || '私聊' }} + {{ errorDetail.error_message }} + {{ errorDetail.created_at }} + +
{{ errorDetail.stack_trace }}
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/plugins/stats_dashboard/templates/groups.html b/plugins/stats_dashboard/templates/groups.html new file mode 100644 index 0000000..0b52dc4 --- /dev/null +++ b/plugins/stats_dashboard/templates/groups.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} + +{% block title %}群组统计 - 机器人统计看板{% endblock %} + +{% block content %} + +
+ + + +
+ 群组活跃度排行 +
+ + + + + + + + + + + + + +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/plugins/stats_dashboard/templates/index.html b/plugins/stats_dashboard/templates/index.html new file mode 100644 index 0000000..dd11f5d --- /dev/null +++ b/plugins/stats_dashboard/templates/index.html @@ -0,0 +1,285 @@ +{% extends "base.html" %} + +{% block title %}首页概览 - 机器人统计看板{% endblock %} + +{% block content %} + +
+ + + +
+ 总调用次数 +
+
+ {{ totalCalls }} +
+
+
+ + +
+ 成功率 +
+
+ {{ successRate }}% +
+
+
+ + +
+ 活跃用户数 +
+
+ {{ activeUsers }} +
+
+
+ + +
+ 活跃群组数 +
+
+ {{ activeGroups }} +
+
+
+
+ + + +
+

插件使用排行

+ +
+
+ +
+

成功率分析

+ +
+
+
+ + + +
+

使用趋势

+ +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/plugins/stats_dashboard/templates/plugins.html b/plugins/stats_dashboard/templates/plugins.html new file mode 100644 index 0000000..4ba3769 --- /dev/null +++ b/plugins/stats_dashboard/templates/plugins.html @@ -0,0 +1,141 @@ +{% extends "base.html" %} + +{% block title %}插件统计 - 机器人统计看板{% endblock %} + +{% block content %} + +
+ + + +
+ 插件使用统计 +
+ + + + + + + + + + + + + + + +
+
+
+ + + +
+

{{ selectedPlugin ? selectedPlugin.plugin_name : '' }} 使用趋势

+ +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/plugins/stats_dashboard/templates/users.html b/plugins/stats_dashboard/templates/users.html new file mode 100644 index 0000000..9c9b3fc --- /dev/null +++ b/plugins/stats_dashboard/templates/users.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} + +{% block title %}用户统计 - 机器人统计看板{% endblock %} + +{% block content %} + +
+ + + +
+ 用户活跃度排行 +
+ + + + + + + + + + + + +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/robot.py b/robot.py index cd871a8..ef2c2f5 100644 --- a/robot.py +++ b/robot.py @@ -586,6 +586,43 @@ class Robot(Job): self.LOG.error(f"插件 {plugin.name} 处理消息失败: {e}") return False + # + # def _start_stats_dashboard(self) -> None: + # """启动统计看板服务器""" + # try: + # # 检查是否已加载统计看板插件 + # if 'stats_dashboard' in self.plugins: + # dashboard_plugin = self.plugins['stats_dashboard'] + # if hasattr(dashboard_plugin, 'start_server'): + # dashboard_plugin.start_server() + # self.LOG.info("统计看板服务器已启动") + # # 获取本地IP地址 + # import socket + # hostname = socket.gethostname() + # local_ip = socket.gethostbyname(hostname) + # + # # 获取配置信息 + # if hasattr(dashboard_plugin, 'config'): + # host = dashboard_plugin.config.get("host", "127.0.0.1") + # port = dashboard_plugin.config.get("port", 8080) + # username = dashboard_plugin.config.get("username", "admin") + # password = dashboard_plugin.config.get("password", "admin123") + # + # # 在控制台输出访问信息 + # print("\n" + "="*50) + # print("统计看板服务器已启动") + # print(f"本地访问地址: http://127.0.0.1:{port}") + # print(f"局域网访问地址: http://{local_ip}:{port}") + # print(f"用户名: {username}") + # print(f"密码: {password}") + # print("="*50 + "\n") + # else: + # self.LOG.warning("统计看板插件未实现start_server方法") + # else: + # self.LOG.warning("未找到统计看板插件,请确保插件已正确安装") + # except Exception as e: + # self.LOG.error(f"启动统计看板服务器出错: {e}") + # # ============================================== 业务内容========================================================== @scheduled_job(cron="0 0 8 * * *", name="每日新闻推送") diff --git a/优化需求.md b/优化需求.md new file mode 100644 index 0000000..01a6413 --- /dev/null +++ b/优化需求.md @@ -0,0 +1,197 @@ +# 插件使用统计功能设计思路 + +为了实现对用户触发的指令进行统计,我需要设计一个完整的数据收集和分析系统。以下是我的思路整理: + +## 1. 数据收集层 + +### 核心需求 +- 记录每次插件被调用的详细信息 +- 包括调用者、调用时间、调用结果等 +- 尽量不影响现有插件的性能 + +### 实现方式 +1. **装饰器模式**:创建一个装饰器函数,包装插件的`process_message`方法 +2. **钩子函数**:在消息处理前后添加钩子函数,记录调用信息 +3. **中间件**:在消息处理流程中添加统计中间件 + +## 2. 数据存储层 + +### 表结构设计 +- **插件使用记录表**:记录每次调用的原始数据 +- **用户使用统计表**:按用户汇总的使用情况 +- **群组使用统计表**:按群组汇总的使用情况 +- **插件统计汇总表**:按插件和时间维度汇总的数据 +- **错误日志表**:记录插件执行错误的详细信息 + +### 数据字段 +- 基础信息:插件名、命令、用户ID、群组ID等 +- 时间信息:调用时间、处理耗时等 +- 结果信息:成功/失败、返回消息等 +- 统计信息:调用次数、成功率、平均响应时间等 + +## 3. 数据分析层 + +### 统计维度 +- 时间维度:按小时、天、周、月统计 +- 用户维度:用户活跃度、偏好插件等 +- 群组维度:群组活跃度、特征插件等 +- 插件维度:使用频率、成功率、响应时间等 + +### 分析指标 +- 插件使用频率排行 +- 用户活跃度排行 +- 群组活跃度排行 +- 命令成功率分析 +- 响应时间分析 + +## 4. 实现方案 + +### 方案一:基于现有消息处理流程的改造 +1. 在`MessagePluginInterface`中添加统计方法 +2. 修改`Robot`类的消息处理流程,添加统计逻辑 +3. 创建数据库操作类,处理统计数据的存储和查询 + +### 方案二:独立的统计模块 +1. 创建独立的统计插件,通过事件系统监听消息处理事件 +2. 实现数据收集、存储和分析功能 +3. 提供API接口,供Web管理界面调用 + +## 5. 数据可视化 + +### 看板设计 +- 插件使用趋势图 +- 用户活跃度热力图 +- 群组活跃度排行榜 +- 命令成功率饼图 +- 响应时间分布图 + +### 报表功能 +- 定期生成使用报告 +- 异常监控报警 +- 性能分析报告 + +这个设计思路全面考虑了数据收集、存储、分析和可视化的各个环节,可以为后期的看板数据分析提供丰富的数据支持。 + + +# 方案二:独立统计模块实现方案 + +如果采用方案二(独立的统计模块),我们可以创建一个专门的插件来处理统计功能。这种方式的优点是与现有代码耦合度低,可以独立开发和部署。 + +## 项目目录结构 + +``` +d:\learn\WeChatRobot\ +├── plugins\ +│ ├── stats_collector\ # 统计收集插件 +│ │ ├── __init__.py # 插件初始化文件 +│ │ ├── main.py # 插件主入口 +│ │ ├── stats_collector.py # 统计收集核心逻辑 +│ │ ├── event_handlers.py # 事件处理器 +│ │ ├── config.yaml # 插件配置文件 +│ │ └── README.md # 插件说明文档 +│ │ +│ └── stats_dashboard\ # 统计看板插件(可选) +│ ├── __init__.py # 插件初始化文件 +│ ├── main.py # 插件主入口 +│ ├── dashboard_server.py # 看板服务器 +│ ├── templates\ # 前端模板 +│ │ ├── index.html # 主页 +│ │ ├── plugins.html # 插件统计页 +│ │ ├── users.html # 用户统计页 +│ │ └── groups.html # 群组统计页 +│ ├── static\ # 静态资源 +│ │ ├── css\ # 样式文件 +│ │ ├── js\ # JavaScript文件 +│ │ └── img\ # 图片资源 +│ ├── config.yaml # 插件配置文件 +│ └── README.md # 插件说明文档 +│ +├── db\ +│ ├── stats_db.py # 统计数据库操作类 +│ └── models\ +│ ├── plugin_stats.py # 插件使用记录模型 +│ ├── user_stats.py # 用户统计模型 +│ ├── group_stats.py # 群组统计模型 +│ └── error_log.py # 错误日志模型 +│ +├── event_system\ +│ └── events\ +│ ├── plugin_events.py # 添加插件相关事件定义 +│ └── stats_events.py # 统计相关事件定义 +│ +└── web_manager\ # Web管理界面(可选) + ├── api\ + │ └── stats_api.py # 统计数据API + └── templates\ + └── stats_dashboard.html # 统计看板页面 +``` + +## 实现思路 + +### 1. 事件系统扩展 + +在现有的事件系统中添加以下事件类型: + +- `PluginCallStartEvent`: 插件调用开始事件 +- `PluginCallEndEvent`: 插件调用结束事件 +- `PluginCallErrorEvent`: 插件调用错误事件 + +### 2. 统计收集插件 + +`stats_collector` 插件负责: + +- 监听插件调用相关事件 +- 收集调用信息(插件名、命令、用户、群组、时间等) +- 计算处理时间 +- 将数据存储到数据库 + +### 3. 数据库模型 + +创建以下数据库表: + +- `t_plugin_stats`: 记录每次插件调用的详细信息 +- `t_user_stats`: 按用户汇总的统计信息 +- `t_group_stats`: 按群组汇总的统计信息 +- `t_plugin_stats`: 按插件汇总的统计信息 +- `t_error_logs`: 记录插件执行错误的详细信息 + +### 4. 机器人核心修改 + +在 `Robot` 类的消息处理流程中添加事件触发点: + +- 在调用插件前触发 `PluginCallStartEvent` +- 在调用插件后触发 `PluginCallEndEvent` +- 在捕获到异常时触发 `PluginCallErrorEvent` + +### 5. 统计看板插件(可选) + +`stats_dashboard` 插件负责: + +- 启动一个轻量级Web服务器 +- 提供统计数据的可视化界面 +- 支持多种维度的数据查询和展示 + +### 6. Web管理界面集成(可选) + +在现有的Web管理界面中添加统计看板页面,展示: + +- 插件使用趋势图 +- 用户活跃度排行 +- 群组活跃度排行 +- 命令成功率分析 +- 响应时间分析 + +## 工作流程 + +1. 用户发送消息 +2. 机器人处理消息,找到匹配的插件 +3. 触发 `PluginCallStartEvent` 事件 +4. `stats_collector` 插件记录开始时间和基本信息 +5. 插件处理消息 +6. 触发 `PluginCallEndEvent` 事件 +7. `stats_collector` 插件计算处理时间,记录结果 +8. 数据存储到数据库 +9. 统计看板实时更新数据 + +这种实现方式可以最小化对现有代码的修改,同时提供全面的统计功能。 +