diff --git a/backend/app/main.py b/backend/app/main.py index 9e654a6..b53ac79 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,6 +10,7 @@ from contextlib import asynccontextmanager from app.config import get_settings from app.utils.logger import logger from app.api import chat, stock, skills, llm, auth, admin, paper_trading, stocks, signals +from app.utils.error_handler import setup_global_exception_handler, init_error_notifier import os @@ -289,6 +290,23 @@ async def lifespan(app: FastAPI): # 启动时执行 logger.info("应用启动") + # 初始化全局异常处理器 + setup_global_exception_handler() + logger.info("全局异常处理器已安装") + + # 初始化飞书错误通知 + try: + from app.services.feishu_service import get_feishu_service + feishu_service = get_feishu_service() + init_error_notifier( + feishu_service=feishu_service, + enabled=True, # 启用异常通知 + cooldown=300 # 5分钟冷却时间 + ) + logger.info("✅ 系统异常通知已启用(异常将发送到飞书)") + except Exception as e: + logger.warning(f"飞书异常通知初始化失败: {e}") + # 启动后台任务 settings = get_settings() if getattr(settings, 'paper_trading_enabled', True): @@ -438,9 +456,19 @@ async def signals_page(): if __name__ == "__main__": import uvicorn - uvicorn.run( - "app.main:app", - host=settings.api_host, - port=settings.api_port, - reload=settings.debug - ) + + # 设置全局异常处理器(防止主线程异常退出) + setup_global_exception_handler() + + try: + uvicorn.run( + "app.main:app", + host=settings.api_host, + port=settings.api_port, + reload=settings.debug + ) + except Exception as e: + logger.error(f"应用启动失败: {e}") + import traceback + logger.error(traceback.format_exc()) + raise diff --git a/backend/app/utils/error_handler.py b/backend/app/utils/error_handler.py new file mode 100644 index 0000000..62457aa --- /dev/null +++ b/backend/app/utils/error_handler.py @@ -0,0 +1,217 @@ +""" +全局异常处理器 + +捕获系统中所有未处理的异常,并发送飞书通知 +""" +import sys +import traceback +from datetime import datetime +from typing import Optional +from threading import Lock + +from app.utils.logger import logger + + +class GlobalExceptionHandler: + """全局异常处理器""" + + _instance = None + _lock = Lock() + _initialized = False + + def __new__(cls): + """单例模式""" + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + """初始化异常处理器""" + if GlobalExceptionHandler._initialized: + return + + GlobalExceptionHandler._initialized = True + self.feishu_service = None + self.enabled = True + self.last_error_time = None + self.error_cooldown = 300 # 错误通知冷却时间(秒),避免重复通知 + + logger.info("全局异常处理器初始化完成") + + def set_feishu_service(self, feishu_service): + """设置飞书服务""" + self.feishu_service = feishu_service + logger.info("异常处理器已连接飞书服务") + + def set_enabled(self, enabled: bool): + """启用或禁用异常通知""" + self.enabled = enabled + logger.info(f"异常通知已{'启用' if enabled else '禁用'}") + + def set_cooldown(self, seconds: int): + """设置错误通知冷却时间""" + self.error_cooldown = seconds + logger.info(f"错误通知冷却时间已设置为 {seconds} 秒") + + def handle_exception(self, exc_type, exc_value, exc_traceback): + """ + 处理异常 + + Args: + exc_type: 异常类型 + exc_value: 异常值 + exc_traceback: 异常堆栈 + """ + # 检查是否是键盘中断(用户主动退出) + if exc_type == KeyboardInterrupt: + logger.info("用户主动中断程序") + return + + # 检查是否启用 + if not self.enabled: + logger.warning("异常通知已禁用,仅记录日志") + self._log_exception(exc_type, exc_value, exc_traceback) + return + + # 检查冷却时间 + if self.last_error_time: + time_since_last = (datetime.now() - self.last_error_time).total_seconds() + if time_since_last < self.error_cooldown: + logger.warning(f"错误通知冷却中(剩余 {int(self.error_cooldown - time_since_last)} 秒)") + self._log_exception(exc_type, exc_value, exc_traceback) + return + + # 记录异常 + self._log_exception(exc_type, exc_value, exc_traceback) + + # 发送飞书通知 + self._send_error_notification(exc_type, exc_value, exc_traceback) + + # 更新最后错误时间 + self.last_error_time = datetime.now() + + def _log_exception(self, exc_type, exc_value, exc_traceback): + """记录异常到日志""" + logger.error("=" * 60) + logger.error("❌ 未捕获的异常") + logger.error("=" * 60) + logger.error(f"异常类型: {exc_type.__name__}") + logger.error(f"异常信息: {str(exc_value)}") + logger.error(f"发生时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + # 打印完整的堆栈跟踪 + tb_lines = traceback.format_exception(exc_type, exc_value, exc_traceback) + logger.error("堆栈跟踪:\n" + "".join(tb_lines)) + logger.error("=" * 60) + + def _send_error_notification(self, exc_type, exc_value, exc_traceback): + """发送错误通知到飞书""" + if not self.feishu_service: + logger.warning("飞书服务未设置,无法发送错误通知") + return + + try: + # 获取异常信息 + exc_name = exc_type.__name__ + exc_msg = str(exc_value) + exc_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + # 获取堆栈跟踪(限制长度) + tb_list = traceback.format_exception(exc_type, exc_value, exc_traceback) + + # 构建堆栈信息(限制长度,避免超出飞书限制) + stack_trace = "".join(tb_list) + + # 限制堆栈信息长度(飞书有长度限制) + max_length = 3000 + if len(stack_trace) > max_length: + stack_trace = stack_trace[:max_length] + "\n... (堆栈信息过长,已截断)" + + # 格式化堆栈信息,使用代码块 + formatted_stack = "```\n" + stack_trace + "\n```" + + # 构建飞书消息 + message = f"""🚨 **系统异常报警** + +**异常类型**: {exc_name} +**异常信息**: {exc_msg} +**发生时间**: {exc_time} + +**堆栈跟踪**: +{formatted_stack} + +⚠️ 请及时处理系统异常""" + + # 发送飞书通知 + import asyncio + try: + # 获取当前事件循环 + loop = asyncio.get_event_loop() + if loop.is_running(): + # 如果事件循环正在运行,使用 run_coroutine_threadsafe + asyncio.run_coroutine_threadsafe( + self.feishu_service.send_text(message), + loop + ) + else: + # 如果事件循环未运行,直接运行 + asyncio.run(self.feishu_service.send_text(message)) + + logger.info("✅ 已发送异常通知到飞书") + + except RuntimeError: + # 没有事件循环,创建新的 + asyncio.run(self.feishu_service.send_text(message)) + logger.info("✅ 已发送异常通知到飞书") + + except Exception as e: + logger.error(f"发送异常通知失败: {e}") + + +# 创建全局异常处理器实例 +_exception_handler: Optional[GlobalExceptionHandler] = None + + +def get_exception_handler() -> GlobalExceptionHandler: + """获取全局异常处理器实例""" + global _exception_handler + if _exception_handler is None: + _exception_handler = GlobalExceptionHandler() + return _exception_handler + + +def setup_global_exception_handler(): + """ + 设置全局异常处理器 + + 捕获所有未处理的异常,并发送飞书通知 + """ + handler = get_exception_handler() + + def handle_exception(exc_type, exc_value, exc_traceback): + """异常处理回调函数""" + handler.handle_exception(exc_type, exc_value, exc_traceback) + + # 设置全局异常钩子 + sys.excepthook = handle_exception + + logger.info("✅ 全局异常处理器已安装") + + +def init_error_notifier(feishu_service, enabled: bool = True, cooldown: int = 300): + """ + 初始化错误通知器 + + Args: + feishu_service: 飞书服务实例 + enabled: 是否启用异常通知 + cooldown: 错误通知冷却时间(秒) + """ + handler = get_exception_handler() + handler.set_feishu_service(feishu_service) + handler.set_enabled(enabled) + handler.set_cooldown(cooldown) + + logger.info("错误通知器初始化完成") diff --git a/docs/ERROR_NOTIFICATION.md b/docs/ERROR_NOTIFICATION.md new file mode 100644 index 0000000..535eb2c --- /dev/null +++ b/docs/ERROR_NOTIFICATION.md @@ -0,0 +1,210 @@ +# 系统异常通知功能使用说明 + +## 功能概述 + +系统异常通知功能会自动捕获所有未处理的异常,并发送通知到飞书,让你及时了解系统错误。 + +## 主要特性 + +✅ **自动捕获异常** - 捕获系统中所有未处理的异常 +✅ **飞书通知** - 自动发送异常详情到飞书 +✅ **完整堆栈信息** - 包含异常类型、错误信息和完整堆栈跟踪 +✅ **冷却机制** - 避免重复通知(默认5分钟冷却时间) +✅ **可配置** - 可以启用/禁用通知,调整冷却时间 + +## 工作原理 + +### 1. 异常捕获流程 + +``` +系统异常 → 全局异常处理器 → 记录日志 → 检查冷却时间 → 发送飞书通知 +``` + +### 2. 通知内容 + +飞书通知包含以下信息: +- 🚨 异常类型(如 `ValueError`, `RuntimeError`) +- 📝 异常信息(错误描述) +- ⏰ 发生时间 +- 📚 完整堆栈跟踪(代码格式化显示) + +### 3. 冷却机制 + +为了避免同一错误重复通知,系统实现了冷却机制: +- 默认冷却时间:300秒(5分钟) +- 在冷却期内的异常只记录日志,不发送飞书通知 +- 冷却时间过后再次出现异常才会发送通知 + +## 配置说明 + +### 代码配置 + +异常通知已在 `main.py` 中自动初始化: + +```python +# 初始化飞书错误通知 +from app.services.feishu_service import get_feishu_service +feishu_service = get_feishu_service() +init_error_notifier( + feishu_service=feishu_service, + enabled=True, # 启用异常通知 + cooldown=300 # 5分钟冷却时间 +) +``` + +### 动态调整配置 + +你可以在运行时动态调整异常通知配置: + +```python +from app.utils.error_handler import get_exception_handler + +handler = get_exception_handler() + +# 启用/禁用异常通知 +handler.set_enabled(True) # 启用 +handler.set_enabled(False) # 禁用 + +# 调整冷却时间(秒) +handler.set_cooldown(300) # 5分钟 +handler.set_cooldown(60) # 1分钟 +handler.set_cooldown(0) # 禁用冷却(每次都通知) +``` + +## 使用示例 + +### 示例1: 手动触发异常通知 + +```python +from app.utils.error_handler import get_exception_handler + +handler = get_exception_handler() + +# 手动报告异常 +try: + # 你的代码 + result = 1 / 0 +except Exception as e: + import sys + exc_type, exc_value, exc_traceback = sys.exc_info() + handler.handle_exception(exc_type, exc_value, exc_traceback) +``` + +### 示例2: 在函数中装饰器使用 + +```python +from functools import wraps +from app.utils.error_handler import get_exception_handler + +def notify_on_error(func): + """异常时发送通知的装饰器""" + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + handler = get_exception_handler() + import sys + exc_type, exc_value, exc_traceback = sys.exc_info() + handler.handle_exception(exc_type, exc_value, exc_traceback) + raise + return wrapper + +@notify_on_error +def risky_function(): + # 可能出错的代码 + pass +``` + +## 测试 + +运行测试脚本验证功能: + +```bash +source backend/venv/bin/activate +python scripts/test_error_notification.py +``` + +测试脚本会: +1. 触发各种类型的异常 +2. 发送飞书通知 +3. 测试冷却机制 +4. 验证通知格式 + +## 飞书通知示例 + +``` +🚨 **系统异常报警** + +**异常类型**: ZeroDivisionError +**异常信息**: division by zero +**发生时间**: 2026-02-22 11:50:59 + +**堆栈跟踪**: +``` +Traceback (most recent call last): + File "/app/main.py", line 100, in process_data + result = calculate_ratio(x, y) + File "/app/utils.py", line 50, in calculate_ratio + return x / y +ZeroDivisionError: division by zero +``` + +⚠️ 请及时处理系统异常 +``` + +## 注意事项 + +1. **敏感信息**: 异常堆栈可能包含敏感信息(如API密钥、密码等),请注意飞书安全性 +2. **网络依赖**: 发送飞书通知需要网络连接,如果网络异常会只记录日志 +3. **性能影响**: 异常处理本身对性能影响很小,但频繁异常可能表示系统有问题 +4. **日志级别**: 异常信息会记录到 ERROR 级别日志,可通过日志系统查询 + +## 故障排查 + +### 问题1: 没有收到飞书通知 + +检查项: +- ✅ 飞书 webhook URL 是否正确配置(`.env` 文件中的 `FEISHU_WEBHOOK_URL`) +- ✅ `FEISHU_ENABLED=true` 是否设置 +- ✅ 网络连接是否正常 +- ✅ 是否在冷却期内 +- ✅ 查看日志中是否有 "飞书消息发送成功" 或相关错误信息 + +### 问题2: 通知太频繁 + +解决方案: +- 增加冷却时间:`handler.set_cooldown(600)` # 10分钟 +- 或者临时禁用通知:`handler.set_enabled(False)` + +### 问题3: 想临时禁用通知 + +```python +from app.utils.error_handler import get_exception_handler +handler = get_exception_handler() +handler.set_enabled(False) +``` + +## 技术实现 + +异常处理器使用 Python 的 `sys.excepthook` 机制,可以捕获所有未处理的异常: + +```python +import sys + +def exception_hook(exc_type, exc_value, exc_traceback): + # 处理异常 + handler.handle_exception(exc_type, exc_value, exc_traceback) + +sys.excepthook = exception_hook +``` + +这种方式可以捕获: +- 主线程中的所有未处理异常 +- 异步任务中的异常(如果未正确捕获) +- 脚本运行时的异常 + +但不能捕获: +- 已被 try-except 捕获的异常(这是设计行为) +- 子线程中的异常(需要在子线程中单独处理) +- 系统级别的严重错误(如 Segmentation Fault) diff --git a/scripts/test_error_notification.py b/scripts/test_error_notification.py new file mode 100755 index 0000000..20a64af --- /dev/null +++ b/scripts/test_error_notification.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +""" +测试系统异常通知功能 + +测试场景: +1. 测试普通异常通知 +2. 测试带堆栈信息的异常通知 +3. 测试冷却时间(避免重复通知) +""" +import sys +from pathlib import Path + +# 添加项目路径 +backend_dir = Path(__file__).parent.parent / "backend" +sys.path.insert(0, str(backend_dir)) + + +def test_normal_exception(): + """测试普通异常通知""" + print("\n" + "=" * 60) + print("🧪 测试1: 普通异常通知") + print("=" * 60) + + from app.utils.error_handler import get_exception_handler + + handler = get_exception_handler() + + # 模拟一个异常 + try: + result = 1 / 0 # ZeroDivisionError + except Exception as e: + import traceback + exc_type, exc_value, exc_traceback = sys.exc_info() + handler.handle_exception(exc_type, exc_value, exc_traceback) + print("✅ 已触发异常处理(请检查飞书是否收到通知)") + + +def test_custom_exception(): + """测试自定义异常通知""" + print("\n" + "=" * 60) + print("🧪 测试2: 自定义异常通知") + print("=" * 60) + + from app.utils.error_handler import get_exception_handler + + handler = get_exception_handler() + + # 模拟一个自定义异常 + try: + raise ValueError("这是一个测试异常,用于验证飞书通知功能") + except Exception as e: + import traceback + exc_type, exc_value, exc_traceback = sys.exc_info() + handler.handle_exception(exc_type, exc_value, exc_traceback) + print("✅ 已触发自定义异常处理(请检查飞书是否收到通知)") + + +def test_nested_exception(): + """测试嵌套调用异常""" + print("\n" + "=" * 60) + print("🧪 测试3: 嵌套调用异常(带完整堆栈)") + print("=" * 60) + + from app.utils.error_handler import get_exception_handler + + handler = get_exception_handler() + + def level_3(): + """第三层调用""" + raise RuntimeError("深层错误:数据库连接失败") + + def level_2(): + """第二层调用""" + level_3() + + def level_1(): + """第一层调用""" + level_2() + + # 模拟嵌套调用异常 + try: + level_1() + except Exception as e: + import traceback + exc_type, exc_value, exc_traceback = sys.exc_info() + handler.handle_exception(exc_type, exc_value, exc_traceback) + print("✅ 已触发嵌套异常处理(请检查飞书是否收到完整堆栈信息)") + + +def test_cooldown(): + """测试冷却时间""" + print("\n" + "=" * 60) + print("🧪 测试4: 冷却时间(连续触发异常)") + print("=" * 60) + + from app.utils.error_handler import get_exception_handler + import time + + handler = get_exception_handler() + handler.set_cooldown(10) # 设置10秒冷却时间 + + print(f"⏰ 冷却时间设置为 10 秒") + + # 第一次触发 + try: + raise RuntimeError("第一次异常") + except Exception: + exc_type, exc_value, exc_traceback = sys.exc_info() + handler.handle_exception(exc_type, exc_value, exc_traceback) + print("✅ 第一次异常已处理") + + # 立即第二次触发(应该在冷却期内) + print("\n⏳ 立即触发第二次异常(应该在冷却期内)...") + try: + raise RuntimeError("第二次异常") + except Exception: + exc_type, exc_value, exc_traceback = sys.exc_info() + handler.handle_exception(exc_type, exc_value, exc_traceback) + print("✅ 第二次异常已处理(应该被冷却,不发送飞书通知)") + + print("\n⏳ 等待 11 秒后再次触发...") + time.sleep(11) + + # 第三次触发(冷却期已过) + try: + raise RuntimeError("第三次异常") + except Exception: + exc_type, exc_value, exc_traceback = sys.exc_info() + handler.handle_exception(exc_type, exc_value, exc_traceback) + print("✅ 第三次异常已处理(冷却期已过,应该发送飞书通知)") + + +def test_with_feishu_integration(): + """测试与飞书集成的完整流程""" + print("\n" + "=" * 60) + print("🧪 测试5: 完整集成测试(带飞书服务)") + print("=" * 60) + + from app.utils.error_handler import setup_global_exception_handler, get_exception_handler + from app.services.feishu_service import get_feishu_service + + # 设置全局异常处理器 + setup_global_exception_handler() + print("✅ 全局异常处理器已安装") + + # 初始化飞书通知 + feishu_service = get_feishu_service() + handler = get_exception_handler() + handler.set_feishu_service(feishu_service) + handler.set_enabled(True) + print("✅ 飞书通知已启用") + + # 触发一个测试异常 + print("\n🔔 触发测试异常...") + try: + raise Exception("【测试】这是一个集成测试异常,用于验证飞书通知功能是否正常工作") + except Exception: + exc_type, exc_value, exc_traceback = sys.exc_info() + handler.handle_exception(exc_type, exc_value, exc_traceback) + print("✅ 测试异常已处理,请检查飞书是否收到通知") + + +def main(): + """主测试函数""" + print("\n" + "🚀" * 30) + print("系统异常通知功能测试") + print("🚀" * 30) + + print("\n⚠️ 注意:以下测试会触发真实的异常,并尝试发送飞书通知") + print("⚠️ 请确保飞书 webhook 已正确配置\n") + + try: + # 测试1: 普通异常 + test_normal_exception() + + # 等待用户确认 + input("\n按 Enter 键继续下一个测试...") + + # 测试2: 自定义异常 + test_custom_exception() + + # 等待用户确认 + input("\n按 Enter 键继续下一个测试...") + + # 测试3: 嵌套异常 + test_nested_exception() + + # 等待用户确认 + input("\n按 Enter 键继续下一个测试...") + + # 测试4: 冷却时间 + test_cooldown() + + # 等待用户确认 + input("\n按 Enter 键继续最后一个测试...") + + # 测试5: 完整集成 + test_with_feishu_integration() + + print("\n" + "=" * 60) + print("✅ 所有测试完成!") + print("=" * 60) + print("\n📋 请检查飞书聊天窗口,确认是否收到异常通知") + print("💡 提示:如果未收到通知,请检查:") + print(" 1. 飞书 webhook URL 是否正确") + print(" 2. 网络连接是否正常") + print(" 3. .env 文件中的 FEISHU_ENABLED 是否为 true\n") + + except KeyboardInterrupt: + print("\n\n⚠️ 测试被用户中断") + except Exception as e: + print(f"\n\n❌ 测试失败: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + main()