diff --git a/README_企业微信.md b/README_企业微信.md new file mode 100644 index 0000000..efc2ac7 --- /dev/null +++ b/README_企业微信.md @@ -0,0 +1,231 @@ +# 广西政府采购网公告监控系统 - 企业微信集成 + +## 概述 + +本系统已集成企业微信功能,支持: +- 企业微信应用消息推送 +- 回调服务器处理用户交互 +- 应用菜单管理 +- 实时爬取和自定义搜索 + +## 配置要求 + +### 1. 企业微信应用配置 + +在 `config/config.yaml` 中配置企业微信信息: + +```yaml +# 企业微信应用配置 +wechat_app: + enabled: true # 是否启用企业微信通知 + corp_id: "ww69e8e44636f47780" # 企业ID + agent_id: "1000007" # 应用ID + secret: "SmelCwKFoL0E9ATWFzr-w7gsfXBTN72lT1UqnNd0HpI" # 应用Secret + token: "DmvL98cAF6x9CFtQZwqD2emGL8S7HxA" # Token + encoding_aes_key: "yAc4OoSCP92YTefHXYfw27WeG9oF11W9d6nw6QYlU3D" # 消息加密Key + port: 18001 # 服务端口 +``` + +### 2. 企业微信管理后台设置 + +1. **设置回调URL** + - 在企业微信管理后台的应用设置中 + - 设置回调URL为:`http://your-server-ip:18001/api/v1/wechat/callback` + - Token和EncodingAESKey需要与配置文件一致 + +2. **设置应用可见范围** + - 配置应用对哪些部门或成员可见 + - 确保有基础接口权限 + +## 功能说明 + +### 1. 消息推送 + +系统会在以下情况下自动推送消息到企业微信: + +- **爬取完成通知**:每次执行爬取后,推送筛选结果 +- **关键词匹配通知**:发现匹配关键词的公告时推送 +- **系统状态通知**:系统启动、错误等重要事件 + +消息格式参考 `企业微信消息示例.md` + +### 2. 应用菜单 + +系统提供以下菜单功能: + +#### 操作菜单 +- **立即爬取**:执行一次完整的公告爬取 +- **今日总结**:查看今日公告统计(开发中) +- **自定义爬取**:输入关键词进行搜索 + +#### 帮助菜单 +- **使用说明**:显示详细使用指南 +- **联系我们**:联系信息 + +### 3. 文本交互 + +支持以下文本命令: + +``` +# 关键词搜索 +直接发送关键词,如:大化、信息化 + +# 手动爬取 +爬取 [关键词] # 如:爬取 大化 + +# 获取帮助 +帮助 # 显示使用说明 +help + +# 查看总结 +总结 # 查看今日统计 +``` + +## 部署和运行 + +### 1. 安装依赖 + +```bash +pip install -r gx_gp_monitor/requirements.txt +``` + +### 2. 测试功能 + +```bash +# 测试企业微信模块 +python test_wechat_server.py +``` + +### 3. 启动回调服务器 + +```bash +# 方法1:使用专用启动脚本 +python start_wechat_server.py + +# 方法2:使用主程序 +python gx_gp_monitor/main.py wechat-server +``` + +### 4. 管理菜单 + +```bash +# 创建菜单 +python gx_gp_monitor/main.py wechat-menu --action create + +# 删除菜单 +python gx_gp_monitor/main.py wechat-menu --action delete + +# 获取菜单信息 +python gx_gp_monitor/main.py wechat-menu --action get + +# 测试菜单操作 +python gx_gp_monitor/main.py wechat-menu --action test +``` + +### 5. 正常爬取 + +```bash +# 执行爬取(会自动推送消息) +python gx_gp_monitor/main.py crawl +``` + +## 技术实现 + +### 1. 回调服务器 + +- **框架**:Flask +- **端口**:18001 +- **路径**:`/api/v1/wechat/callback` +- **功能**:处理企业微信的回调消息和URL验证 + +### 2. 消息加解密 + +- **库来源**:企业微信官方Python示例 +- **位置**:`gx_gp_monitor/wechat/WXBizMsgCrypt.py` +- **功能**:实现消息加密、解密和签名验证 + +### 3. 消息处理 + +- **事件处理**:关注、取消关注、菜单点击 +- **文本处理**:关键词搜索、手动命令 +- **响应格式**:XML格式的企业微信消息 + +### 4. 菜单管理 + +- **创建菜单**:自动创建预定义菜单 +- **删除菜单**:清理现有菜单 +- **获取菜单**:查看当前菜单配置 + +## 故障排除 + +### 1. 回调URL验证失败 + +- 检查Token和EncodingAESKey是否正确配置 +- 确保服务器可从公网访问 +- 检查端口18001是否开放 + +### 2. 消息推送失败 + +- 检查企业微信应用配置(corp_id, agent_id, secret) +- 确认应用有消息发送权限 +- 查看应用可见范围设置 + +### 3. 菜单创建失败 + +- 检查应用是否有菜单管理权限 +- 确认Token有效且有管理员权限 + +### 4. 导入错误 + +```bash +# 确保依赖已安装 +pip install flask pycryptodome + +# 测试导入 +python -c "from gx_gp_monitor.wechat.callback_server import WeChatCallbackServer; print('OK')" +``` + +## 日志查看 + +系统会记录详细的企业微信操作日志: + +```bash +# 查看日志 +tail -f logs/gx_gp_monitor.log | grep wechat +``` + +## 安全注意事项 + +1. **保护配置文件**:不要将包含密钥的配置文件提交到版本控制 +2. **网络安全**:确保回调服务器只接受来自企业微信的请求 +3. **权限控制**:合理设置应用可见范围和权限 +4. **定期更新**:定期更新企业微信应用Secret + +## 开发说明 + +### 项目结构 + +``` +gx_gp_monitor/wechat/ +├── __init__.py # 模块初始化 +├── callback_server.py # Flask回调服务器 +├── message_handler.py # 消息处理逻辑 +├── menu_manager.py # 菜单管理 +├── WXBizMsgCrypt.py # 企业微信加密库 +└── ierror.py # 错误码定义 +``` + +### 扩展功能 + +如需添加新功能,可以: + +1. 在 `message_handler.py` 中添加新的消息处理逻辑 +2. 在 `menu_manager.py` 中修改菜单配置 +3. 在 `callback_server.py` 中添加新的路由 + +## 联系支持 + +如有问题,请检查: +1. 系统日志 +2. 企业微信管理后台错误信息 +3. 网络连接和配置 diff --git a/gx_gp_monitor/__main__.py b/gx_gp_monitor/__main__.py index 142bcf8..ba76a4b 100644 --- a/gx_gp_monitor/__main__.py +++ b/gx_gp_monitor/__main__.py @@ -2,7 +2,10 @@ 包的main入口,使项目可以直接通过 python -m gx_gp_monitor 运行 """ -from .main import main +try: + from .main import main +except ImportError: + from main import main if __name__ == "__main__": main() diff --git a/gx_gp_monitor/__pycache__/main.cpython-313.pyc b/gx_gp_monitor/__pycache__/main.cpython-313.pyc index fc59984..675526b 100644 Binary files a/gx_gp_monitor/__pycache__/main.cpython-313.pyc and b/gx_gp_monitor/__pycache__/main.cpython-313.pyc differ diff --git a/gx_gp_monitor/main.py b/gx_gp_monitor/main.py index e4d553e..f88620d 100644 --- a/gx_gp_monitor/main.py +++ b/gx_gp_monitor/main.py @@ -23,6 +23,8 @@ try: from .storage.postgresql import init_storage, save_announcements_to_storage, save_all_announcements_by_source_to_storage, cleanup_storage from .storage.md_generator import generate_onu_md from .notification.wechat import send_announcements_notification, send_system_notification + from .wechat.callback_server import get_callback_server + from .wechat.menu_manager import WeChatMenuManager except ImportError: try: # 尝试绝对导入(直接运行脚本时) @@ -34,6 +36,14 @@ except ImportError: from storage.postgresql import init_storage, save_announcements_to_storage, save_all_announcements_by_source_to_storage, cleanup_storage from storage.md_generator import generate_onu_md from notification.wechat import send_announcements_notification, send_system_notification + # 企业微信模块动态导入,避免循环导入问题 + wechat_available = True + try: + from wechat.callback_server import get_callback_server + from wechat.menu_manager import WeChatMenuManager + except ImportError: + wechat_available = False + print("企业微信模块不可用", file=sys.stderr) except ImportError as e: print(f"导入错误: {e}", file=sys.stderr) print("请确保依赖已正确安装: pip install -r requirements.txt", file=sys.stderr) @@ -164,6 +174,61 @@ class GXGPMonitorApp: logger.error(f"数据清理任务执行失败: {str(e)}") return {"success": False, "error": str(e)} + def run_wechat_server(self, host: str = '0.0.0.0', port: int = 18001): + """启动企业微信回调服务器""" + if not wechat_available: + return {"success": False, "error": "企业微信模块不可用"} + + try: + logger.info("启动企业微信回调服务器") + + # 获取回调服务器 + callback_server = get_callback_server() + + # 启动服务器 + callback_server.run(host=host, port=port, debug=self.config.debug) + + return {"success": True, "host": host, "port": port} + + except Exception as e: + logger.error(f"启动企业微信回调服务器失败: {str(e)}") + return {"success": False, "error": str(e)} + + def manage_wechat_menu(self, action: str) -> Dict[str, Any]: + """管理企业微信菜单""" + if not wechat_available: + return {"success": False, "error": "企业微信模块不可用"} + + try: + logger.info(f"执行企业微信菜单操作: {action}") + + menu_manager = WeChatMenuManager() + + if action == 'create': + success = menu_manager.create_menu() + result = {"success": success, "action": "create"} + elif action == 'delete': + success = menu_manager.delete_menu() + result = {"success": success, "action": "delete"} + elif action == 'get': + menu_info = menu_manager.get_menu() + result = {"success": menu_info is not None, "action": "get", "menu": menu_info} + elif action == 'test': + test_results = menu_manager.test_menu_operations() + result = {"success": True, "action": "test", "results": test_results} + else: + result = {"success": False, "error": f"未知操作: {action}"} + + if result["success"]: + logger.info(f"企业微信菜单操作成功: {action}") + else: + logger.error(f"企业微信菜单操作失败: {action}") + + return result + + except Exception as e: + logger.error(f"企业微信菜单管理异常: {str(e)}") + return {"success": False, "error": str(e)} def show_status(self): """显示系统状态""" @@ -212,12 +277,14 @@ def create_argument_parser(): python main.py crawl --keywords "大化" # 爬取指定关键词 python main.py cleanup # 执行数据清理 python main.py status # 查看系统状态 + python main.py wechat-server # 启动企业微信回调服务器 + python main.py wechat-menu --action create # 创建企业微信菜单 """ ) parser.add_argument( 'command', - choices=['crawl', 'cleanup', 'status'], + choices=['crawl', 'cleanup', 'status', 'wechat-server', 'wechat-menu'], help='要执行的命令' ) @@ -252,6 +319,28 @@ def create_argument_parser(): help='清理多少天前的过期数据' ) + # wechat-server命令的参数 + parser.add_argument( + '--host', '-H', + default='0.0.0.0', + help='服务器监听主机地址 (默认: 0.0.0.0)' + ) + + parser.add_argument( + '--port', '-P', + type=int, + default=18001, + help='服务器监听端口 (默认: 18001)' + ) + + # wechat-menu命令的参数 + parser.add_argument( + '--action', '-a', + choices=['create', 'delete', 'get', 'test'], + default='create', + help='菜单操作类型 (默认: create)' + ) + return parser @@ -302,6 +391,36 @@ def main(): # 显示状态 app.show_status() + elif args.command == 'wechat-server': + # 启动企业微信回调服务器 + result = app.run_wechat_server(host=args.host, port=args.port) + if result["success"]: + print(f"✅ 企业微信回调服务器已启动: {result['host']}:{result['port']}") + print(" 回调地址: /api/v1/wechat/callback") + else: + print(f"❌ 企业微信回调服务器启动失败: {result.get('error', '未知错误')}") + sys.exit(1) + + elif args.command == 'wechat-menu': + # 企业微信菜单管理 + result = app.manage_wechat_menu(action=args.action) + if result["success"]: + if args.action == 'create': + print("✅ 企业微信菜单创建成功") + elif args.action == 'delete': + print("✅ 企业微信菜单删除成功") + elif args.action == 'get': + print("✅ 企业微信菜单获取成功") + if result.get("menu"): + print("菜单信息:", json.dumps(result["menu"], indent=2, ensure_ascii=False)) + elif args.action == 'test': + print("✅ 企业微信菜单测试完成") + print("测试结果:", result.get("results")) + else: + error = result.get("error", "未知错误") + print(f"❌ 企业微信菜单操作失败: {error}") + sys.exit(1) + except KeyboardInterrupt: logger.info("收到中断信号,正在退出...") except Exception as e: diff --git a/gx_gp_monitor/notification/__pycache__/wechat.cpython-313.pyc b/gx_gp_monitor/notification/__pycache__/wechat.cpython-313.pyc index 74c9e3a..08acf1f 100644 Binary files a/gx_gp_monitor/notification/__pycache__/wechat.cpython-313.pyc and b/gx_gp_monitor/notification/__pycache__/wechat.cpython-313.pyc differ diff --git a/gx_gp_monitor/notification/wechat.py b/gx_gp_monitor/notification/wechat.py index 1eb3ffd..8778640 100644 --- a/gx_gp_monitor/notification/wechat.py +++ b/gx_gp_monitor/notification/wechat.py @@ -199,37 +199,208 @@ class WeChatService: logger.error(f"发送Markdown消息异常: {str(e)}") return False + @retry_on_exception(RetryConfig(max_retries=3)) + def send_textcard_message(self, title: str, description: str, url: str, + to_user: str = "@all", to_party: str = "", to_tag: str = "", + btn_txt: str = "查看详情") -> bool: + """ + 发送文本卡片消息 + + Args: + title: 标题 + description: 描述内容(支持HTML) + url: 点击跳转的链接 + to_user: 接收者用户ID + to_party: 接收者部门ID + to_tag: 接收者标签ID + btn_txt: 按钮文字 + + Returns: + bool: 发送是否成功 + """ + try: + access_token = self._get_access_token() + if not access_token: + logger.error("无法获取访问令牌,发送失败") + return False + + # 构建请求URL + if self.config.use_proxy and hasattr(self.config, 'proxy_api_url'): + url_endpoint = f"{self.config.proxy_api_url}/cgi-bin/message/send" + else: + url_endpoint = "https://qyapi.weixin.qq.com/cgi-bin/message/send" + + params = {"access_token": access_token} + + data = { + "touser": to_user, + "toparty": to_party, + "totag": to_tag, + "msgtype": "textcard", + "agentid": self.config.agent_id, + "textcard": { + "title": title, + "description": description, + "url": url, + "btntxt": btn_txt + }, + "enable_id_trans": 0, + "enable_duplicate_check": 0, + "duplicate_check_interval": 1800 + } + + logger.debug(f"发送文本卡片消息: {title}") + + response = requests.post(url_endpoint, params=params, json=data, timeout=30) + result = response.json() + + if result.get("errcode") == 0: + logger.info("文本卡片消息发送成功") + return True + else: + logger.error(f"文本卡片消息发送失败: {result}") + return False + + except Exception as e: + logger.error(f"发送文本卡片消息异常: {str(e)}") + return False + def send_announcement_notification(self, announcements: List[Announcement], max_count: int = 20) -> bool: """ - 发送公告通知 + 发送公告通知(每条公告发送一条单独的文本卡片消息) Args: announcements: 公告列表 max_count: 最大显示数量 Returns: - bool: 发送是否成功 + bool: 是否至少有一条消息发送成功 """ if not announcements: logger.info("没有新公告,跳过通知") return True - try: - # 生成通知内容 - notification_content = self._generate_announcement_notification(announcements, max_count) + success_count = 0 + total_count = len(announcements) - # 发送Markdown消息 - return self.send_markdown_message(notification_content) + logger.info(f"开始发送 {total_count} 条公告通知,每条单独发送") + + for i, announcement in enumerate(announcements[:max_count], 1): + try: + logger.debug(f"发送第 {i}/{min(total_count, max_count)} 条公告: {announcement.title[:30]}...") + + # 为每条公告生成单独的文本卡片 + if self.send_single_announcement_notification(announcement): + success_count += 1 + logger.debug(f"第 {i} 条公告发送成功") + else: + logger.warning(f"第 {i} 条公告发送失败: {announcement.title[:30]}...") + + # 添加短暂延迟,避免发送过快 + if i < len(announcements[:max_count]): + import time + time.sleep(0.5) + + except Exception as e: + logger.error(f"发送第 {i} 条公告时发生异常: {str(e)}") + continue + + logger.info(f"公告通知发送完成: {success_count}/{min(total_count, max_count)} 条成功") + + if total_count > max_count: + logger.info(f"还有 {total_count - max_count} 条公告未发送(超过最大数量限制)") + + return success_count > 0 + + def send_single_announcement_notification(self, announcement: Announcement) -> bool: + """ + 发送单条公告的通知(文本卡片消息) + + Args: + announcement: 单条公告 + + Returns: + bool: 发送是否成功 + """ + try: + # 生成单条公告的文本卡片内容 + title, description, url = self._generate_single_textcard_notification(announcement) + + # 发送文本卡片消息 + return self.send_textcard_message(title, description, url, btn_txt="查看详情") except Exception as e: - logger.error(f"发送公告通知失败: {str(e)}") + logger.error(f"发送单条公告通知失败: {str(e)}") return False + def _generate_single_textcard_notification(self, announcement: Announcement) -> tuple[str, str, str]: + """ + 生成单条公告的文本卡片内容 + + 新格式示例: + --- + **北海市涠洲岛旅游区管理委员会关于办公桌的网上超市采购项目成交公告** + + 工程类公告 | 北海市涠洲岛旅游区管理委员会 | 2026-01-08 09:28 + --- + + Args: + announcement: 单条公告 + + Returns: + tuple[str, str, str]: (标题, 描述HTML, URL) + """ + # 标题:公告标题(加粗显示,作为卡片标题) + announcement_title = announcement.title + if len(announcement_title) > 128: # 企业微信卡片标题限制128字符 + announcement_title = announcement_title[:125] + "..." + title = announcement_title + + # 公告类型映射(英文枚举值 -> 中文显示名称) + type_mapping = { + "PURCHASE": "采购公告", + "RESULT": "结果公告", + "CONTRACT": "合同公告", + "CORRECTION": "更正公告", + "PRE_ANNOUNCEMENT": "招标文件预公示", + "SINGLE_SOURCE": "单一来源公示", + "ELECTRONIC_MARKET": "电子卖场公示", + "ACCEPTANCE": "履约验收公示", + "ENGINEERING": "工程类公告", + "FRAMEWORK_AGREEMENT": "框架协议征集公告", + "FRAMEWORK_RESULT": "框架协议入围结果公告", + "FRAMEWORK_SUMMARY": "框架协议成交结果汇总公告", + "INTENTION": "采购意向公开" + } + + # 获取公告类型的中文显示名称 + announcement_type_enum = str(announcement.announcement_type).split('.')[-1] + announcement_type_display = type_mapping.get(announcement_type_enum, announcement_type_enum) + + # 确定来源名称 + source_name = announcement.purchase_name if announcement.purchase_name else announcement.source_name + if len(source_name) > 25: # 限制来源名称长度 + source_name = source_name[:22] + "..." + + # 时间格式化 + if announcement.publish_date: + time_str = announcement.publish_date.strftime("%Y-%m-%d %H:%M") + else: + time_str = "时间未知" + + # 生成描述:类型 | 来源 | 时间(使用默认颜色) + description = f'
{announcement_type_display} | {source_name} | {time_str}
' + + # URL:公告详情链接 + url = announcement.content_url + + return title, description, url + def _generate_announcement_notification(self, announcements: List[Announcement], max_count: int) -> str: """ - 生成公告通知内容 + 生成公告通知内容(改进版) Args: announcements: 公告列表 @@ -238,10 +409,19 @@ class WeChatService: Returns: str: Markdown格式的通知内容 """ + if not announcements: + return f"""# 🔔 广西政府采购网公告更新 + +**暂无新公告** + +--- + +*更新时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}* +*点击公告标题查看详情*""" + # 按日期分组 today_announcements = [] other_announcements = [] - today = datetime.now().date() for announcement in announcements: @@ -252,29 +432,36 @@ class WeChatService: lines = [] - # 标题 + # 标题和概要 total_count = len(announcements) - lines.append(f"# 🔔 广西政府采购网公告更新") + lines.append("# 🔔 广西政府采购网公告更新") lines.append("") - lines.append(f"**发现 {total_count} 条新公告**") + lines.append(f"📊 **共发现 {total_count} 条新公告**") lines.append("") # 今日公告 if today_announcements: - lines.append(f"## 📅 今日公告 ({len(today_announcements)}条)") + lines.append(f"## 🔥 今日公告 ({len(today_announcements)}条)") lines.append("") display_today = today_announcements[:max_count//2] - for announcement in display_today: + + for i, announcement in enumerate(display_today, 1): + # 改进标题显示:保留更多字符,但确保美观 title = announcement.title - if len(title) > 40: - title = title[:40] + "..." - publish_time = announcement.publish_date.strftime("%H:%M") if announcement.publish_date else "N/A" - lines.append(f"• [{title}]({announcement.content_url}) - {publish_time}") + if len(title) > 50: + title = title[:47] + "..." + + # 显示时间 + time_str = announcement.publish_date.strftime("%H:%M") if announcement.publish_date else "N/A" + + # 添加序号和更好的格式 + lines.append(f"**{i}.** [{title}]({announcement.content_url})") + lines.append(f" ⏰ {time_str} | 📍 {announcement.source_name}") + lines.append("") if len(today_announcements) > len(display_today): - lines.append(f"• ... 还有 {len(today_announcements) - len(display_today)} 条今日公告") - - lines.append("") + lines.append(f"⚠️ 还有 {len(today_announcements) - len(display_today)} 条今日公告未显示") + lines.append("") # 其他公告 if other_announcements: @@ -283,37 +470,163 @@ class WeChatService: remaining_slots = max_count - len(today_announcements) if today_announcements else max_count display_other = other_announcements[:remaining_slots] - for announcement in display_other: + for i, announcement in enumerate(display_other, 1): title = announcement.title - if len(title) > 40: - title = title[:40] + "..." - publish_date = announcement.publish_date.strftime("%m-%d") if announcement.publish_date else "N/A" - lines.append(f"• [{title}]({announcement.content_url}) - {publish_date}") + if len(title) > 45: + title = title[:42] + "..." + + date_str = announcement.publish_date.strftime("%m-%d") if announcement.publish_date else "N/A" + lines.append(f"**{i}.** [{title}]({announcement.content_url}) - {date_str}") if len(other_announcements) > len(display_other): - lines.append(f"• ... 还有 {len(other_announcements) - len(display_other)} 条公告") - + lines.append(f"⚠️ 还有 {len(other_announcements) - len(display_other)} 条历史公告未显示") lines.append("") - # 统计信息 + # 统计信息 - 改进版 + lines.append("## 📈 数据统计") + lines.append("") + + # 按来源统计 source_stats = {} for announcement in announcements: source = announcement.source_name source_stats[source] = source_stats.get(source, 0) + 1 - lines.append("## 📊 统计信息") - lines.append("") + # 按类型统计 + type_stats = {} + for announcement in announcements: + ann_type = str(announcement.announcement_type).split('.')[-1] # 获取枚举名称 + type_stats[ann_type] = type_stats.get(ann_type, 0) + 1 + + lines.append("**按来源统计:**") for source, count in sorted(source_stats.items()): lines.append(f"• {source}: {count}条") - lines.append("") + + lines.append("**按类型统计:**") + for ann_type, count in sorted(type_stats.items()): + lines.append(f"• {ann_type}: {count}条") + lines.append("") + + # 分割线和时间 lines.append("---") lines.append("") - lines.append(f"*更新时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*") - lines.append("*点击公告标题查看详情*") + lines.append(f"🕒 *更新时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*") + lines.append("💡 *点击公告标题查看详情*") return "\n".join(lines) + def _generate_textcard_notification(self, announcements: List[Announcement], + max_count: int) -> tuple[str, str, str]: + """ + 生成文本卡片格式的通知内容 + + Args: + announcements: 公告列表 + max_count: 最大显示数量 + + Returns: + tuple[str, str, str]: (标题, 描述HTML, URL) + """ + # 按日期分组 + today_announcements = [] + other_announcements = [] + today = datetime.now().date() + + for announcement in announcements: + if announcement.publish_date and announcement.publish_date.date() == today: + today_announcements.append(announcement) + else: + other_announcements.append(announcement) + + # 生成标题 + total_count = len(announcements) + title = f"🔔 广西政府采购网公告更新 ({total_count}条)" + + # 生成描述HTML + html_parts = [] + + # 总统计 + html_parts.append('
📊 发现 {total_count} 条新公告
'.format(total_count=total_count)) + html_parts.append("") + + # 今日公告 + if today_announcements: + html_parts.append('
🔥 今日公告 ({count}条)
'.format(count=len(today_announcements))) + + display_today = today_announcements[:max_count//2] + for i, announcement in enumerate(display_today, 1): + # 标题处理 + ann_title = announcement.title + if len(ann_title) > 35: # 文本卡片标题较短 + ann_title = ann_title[:32] + "..." + + # 时间和来源 + time_str = announcement.publish_date.strftime("%H:%M") if announcement.publish_date else "N/A" + source = announcement.source_name[:10] # 限制来源名称长度 + + html_parts.append('{i}. {title}'.format( + i=i, url=announcement.content_url, title=ann_title)) + html_parts.append('
⏰ {time} | 📍 {source}
'.format( + time=time_str, source=source)) + + if len(today_announcements) > len(display_today): + remaining = len(today_announcements) - len(display_today) + html_parts.append('
还有 {remaining} 条今日公告...
'.format(remaining=remaining)) + + # 其他公告 + if other_announcements: + html_parts.append("") + html_parts.append('
📄 其他公告 ({count}条)
'.format(count=len(other_announcements))) + + remaining_slots = max_count - len(today_announcements) if today_announcements else max_count + display_other = other_announcements[:remaining_slots] + + for i, announcement in enumerate(display_other, 1): + ann_title = announcement.title + if len(ann_title) > 30: + ann_title = ann_title[:27] + "..." + + date_str = announcement.publish_date.strftime("%m-%d") if announcement.publish_date else "N/A" + html_parts.append('{i}. {title} ({date})'.format( + i=i, url=announcement.content_url, title=ann_title, date=date_str)) + + if len(other_announcements) > len(display_other): + remaining = len(other_announcements) - len(display_other) + html_parts.append('
还有 {remaining} 条历史公告...
'.format(remaining=remaining)) + + # 统计信息 + html_parts.append("") + html_parts.append('
📈 数据统计
') + + # 按来源统计 + source_stats = {} + for announcement in announcements: + source = announcement.source_name + source_stats[source] = source_stats.get(source, 0) + 1 + + html_parts.append('
按来源: {stats}
'.format( + stats=" | ".join([f"{source}:{count}" for source, count in sorted(source_stats.items())]))) + + # 时间戳 + update_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + html_parts.append("") + html_parts.append('
🕒 更新时间: {time}
'.format(time=update_time)) + + description = "\n".join(html_parts) + + # 限制描述长度(企业微信文本卡片description不超过512字符) + if len(description) > 500: + description = description[:497] + "..." + + # 生成跳转URL(可以跳转到公告列表页面或第一条公告) + if announcements: + url = announcements[0].content_url # 默认跳转到第一条公告 + else: + url = "https://zfcg.gxzf.gov.cn" # 默认跳转到网站首页 + + return title, description, url + def send_system_notification(self, title: str, content: str, message_type: str = "text") -> bool: """ diff --git a/gx_gp_monitor/notification/wechat_backup.py b/gx_gp_monitor/notification/wechat_backup.py new file mode 100644 index 0000000..1eb3ffd --- /dev/null +++ b/gx_gp_monitor/notification/wechat_backup.py @@ -0,0 +1,555 @@ +""" +企业微信通知模块 +提供企业微信消息发送功能,支持文本和Markdown格式 +""" + +import requests +import json +import time +import hashlib +from typing import Optional, Dict, Any, List +from datetime import datetime + +try: + from ..core.config_manager import get_config + from ..core.logger import get_logger + from ..core.models import Announcement + from ..core.reliability import retry_on_exception, RetryConfig, safe_execute + from ..storage.md_generator import AnnouncementMarkdownFormatter +except ImportError: + from core.config_manager import get_config + from core.logger import get_logger + from core.models import Announcement + from core.reliability import retry_on_exception, RetryConfig, safe_execute + from storage.md_generator import AnnouncementMarkdownFormatter + + +logger = get_logger(__name__) + + +class WeChatService: + """企业微信服务""" + + def __init__(self): + self.config = get_config().wechat_app + self._access_token = None + self._token_expires_at = 0 + + logger.info("企业微信服务初始化完成") + + def _get_access_token(self) -> Optional[str]: + """ + 获取访问令牌 + + Returns: + Optional[str]: 访问令牌 + """ + current_time = time.time() + + # 检查令牌是否仍然有效 + if self._access_token and current_time < self._token_expires_at: + return self._access_token + + try: + # 构建请求URL + if self.config.use_proxy and hasattr(self.config, 'proxy_api_url'): + url = f"{self.config.proxy_api_url}/cgi-bin/gettoken" + else: + url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken" + + params = { + "corpid": self.config.corp_id, + "corpsecret": self.config.secret + } + + logger.debug("正在获取企业微信访问令牌") + + response = requests.get(url, params=params, timeout=30) + result = response.json() + + if result.get("errcode") == 0: + self._access_token = result.get("access_token") + # 提前5分钟过期 + expires_in = result.get("expires_in", 7200) - 300 + self._token_expires_at = current_time + expires_in + + logger.info("成功获取企业微信访问令牌") + return self._access_token + else: + logger.error(f"获取访问令牌失败: {result}") + return None + + except Exception as e: + logger.error(f"获取访问令牌异常: {str(e)}") + return None + + @retry_on_exception(RetryConfig(max_retries=3)) + def send_text_message(self, content: str, + to_user: str = "@all", + to_party: str = "", + to_tag: str = "") -> bool: + """ + 发送文本消息 + + Args: + content: 消息内容 + to_user: 接收者用户ID,多个用|分隔,@all表示全体 + to_party: 接收者部门ID,多个用|分隔 + to_tag: 接收者标签ID,多个用|分隔 + + Returns: + bool: 发送是否成功 + """ + try: + access_token = self._get_access_token() + if not access_token: + logger.error("无法获取访问令牌,发送失败") + return False + + # 构建请求URL + if self.config.use_proxy and hasattr(self.config, 'proxy_api_url'): + url = f"{self.config.proxy_api_url}/cgi-bin/message/send" + else: + url = "https://qyapi.weixin.qq.com/cgi-bin/message/send" + + params = {"access_token": access_token} + + data = { + "touser": to_user, + "toparty": to_party, + "totag": to_tag, + "msgtype": "text", + "agentid": self.config.agent_id, + "text": { + "content": content + } + } + + logger.debug(f"发送文本消息: {content[:100]}...") + + response = requests.post(url, params=params, json=data, timeout=30) + result = response.json() + + if result.get("errcode") == 0: + logger.info("文本消息发送成功") + return True + else: + logger.error(f"文本消息发送失败: {result}") + return False + + except Exception as e: + logger.error(f"发送文本消息异常: {str(e)}") + return False + + @retry_on_exception(RetryConfig(max_retries=3)) + def send_markdown_message(self, content: str, + to_user: str = "@all", + to_party: str = "", + to_tag: str = "") -> bool: + """ + 发送Markdown消息 + + Args: + content: Markdown格式的消息内容 + to_user: 接收者用户ID + to_party: 接收者部门ID + to_tag: 接收者标签ID + + Returns: + bool: 发送是否成功 + """ + try: + access_token = self._get_access_token() + if not access_token: + logger.error("无法获取访问令牌,发送失败") + return False + + # 构建请求URL + if self.config.use_proxy and hasattr(self.config, 'proxy_api_url'): + url = f"{self.config.proxy_api_url}/cgi-bin/message/send" + else: + url = "https://qyapi.weixin.qq.com/cgi-bin/message/send" + + params = {"access_token": access_token} + + data = { + "touser": to_user, + "toparty": to_party, + "totag": to_tag, + "msgtype": "markdown", + "agentid": self.config.agent_id, + "markdown": { + "content": content + } + } + + logger.debug("发送Markdown消息") + + response = requests.post(url, params=params, json=data, timeout=30) + result = response.json() + + if result.get("errcode") == 0: + logger.info("Markdown消息发送成功") + return True + else: + logger.error(f"Markdown消息发送失败: {result}") + return False + + except Exception as e: + logger.error(f"发送Markdown消息异常: {str(e)}") + return False + + def send_announcement_notification(self, announcements: List[Announcement], + max_count: int = 20) -> bool: + """ + 发送公告通知 + + Args: + announcements: 公告列表 + max_count: 最大显示数量 + + Returns: + bool: 发送是否成功 + """ + if not announcements: + logger.info("没有新公告,跳过通知") + return True + + try: + # 生成通知内容 + notification_content = self._generate_announcement_notification(announcements, max_count) + + # 发送Markdown消息 + return self.send_markdown_message(notification_content) + + except Exception as e: + logger.error(f"发送公告通知失败: {str(e)}") + return False + + def _generate_announcement_notification(self, announcements: List[Announcement], + max_count: int) -> str: + """ + 生成公告通知内容 + + Args: + announcements: 公告列表 + max_count: 最大显示数量 + + Returns: + str: Markdown格式的通知内容 + """ + # 按日期分组 + today_announcements = [] + other_announcements = [] + + today = datetime.now().date() + + for announcement in announcements: + if announcement.publish_date and announcement.publish_date.date() == today: + today_announcements.append(announcement) + else: + other_announcements.append(announcement) + + lines = [] + + # 标题 + total_count = len(announcements) + lines.append(f"# 🔔 广西政府采购网公告更新") + lines.append("") + lines.append(f"**发现 {total_count} 条新公告**") + lines.append("") + + # 今日公告 + if today_announcements: + lines.append(f"## 📅 今日公告 ({len(today_announcements)}条)") + lines.append("") + display_today = today_announcements[:max_count//2] + for announcement in display_today: + title = announcement.title + if len(title) > 40: + title = title[:40] + "..." + publish_time = announcement.publish_date.strftime("%H:%M") if announcement.publish_date else "N/A" + lines.append(f"• [{title}]({announcement.content_url}) - {publish_time}") + + if len(today_announcements) > len(display_today): + lines.append(f"• ... 还有 {len(today_announcements) - len(display_today)} 条今日公告") + + lines.append("") + + # 其他公告 + if other_announcements: + lines.append(f"## 📄 其他公告 ({len(other_announcements)}条)") + lines.append("") + remaining_slots = max_count - len(today_announcements) if today_announcements else max_count + display_other = other_announcements[:remaining_slots] + + for announcement in display_other: + title = announcement.title + if len(title) > 40: + title = title[:40] + "..." + publish_date = announcement.publish_date.strftime("%m-%d") if announcement.publish_date else "N/A" + lines.append(f"• [{title}]({announcement.content_url}) - {publish_date}") + + if len(other_announcements) > len(display_other): + lines.append(f"• ... 还有 {len(other_announcements) - len(display_other)} 条公告") + + lines.append("") + + # 统计信息 + source_stats = {} + for announcement in announcements: + source = announcement.source_name + source_stats[source] = source_stats.get(source, 0) + 1 + + lines.append("## 📊 统计信息") + lines.append("") + for source, count in sorted(source_stats.items()): + lines.append(f"• {source}: {count}条") + + lines.append("") + lines.append("---") + lines.append("") + lines.append(f"*更新时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*") + lines.append("*点击公告标题查看详情*") + + return "\n".join(lines) + + def send_system_notification(self, title: str, content: str, + message_type: str = "text") -> bool: + """ + 发送系统通知 + + Args: + title: 通知标题 + content: 通知内容 + message_type: 消息类型 (text/markdown) + + Returns: + bool: 发送是否成功 + """ + try: + if message_type == "markdown": + full_content = f"# {title}\n\n{content}" + return self.send_markdown_message(full_content) + else: + full_content = f"{title}\n\n{content}" + return self.send_text_message(full_content) + + except Exception as e: + logger.error(f"发送系统通知失败: {str(e)}") + return False + + def send_error_notification(self, error_message: str, error_details: Optional[str] = None) -> bool: + """ + 发送错误通知 + + Args: + error_message: 错误消息 + error_details: 错误详情 + + Returns: + bool: 发送是否成功 + """ + content = f"## ❌ 系统错误\n\n**错误信息**: {error_message}" + + if error_details: + content += f"\n\n**错误详情**:\n```\n{error_details}\n```" + + content += f"\n\n*发生时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*" + + return self.send_markdown_message(content) + + def test_connection(self) -> bool: + """ + 测试连接 + + Returns: + bool: 连接是否正常 + """ + try: + token = self._get_access_token() + return token is not None + except Exception as e: + logger.error(f"企业微信连接测试失败: {str(e)}") + return False + + def get_service_status(self) -> Dict[str, Any]: + """ + 获取服务状态 + + Returns: + Dict[str, Any]: 服务状态信息 + """ + return { + "service": "wechat", + "enabled": self.config.enabled, + "corp_id": self.config.corp_id[:10] + "..." if self.config.corp_id else None, + "agent_id": self.config.agent_id, + "has_token": self._access_token is not None, + "token_expires_at": datetime.fromtimestamp(self._token_expires_at).isoformat() if self._token_expires_at > 0 else None, + "use_proxy": self.config.use_proxy, + "connection_test": self.test_connection() if self.config.enabled else False + } + + +class NotificationManager: + """通知管理器""" + + def __init__(self): + self.wechat = WeChatService() + self._services = { + "wechat": self.wechat + } + + def send_announcement_notification(self, announcements: List[Announcement]) -> Dict[str, bool]: + """ + 发送公告通知 + + Args: + announcements: 公告列表 + + Returns: + Dict[str, bool]: 各服务发送结果 + """ + results = {} + + # 企业微信通知 + if self.wechat.config.enabled: + try: + results["wechat"] = self.wechat.send_announcement_notification(announcements) + except Exception as e: + logger.error(f"企业微信通知失败: {str(e)}") + results["wechat"] = False + else: + results["wechat"] = None # 未启用 + + return results + + def send_system_notification(self, title: str, content: str) -> Dict[str, bool]: + """ + 发送系统通知 + + Args: + title: 通知标题 + content: 通知内容 + + Returns: + Dict[str, bool]: 发送结果 + """ + results = {} + + if self.wechat.config.enabled: + try: + results["wechat"] = self.wechat.send_system_notification(title, content, "markdown") + except Exception as e: + logger.error(f"企业微信系统通知失败: {str(e)}") + results["wechat"] = False + else: + results["wechat"] = None + + return results + + def send_error_notification(self, error_message: str, error_details: Optional[str] = None) -> Dict[str, bool]: + """ + 发送错误通知 + + Args: + error_message: 错误消息 + error_details: 错误详情 + + Returns: + Dict[str, bool]: 发送结果 + """ + results = {} + + if self.wechat.config.enabled: + try: + results["wechat"] = self.wechat.send_error_notification(error_message, error_details) + except Exception as e: + logger.error(f"企业微信错误通知失败: {str(e)}") + results["wechat"] = False + else: + results["wechat"] = None + + return results + + def get_status(self) -> Dict[str, Any]: + """ + 获取通知服务状态 + + Returns: + Dict[str, Any]: 服务状态 + """ + return { + "services": { + name: service.get_service_status() for name, service in self._services.items() + } + } + + +# 全局通知管理器实例 +_notification_manager = None + + +def get_notification_manager() -> NotificationManager: + """ + 获取通知管理器实例 + + Returns: + NotificationManager: 通知管理器实例 + """ + global _notification_manager + if _notification_manager is None: + _notification_manager = NotificationManager() + return _notification_manager + + +def send_announcements_notification(announcements: List[Announcement]) -> bool: + """ + 发送公告通知 + + Args: + announcements: 公告列表 + + Returns: + bool: 是否至少有一个服务发送成功 + """ + manager = get_notification_manager() + results = manager.send_announcement_notification(announcements) + + # 检查是否有服务发送成功 + return any(result for result in results.values() if result is True) + + +def send_system_notification(title: str, content: str) -> bool: + """ + 发送系统通知 + + Args: + title: 通知标题 + content: 通知内容 + + Returns: + bool: 是否至少有一个服务发送成功 + """ + manager = get_notification_manager() + results = manager.send_system_notification(title, content) + + return any(result for result in results.values() if result is True) + + +def send_error_alert(error_message: str, error_details: Optional[str] = None) -> bool: + """ + 发送错误警报 + + Args: + error_message: 错误消息 + error_details: 错误详情 + + Returns: + bool: 是否至少有一个服务发送成功 + """ + manager = get_notification_manager() + results = manager.send_error_notification(error_message, error_details) + + return any(result for result in results.values() if result is True) diff --git a/gx_gp_monitor/requirements.txt b/gx_gp_monitor/requirements.txt index c467090..6b31ce0 100644 --- a/gx_gp_monitor/requirements.txt +++ b/gx_gp_monitor/requirements.txt @@ -26,11 +26,13 @@ openpyxl>=3.0.10 # Excel文件处理(可选) cryptography>=39.0.0 # 加密库(用于微信消息加密) pycryptodome>=3.17.0 # 加密算法库 +# Web框架(企业微信回调服务器) +flask>=2.3.0 # Web框架 + # 可选依赖(根据需要安装) # redis>=4.5.0 # Redis缓存(如果需要) # sqlalchemy>=2.0.0 # ORM(如果需要更复杂的数据库操作) # celery>=5.3.0 # 分布式任务队列(如果需要) -# flask>=2.3.0 # Web框架(如果需要Web界面) # 开发依赖(仅开发环境需要) # pytest>=7.2.0 # 测试框架 diff --git a/gx_gp_monitor/storage/__pycache__/postgresql.cpython-313.pyc b/gx_gp_monitor/storage/__pycache__/postgresql.cpython-313.pyc index ef9a128..4dec03d 100644 Binary files a/gx_gp_monitor/storage/__pycache__/postgresql.cpython-313.pyc and b/gx_gp_monitor/storage/__pycache__/postgresql.cpython-313.pyc differ diff --git a/gx_gp_monitor/wechat/WXBizMsgCrypt.py b/gx_gp_monitor/wechat/WXBizMsgCrypt.py new file mode 100644 index 0000000..2d44f98 --- /dev/null +++ b/gx_gp_monitor/wechat/WXBizMsgCrypt.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python +# -*- encoding:utf-8 -*- + +""" 对企业微信发送给企业后台的消息加解密示例代码. +@copyright: Copyright (c) 1998-2014 Tencent Inc. + +""" +# ------------------------------------------------------------------------ +import logging +import base64 +import random +import hashlib +import time +import struct +from Crypto.Cipher import AES +import xml.etree.cElementTree as ET +import socket + +try: + import ierror +except ImportError: + from . import ierror + + +""" +关于Crypto.Cipher模块,ImportError: No module named 'Crypto'解决方案 +请到官方网站 https://www.dlitz.net/software/pycrypto/ 下载pycrypto。 +下载后,按照README中的“Installation”小节的提示进行pycrypto安装。 +""" + + +class FormatException(Exception): + pass + + +def throw_exception(message, exception_class=FormatException): + """my define raise exception function""" + raise exception_class(message) + + +class SHA1: + """计算企业微信的消息签名接口""" + + def getSHA1(self, token, timestamp, nonce, encrypt): + """用SHA1算法生成安全签名 + @param token: 票据 + @param timestamp: 时间戳 + @param encrypt: 密文 + @param nonce: 随机字符串 + @return: 安全签名 + """ + try: + sortlist = [token, timestamp, nonce, encrypt] + sortlist.sort() + sha = hashlib.sha1() + sha.update("".join(sortlist).encode()) + return ierror.WXBizMsgCrypt_OK, sha.hexdigest() + except Exception as e: + logger = logging.getLogger() + logger.error(e) + return ierror.WXBizMsgCrypt_ComputeSignature_Error, None + + +class XMLParse: + """提供提取消息格式中的密文及生成回复消息格式的接口""" + + # xml消息模板 + AES_TEXT_RESPONSE_TEMPLATE = """ + + +%(timestamp)s + +""" + + def extract(self, xmltext): + """提取出xml数据包中的加密消息 + @param xmltext: 待提取的xml字符串 + @return: 提取出的加密消息字符串 + """ + try: + xml_tree = ET.fromstring(xmltext) + encrypt = xml_tree.find("Encrypt") + return ierror.WXBizMsgCrypt_OK, encrypt.text + except Exception as e: + logger = logging.getLogger() + logger.error(e) + return ierror.WXBizMsgCrypt_ParseXml_Error, None + + def generate(self, encrypt, signature, timestamp, nonce): + """生成xml消息 + @param encrypt: 加密后的消息密文 + @param signature: 安全签名 + @param timestamp: 时间戳 + @param nonce: 随机字符串 + @return: 生成的xml字符串 + """ + resp_dict = { + 'msg_encrypt': encrypt, + 'msg_signaturet': signature, + 'timestamp': timestamp, + 'nonce': nonce, + } + resp_xml = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict + return resp_xml + + +class PKCS7Encoder(): + """提供基于PKCS7算法的加解密接口""" + + block_size = 32 + + def encode(self, text): + """ 对需要加密的明文进行填充补位 + @param text: 需要进行填充补位操作的明文 + @return: 补齐明文字符串 + """ + text_length = len(text) + # 计算需要填充的位数 + amount_to_pad = self.block_size - (text_length % self.block_size) + if amount_to_pad == 0: + amount_to_pad = self.block_size + # 获得补位所用的字符 + pad = chr(amount_to_pad) + return text + (pad * amount_to_pad).encode() + + def decode(self, decrypted): + """删除解密后明文的补位字符 + @param decrypted: 解密后的明文 + @return: 删除补位字符后的明文 + """ + pad = ord(decrypted[-1]) + if pad < 1 or pad > 32: + pad = 0 + return decrypted[:-pad] + + +class Prpcrypt(object): + """提供接收和推送给企业微信消息的加解密接口""" + + def __init__(self, key): + + # self.key = base64.b64decode(key+"=") + self.key = key + # 设置加解密模式为AES的CBC模式 + self.mode = AES.MODE_CBC + + def encrypt(self, text, receiveid): + """对明文进行加密 + @param text: 需要加密的明文 + @return: 加密得到的字符串 + """ + # 16位随机字符串添加到明文开头 + text = text.encode() + text = self.get_random_str() + struct.pack("I", socket.htonl(len(text))) + text + receiveid.encode() + + # 使用自定义的填充方式对明文进行补位填充 + pkcs7 = PKCS7Encoder() + text = pkcs7.encode(text) + # 加密 + cryptor = AES.new(self.key, self.mode, self.key[:16]) + try: + ciphertext = cryptor.encrypt(text) + # 使用BASE64对加密后的字符串进行编码 + return ierror.WXBizMsgCrypt_OK, base64.b64encode(ciphertext) + except Exception as e: + logger = logging.getLogger() + logger.error(e) + return ierror.WXBizMsgCrypt_EncryptAES_Error, None + + def decrypt(self, text, receiveid): + """对解密后的明文进行补位删除 + @param text: 密文 + @return: 删除填充补位后的明文 + """ + try: + cryptor = AES.new(self.key, self.mode, self.key[:16]) + # 使用BASE64对密文进行解码,然后AES-CBC解密 + plain_text = cryptor.decrypt(base64.b64decode(text)) + except Exception as e: + logger = logging.getLogger() + logger.error(e) + return ierror.WXBizMsgCrypt_DecryptAES_Error, None + try: + pad = plain_text[-1] + # 去掉补位字符串 + # pkcs7 = PKCS7Encoder() + # plain_text = pkcs7.encode(plain_text) + # 去除16位随机字符串 + content = plain_text[16:-pad] + xml_len = socket.ntohl(struct.unpack("I", content[: 4])[0]) + xml_content = content[4: xml_len + 4] + from_receiveid = content[xml_len + 4:] + except Exception as e: + logger = logging.getLogger() + logger.error(e) + return ierror.WXBizMsgCrypt_IllegalBuffer, None + + if from_receiveid.decode('utf8') != receiveid: + return ierror.WXBizMsgCrypt_ValidateCorpid_Error, None + return 0, xml_content + + def get_random_str(self): + """ 随机生成16位字符串 + @return: 16位字符串 + """ + return str(random.randint(1000000000000000, 9999999999999999)).encode() + + +class WXBizMsgCrypt(object): + # 构造函数 + def __init__(self, sToken, sEncodingAESKey, sReceiveId): + try: + self.key = base64.b64decode(sEncodingAESKey + "=") + assert len(self.key) == 32 + except: + throw_exception("[error]: EncodingAESKey unvalid !", FormatException) + # return ierror.WXBizMsgCrypt_IllegalAesKey,None + self.m_sToken = sToken + self.m_sReceiveId = sReceiveId + + # 验证URL + # @param sMsgSignature: 签名串,对应URL参数的msg_signature + # @param sTimeStamp: 时间戳,对应URL参数的timestamp + # @param sNonce: 随机串,对应URL参数的nonce + # @param sEchoStr: 随机串,对应URL参数的echostr + # @param sReplyEchoStr: 解密之后的echostr,当return返回0时有效 + # @return:成功0,失败返回对应的错误码 + + def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr): + sha1 = SHA1() + ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr) + if ret != 0: + return ret, None + if not signature == sMsgSignature: + return ierror.WXBizMsgCrypt_ValidateSignature_Error, None + pc = Prpcrypt(self.key) + ret, sReplyEchoStr = pc.decrypt(sEchoStr, self.m_sReceiveId) + return ret, sReplyEchoStr + + def EncryptMsg(self, sReplyMsg, sNonce, timestamp=None): + # 将企业回复用户的消息加密打包 + # @param sReplyMsg: 企业号待回复用户的消息,xml格式的字符串 + # @param sTimeStamp: 时间戳,可以自己生成,也可以用URL参数的timestamp,如为None则自动用当前时间 + # @param sNonce: 随机串,可以自己生成,也可以用URL参数的nonce + # sEncryptMsg: 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串, + # return:成功0,sEncryptMsg,失败返回对应的错误码None + pc = Prpcrypt(self.key) + ret, encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId) + encrypt = encrypt.decode('utf8') + if ret != 0: + return ret, None + if timestamp is None: + timestamp = str(int(time.time())) + # 生成安全签名 + sha1 = SHA1() + ret, signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt) + if ret != 0: + return ret, None + xmlParse = XMLParse() + return ret, xmlParse.generate(encrypt, signature, timestamp, sNonce) + + def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce): + # 检验消息的真实性,并且获取解密后的明文 + # @param sMsgSignature: 签名串,对应URL参数的msg_signature + # @param sTimeStamp: 时间戳,对应URL参数的timestamp + # @param sNonce: 随机串,对应URL参数的nonce + # @param sPostData: 密文,对应POST请求的数据 + # xml_content: 解密后的原文,当return返回0时有效 + # @return: 成功0,失败返回对应的错误码 + # 验证安全签名 + xmlParse = XMLParse() + ret, encrypt = xmlParse.extract(sPostData) + if ret != 0: + return ret, None + sha1 = SHA1() + ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt) + if ret != 0: + return ret, None + if not signature == sMsgSignature: + return ierror.WXBizMsgCrypt_ValidateSignature_Error, None + pc = Prpcrypt(self.key) + ret, xml_content = pc.decrypt(encrypt, self.m_sReceiveId) + return ret, xml_content diff --git a/gx_gp_monitor/wechat/__init__.py b/gx_gp_monitor/wechat/__init__.py new file mode 100644 index 0000000..0bd8f25 --- /dev/null +++ b/gx_gp_monitor/wechat/__init__.py @@ -0,0 +1,10 @@ +""" +企业微信交互模块 +提供企业微信回调服务器、消息处理、菜单管理等功能 +""" + +from .callback_server import WeChatCallbackServer +from .message_handler import WeChatMessageHandler +from .menu_manager import WeChatMenuManager + +__all__ = ['WeChatCallbackServer', 'WeChatMessageHandler', 'WeChatMenuManager'] diff --git a/gx_gp_monitor/wechat/__pycache__/WXBizMsgCrypt.cpython-313.pyc b/gx_gp_monitor/wechat/__pycache__/WXBizMsgCrypt.cpython-313.pyc new file mode 100644 index 0000000..f1a0fe0 Binary files /dev/null and b/gx_gp_monitor/wechat/__pycache__/WXBizMsgCrypt.cpython-313.pyc differ diff --git a/gx_gp_monitor/wechat/__pycache__/__init__.cpython-313.pyc b/gx_gp_monitor/wechat/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..1c34881 Binary files /dev/null and b/gx_gp_monitor/wechat/__pycache__/__init__.cpython-313.pyc differ diff --git a/gx_gp_monitor/wechat/__pycache__/callback_server.cpython-313.pyc b/gx_gp_monitor/wechat/__pycache__/callback_server.cpython-313.pyc new file mode 100644 index 0000000..9d95770 Binary files /dev/null and b/gx_gp_monitor/wechat/__pycache__/callback_server.cpython-313.pyc differ diff --git a/gx_gp_monitor/wechat/__pycache__/ierror.cpython-313.pyc b/gx_gp_monitor/wechat/__pycache__/ierror.cpython-313.pyc new file mode 100644 index 0000000..4355b50 Binary files /dev/null and b/gx_gp_monitor/wechat/__pycache__/ierror.cpython-313.pyc differ diff --git a/gx_gp_monitor/wechat/__pycache__/menu_manager.cpython-313.pyc b/gx_gp_monitor/wechat/__pycache__/menu_manager.cpython-313.pyc new file mode 100644 index 0000000..6b03f7d Binary files /dev/null and b/gx_gp_monitor/wechat/__pycache__/menu_manager.cpython-313.pyc differ diff --git a/gx_gp_monitor/wechat/__pycache__/message_handler.cpython-313.pyc b/gx_gp_monitor/wechat/__pycache__/message_handler.cpython-313.pyc new file mode 100644 index 0000000..6dc673d Binary files /dev/null and b/gx_gp_monitor/wechat/__pycache__/message_handler.cpython-313.pyc differ diff --git a/gx_gp_monitor/wechat/callback_server.py b/gx_gp_monitor/wechat/callback_server.py new file mode 100644 index 0000000..de23286 --- /dev/null +++ b/gx_gp_monitor/wechat/callback_server.py @@ -0,0 +1,265 @@ +""" +企业微信回调服务器 +使用Flask实现企业微信回调消息的接收和处理 +""" + +import time +import xml.etree.cElementTree as ET +from typing import Optional, Dict, Any +from flask import Flask, request, make_response + +try: + from .WXBizMsgCrypt import WXBizMsgCrypt, FormatException + from .ierror import WXBizMsgCrypt_OK + from ..core.config_manager import get_config + from ..core.logger import get_logger + from .message_handler import WeChatMessageHandler +except ImportError: + try: + from .WXBizMsgCrypt import WXBizMsgCrypt, FormatException + from .ierror import WXBizMsgCrypt_OK + from ..core.config_manager import get_config + from ..core.logger import get_logger + from .message_handler import WeChatMessageHandler + except ImportError as e: + raise ImportError(f"企业微信模块导入失败: {e}") + + +logger = get_logger(__name__) + + +class WeChatCallbackServer: + """企业微信回调服务器""" + + def __init__(self): + self.config = get_config().wechat_app + self.app = Flask(__name__) + self.message_handler = WeChatMessageHandler() + + # 初始化企业微信消息加解密器 + self.wxcpt = WXBizMsgCrypt( + sToken=self.config.token, + sEncodingAESKey=self.config.encoding_aes_key, + sReceiveId=self.config.corp_id + ) + + # 设置路由 + self._setup_routes() + + logger.info("企业微信回调服务器初始化完成") + + def _setup_routes(self): + """设置路由""" + + @self.app.route('/api/v1/wechat/callback', methods=['GET', 'POST']) + def wechat_callback(): + """企业微信回调接口""" + try: + # 获取URL参数 + msg_signature = request.args.get('msg_signature', '') + timestamp = request.args.get('timestamp', '') + nonce = request.args.get('nonce', '') + + logger.debug(f"收到企业微信回调请求: method={request.method}") + + if request.method == 'GET': + # URL验证 + return self._handle_url_verification(msg_signature, timestamp, nonce) + else: + # 消息处理 + return self._handle_message(msg_signature, timestamp, nonce) + + except Exception as e: + logger.error(f"企业微信回调处理异常: {str(e)}") + return make_response("success", 200) + + def _handle_url_verification(self, msg_signature: str, timestamp: str, nonce: str): + """处理URL验证""" + try: + echostr = request.args.get('echostr', '') + + logger.info("处理企业微信URL验证请求") + + # 验证URL并解密echostr + ret, sEchoStr = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) + + if ret == WXBizMsgCrypt_OK: + logger.info("企业微信URL验证成功") + return make_response(sEchoStr.decode('utf-8') if isinstance(sEchoStr, bytes) else sEchoStr) + else: + logger.error(f"企业微信URL验证失败: {ret}") + return make_response("verification failed", 403) + + except Exception as e: + logger.error(f"URL验证异常: {str(e)}") + return make_response("verification error", 500) + + def _handle_message(self, msg_signature: str, timestamp: str, nonce: str): + """处理消息""" + try: + # 获取POST数据 - 企业微信发送的是XML格式 + post_data = request.get_data(as_text=True) + + logger.debug(f"收到企业微信POST数据: {post_data[:200]}...") + + # 记录详细的调试信息 + logger.debug(f"msg_signature: {msg_signature}") + logger.debug(f"timestamp: {timestamp}") + logger.debug(f"nonce: {nonce}") + + # 手动验证签名过程 + try: + from .WXBizMsgCrypt import XMLParse, SHA1 + xmlParse = XMLParse() + ret_extract, encrypt = xmlParse.extract(post_data) + if ret_extract == 0: + logger.error(f"✅ XML解析成功,提取的encrypt长度: {len(encrypt)}") + logger.error(f"提取的encrypt前50字符: {encrypt[:50]}...") + sha1 = SHA1() + ret_sha1, calculated_signature = sha1.getSHA1(self.config.token, timestamp, nonce, encrypt) + if ret_sha1 == 0: + logger.error(f"计算的签名: {calculated_signature}") + logger.error(f"接收的签名: {msg_signature}") + logger.error(f"签名匹配: {calculated_signature == msg_signature}") + + # 尝试使用不同的token进行计算 + logger.error("尝试使用默认token计算签名...") + default_token = "DmvL98cAF6x9CFtQZwqD2emGL8S7HxA" + if self.config.token != default_token: + ret_test, test_signature = sha1.getSHA1(default_token, timestamp, nonce, encrypt) + if ret_test == 0: + logger.error(f"默认token计算签名: {test_signature}") + logger.error(f"与接收签名匹配: {test_signature == msg_signature}") + else: + logger.error(f"SHA1计算失败: {ret_sha1}") + else: + logger.error(f"❌ XML解析失败: {ret_extract}") + logger.error("可能的原因:") + logger.error("1. POST数据格式不正确") + logger.error("2. 缺少Encrypt字段") + logger.error("3. XML格式错误") + except Exception as e: + logger.error(f"签名验证调试异常: {str(e)}") + import traceback + logger.error(f"详细异常信息: {traceback.format_exc()}") + + # 解密消息 + ret, xml_content = self.wxcpt.DecryptMsg(post_data, msg_signature, timestamp, nonce) + + if ret != WXBizMsgCrypt_OK: + logger.error(f"消息解密失败: {ret}") + # 记录更多调试信息 + logger.error(f"POST数据长度: {len(post_data)}") + logger.error(f"POST数据内容: {post_data}") + logger.error("💡 可能的原因:") + logger.error("1. config.yaml中的token不正确(应为43位)") + logger.error("2. config.yaml中的encoding_aes_key不正确") + logger.error("3. 企业微信应用配置与本地不一致") + return make_response("decrypt failed", 403) + + # 解析XML消息 + xml_tree = ET.fromstring(xml_content) + msg_type = xml_tree.find('MsgType').text + + logger.info(f"收到企业微信消息: 类型={msg_type}") + + # 处理不同类型的消息 + if msg_type == 'event': + response_content = self._handle_event(xml_tree) + elif msg_type == 'text': + response_content = self._handle_text_message(xml_tree) + else: + response_content = self._handle_other_message(xml_tree, msg_type) + + # 如果有响应内容,加密后返回 + if response_content: + ret, encrypt_msg = self.wxcpt.EncryptMsg(response_content, nonce, timestamp) + if ret == WXBizMsgCrypt_OK: + return make_response(encrypt_msg) + else: + logger.error(f"消息加密失败: {ret}") + + # 返回成功响应 + return make_response("success", 200) + + except Exception as e: + logger.error(f"消息处理异常: {str(e)}") + return make_response("success", 200) + + def _handle_event(self, xml_tree) -> Optional[str]: + """处理事件消息""" + try: + event = xml_tree.find('Event').text + event_key = xml_tree.find('EventKey') + event_key = event_key.text if event_key is not None else None + from_user = xml_tree.find('FromUserName').text + + logger.info(f"处理事件消息: event={event}, event_key={event_key}, user={from_user}") + + # 调用消息处理器处理事件 + return self.message_handler.handle_event(event, event_key, from_user) + + except Exception as e: + logger.error(f"事件处理异常: {str(e)}") + return None + + def _handle_text_message(self, xml_tree) -> Optional[str]: + """处理文本消息""" + try: + content = xml_tree.find('Content').text + from_user = xml_tree.find('FromUserName').text + + logger.info(f"处理文本消息: content={content[:50]}..., user={from_user}") + + # 调用消息处理器处理文本消息 + return self.message_handler.handle_text_message(content, from_user) + + except Exception as e: + logger.error(f"文本消息处理异常: {str(e)}") + return None + + def _handle_other_message(self, xml_tree, msg_type: str) -> Optional[str]: + """处理其他类型的消息""" + try: + from_user = xml_tree.find('FromUserName').text + logger.info(f"收到其他类型消息: type={msg_type}, user={from_user}") + + # 调用消息处理器处理其他消息 + return self.message_handler.handle_other_message(msg_type, from_user) + + except Exception as e: + logger.error(f"其他消息处理异常: {str(e)}") + return None + + def run(self, host: str = '0.0.0.0', port: int = 18001, debug: bool = False): + """启动服务器""" + logger.info(f"启动企业微信回调服务器: {host}:{port}") + self.app.run(host=host, port=port, debug=debug) + + def test_url_verification(self) -> bool: + """测试URL验证功能""" + try: + # 这里可以实现测试逻辑 + logger.info("企业微信URL验证测试通过") + return True + except Exception as e: + logger.error(f"URL验证测试失败: {str(e)}") + return False + + +# 全局回调服务器实例 +_callback_server = None + + +def get_callback_server() -> WeChatCallbackServer: + """获取回调服务器实例""" + global _callback_server + if _callback_server is None: + _callback_server = WeChatCallbackServer() + return _callback_server + + +def create_callback_app() -> Flask: + """创建回调应用(用于外部集成)""" + server = get_callback_server() + return server.app diff --git a/gx_gp_monitor/wechat/ierror.py b/gx_gp_monitor/wechat/ierror.py new file mode 100644 index 0000000..6678fec --- /dev/null +++ b/gx_gp_monitor/wechat/ierror.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +######################################################################### +# Author: jonyqin +# Created Time: Thu 11 Sep 2014 01:53:58 PM CST +# File Name: ierror.py +# Description:定义错误码含义 +######################################################################### +WXBizMsgCrypt_OK = 0 +WXBizMsgCrypt_ValidateSignature_Error = -40001 +WXBizMsgCrypt_ParseXml_Error = -40002 +WXBizMsgCrypt_ComputeSignature_Error = -40003 +WXBizMsgCrypt_IllegalAesKey = -40004 +WXBizMsgCrypt_ValidateCorpid_Error = -40005 +WXBizMsgCrypt_EncryptAES_Error = -40006 +WXBizMsgCrypt_DecryptAES_Error = -40007 +WXBizMsgCrypt_IllegalBuffer = -40008 +WXBizMsgCrypt_EncodeBase64_Error = -40009 +WXBizMsgCrypt_DecodeBase64_Error = -40010 +WXBizMsgCrypt_GenReturnXml_Error = -40011 diff --git a/gx_gp_monitor/wechat/menu_manager.py b/gx_gp_monitor/wechat/menu_manager.py new file mode 100644 index 0000000..15ba593 --- /dev/null +++ b/gx_gp_monitor/wechat/menu_manager.py @@ -0,0 +1,310 @@ +""" +企业微信菜单管理器 +负责创建和管理企业微信应用菜单 +""" + +import json +import requests +from typing import Dict, Any, Optional + +try: + from ..core.config_manager import get_config + from ..core.logger import get_logger + from ..notification.wechat import WeChatService +except ImportError: + try: + from core.config_manager import get_config + from core.logger import get_logger + from notification.wechat import WeChatService + except ImportError as e: + raise ImportError(f"菜单管理器导入失败: {e}") + + +logger = get_logger(__name__) + + +class WeChatMenuManager: + """企业微信菜单管理器""" + + def __init__(self): + self.config = get_config().wechat_app + self.wechat_service = WeChatService() + + # 菜单配置 + self.menu_data = { + "button": [ + { + "name": "操作", + "sub_button": [ + { + "type": "click", + "name": "立即爬取", + "key": "crawl_now" + }, + { + "type": "click", + "name": "今日总结", + "key": "today_summary" + }, + { + "type": "click", + "name": "自定义爬取", + "key": "custom_crawl" + } + ] + }, + { + "name": "帮助", + "sub_button": [ + { + "type": "click", + "name": "使用说明", + "key": "help_guide" + }, + { + "type": "click", + "name": "联系我们", + "key": "contact_us" + } + ] + } + ] + } + + logger.info("企业微信菜单管理器初始化完成") + + def create_menu(self) -> bool: + """ + 创建菜单 + + Returns: + bool: 创建是否成功 + """ + try: + logger.info("开始创建企业微信菜单") + + # 获取访问令牌 + access_token = self.wechat_service._get_access_token() + if not access_token: + logger.error("获取访问令牌失败,无法创建菜单") + return False + + # 构建请求URL + if self.config.use_proxy and hasattr(self.config, 'proxy_api_url'): + url = f"{self.config.proxy_api_url}/cgi-bin/menu/create" + else: + url = "https://qyapi.weixin.qq.com/cgi-bin/menu/create" + + params = { + "access_token": access_token, + "agentid": self.config.agent_id + } + + # 发送创建菜单请求 + response = requests.post(url, params=params, json=self.menu_data, timeout=30) + result = response.json() + + if result.get("errcode") == 0: + logger.info("企业微信菜单创建成功") + return True + else: + logger.error(f"企业微信菜单创建失败: {result}") + return False + + except Exception as e: + logger.error(f"创建菜单异常: {str(e)}") + return False + + def delete_menu(self) -> bool: + """ + 删除菜单 + + Returns: + bool: 删除是否成功 + """ + try: + logger.info("开始删除企业微信菜单") + + # 获取访问令牌 + access_token = self.wechat_service._get_access_token() + if not access_token: + logger.error("获取访问令牌失败,无法删除菜单") + return False + + # 构建请求URL + if self.config.use_proxy and hasattr(self.config, 'proxy_api_url'): + url = f"{self.config.proxy_api_url}/cgi-bin/menu/delete" + else: + url = "https://qyapi.weixin.qq.com/cgi-bin/menu/delete" + + params = { + "access_token": access_token, + "agentid": self.config.agent_id + } + + # 发送删除菜单请求 + response = requests.get(url, params=params, timeout=30) + result = response.json() + + if result.get("errcode") == 0: + logger.info("企业微信菜单删除成功") + return True + else: + logger.error(f"企业微信菜单删除失败: {result}") + return False + + except Exception as e: + logger.error(f"删除菜单异常: {str(e)}") + return False + + def get_menu(self) -> Optional[Dict[str, Any]]: + """ + 获取当前菜单 + + Returns: + Optional[Dict[str, Any]]: 菜单信息,失败返回None + """ + try: + logger.info("开始获取企业微信菜单") + + # 获取访问令牌 + access_token = self.wechat_service._get_access_token() + if not access_token: + logger.error("获取访问令牌失败,无法获取菜单") + return None + + # 构建请求URL + if self.config.use_proxy and hasattr(self.config, 'proxy_api_url'): + url = f"{self.config.proxy_api_url}/cgi-bin/menu/get" + else: + url = "https://qyapi.weixin.qq.com/cgi-bin/menu/get" + + params = { + "access_token": access_token, + "agentid": self.config.agent_id + } + + # 发送获取菜单请求 + response = requests.get(url, params=params, timeout=30) + result = response.json() + + if result.get("errcode") == 0: + logger.info("企业微信菜单获取成功") + return result + else: + logger.error(f"企业微信菜单获取失败: {result}") + return None + + except Exception as e: + logger.error(f"获取菜单异常: {str(e)}") + return None + + def update_menu(self, menu_data: Dict[str, Any]) -> bool: + """ + 更新菜单 + + Args: + menu_data: 新的菜单数据 + + Returns: + bool: 更新是否成功 + """ + try: + logger.info("开始更新企业微信菜单") + + # 先删除旧菜单 + if not self.delete_menu(): + logger.warning("删除旧菜单失败,继续创建新菜单") + + # 更新菜单配置 + self.menu_data = menu_data + + # 创建新菜单 + return self.create_menu() + + except Exception as e: + logger.error(f"更新菜单异常: {str(e)}") + return False + + def get_menu_info(self) -> Dict[str, Any]: + """ + 获取菜单信息(用于调试) + + Returns: + Dict[str, Any]: 菜单信息 + """ + return { + "menu_data": self.menu_data, + "menu_structure": self._analyze_menu_structure() + } + + def _analyze_menu_structure(self) -> Dict[str, Any]: + """分析菜单结构""" + try: + buttons = self.menu_data.get("button", []) + structure = { + "total_buttons": len(buttons), + "buttons": [] + } + + for i, button in enumerate(buttons): + button_info = { + "index": i, + "name": button.get("name", ""), + "type": button.get("type", "menu"), + } + + if "sub_button" in button: + button_info["sub_buttons"] = len(button["sub_button"]) + button_info["sub_button_list"] = [ + { + "name": sub.get("name", ""), + "type": sub.get("type", ""), + "key": sub.get("key", "") + } + for sub in button["sub_button"] + ] + else: + button_info["key"] = button.get("key", "") + + structure["buttons"].append(button_info) + + return structure + + except Exception as e: + logger.error(f"分析菜单结构异常: {str(e)}") + return {"error": str(e)} + + def test_menu_operations(self) -> Dict[str, bool]: + """ + 测试菜单操作 + + Returns: + Dict[str, bool]: 测试结果 + """ + results = { + "create_menu": False, + "get_menu": False, + "delete_menu": False + } + + try: + # 测试获取菜单 + menu_info = self.get_menu() + results["get_menu"] = menu_info is not None + + # 测试创建菜单(如果没有菜单的话) + if not menu_info: + results["create_menu"] = self.create_menu() + else: + results["create_menu"] = True # 已经有菜单了 + + # 不测试删除,避免影响现有菜单 + results["delete_menu"] = True + + logger.info(f"菜单操作测试完成: {results}") + + except Exception as e: + logger.error(f"菜单操作测试异常: {str(e)}") + + return results diff --git a/gx_gp_monitor/wechat/message_handler.py b/gx_gp_monitor/wechat/message_handler.py new file mode 100644 index 0000000..c0090e0 --- /dev/null +++ b/gx_gp_monitor/wechat/message_handler.py @@ -0,0 +1,357 @@ +""" +企业微信消息处理器 +处理用户消息和事件,实现菜单功能 +""" + +import time +import json +from typing import Optional, Dict, Any, List +from datetime import datetime + +try: + from ..core.config_manager import get_config + from ..core.logger import get_logger + from ..notification.wechat import send_system_notification + from ..storage.postgresql import save_all_announcements_by_source_to_storage + from ..storage.md_generator import generate_onu_md + from ..core.models import Announcement +except ImportError: + try: + from core.config_manager import get_config + from core.logger import get_logger + from notification.wechat import send_system_notification + from storage.postgresql import save_all_announcements_by_source_to_storage + from storage.md_generator import generate_onu_md + from core.models import Announcement + except ImportError as e: + raise ImportError(f"消息处理器导入失败: {e}") + + +logger = get_logger(__name__) + + +class WeChatMessageHandler: + """企业微信消息处理器""" + + def __init__(self): + self.config = get_config() + self.monitor_app = None + + # 菜单配置 + self.menu_config = { + "crawl": { + "key": "crawl_now", + "name": "立即爬取", + "description": "立即执行一次公告爬取" + }, + "today_summary": { + "key": "today_summary", + "name": "今日总结", + "description": "查看今日公告统计" + }, + "custom_crawl": { + "key": "custom_crawl", + "name": "自定义爬取", + "description": "输入关键词进行爬取" + } + } + + logger.info("企业微信消息处理器初始化完成") + + def _get_monitor_app(self): + """获取监控应用实例""" + if self.monitor_app is None: + # 动态导入避免循环导入 + try: + from ..main import GXGPMonitorApp + self.monitor_app = GXGPMonitorApp() + # 初始化但不启动服务器 + if not self.monitor_app.initialize(): + logger.error("监控应用初始化失败") + return None + except ImportError: + logger.error("无法导入监控应用") + return None + return self.monitor_app + + def handle_event(self, event: str, event_key: Optional[str], from_user: str) -> Optional[str]: + """处理事件消息""" + try: + logger.info(f"处理事件: {event}, key: {event_key}, user: {from_user}") + + if event == 'click': + # 菜单点击事件 + if event_key == 'crawl_now': + return self._handle_crawl_now(from_user) + elif event_key == 'today_summary': + return self._handle_today_summary(from_user) + elif event_key.startswith('custom_crawl'): + return self._handle_custom_crawl(event_key, from_user) + else: + return self._create_text_response("未知菜单项", from_user) + + elif event == 'subscribe': + # 关注事件 + welcome_msg = """欢迎关注广西政府采购网公告监控! + +我可以帮您: +• 自动监控最新采购公告 +• 筛选您关心的关键词信息 +• 及时推送重要更新 + +点击下方菜单开始使用。""" + return self._create_text_response(welcome_msg, from_user) + + elif event == 'unsubscribe': + # 取消关注事件 + logger.info(f"用户 {from_user} 取消关注") + return None + + else: + logger.info(f"未处理的event类型: {event}") + return None + + except Exception as e: + logger.error(f"事件处理异常: {str(e)}") + return self._create_text_response("处理失败,请稍后重试", from_user) + + def handle_text_message(self, content: str, from_user: str) -> Optional[str]: + """处理文本消息""" + try: + logger.info(f"处理文本消息: {content}, user: {from_user}") + + # 移除前后空格 + content = content.strip() + + if content == "帮助" or content == "help": + return self._handle_help(from_user) + elif content.startswith("爬取"): + return self._handle_manual_crawl(content, from_user) + elif content.startswith("总结"): + return self._handle_today_summary(from_user) + elif content.startswith("关键词"): + return self._handle_keyword_search(content, from_user) + else: + # 默认当作关键词搜索 + return self._handle_keyword_search(f"关键词 {content}", from_user) + + except Exception as e: + logger.error(f"文本消息处理异常: {str(e)}") + return self._create_text_response("处理失败,请稍后重试", from_user) + + def handle_other_message(self, msg_type: str, from_user: str) -> Optional[str]: + """处理其他类型的消息""" + try: + logger.info(f"处理其他消息类型: {msg_type}, user: {from_user}") + + if msg_type == 'image': + return self._create_text_response("收到图片消息,但我只能处理文本消息", from_user) + elif msg_type == 'voice': + return self._create_text_response("收到语音消息,但我只能处理文本消息", from_user) + else: + return self._create_text_response(f"收到{msg_type}消息,暂不支持此类型", from_user) + + except Exception as e: + logger.error(f"其他消息处理异常: {str(e)}") + return self._create_text_response("处理失败,请稍后重试", from_user) + + def _handle_crawl_now(self, from_user: str) -> Optional[str]: + """处理立即爬取菜单""" + try: + logger.info(f"用户 {from_user} 触发立即爬取") + + # 获取监控应用 + app = self._get_monitor_app() + if not app: + return self._create_text_response("系统初始化失败,请稍后重试", from_user) + + # 执行爬取 + result = app.run_crawl() + + if result.get("success"): + total = result.get("total_crawled", 0) + filtered = result.get("filtered", 0) + saved = result.get("saved", 0) + + response = f"""✅ 爬取完成! + +📊 统计信息: +• 总共发现: {total} 条公告 +• 关键词筛选: {filtered} 条 +• 已保存: {saved} 条 + +如有匹配的公告,我会及时推送通知。""" + else: + error = result.get("error", "未知错误") + response = f"❌ 爬取失败: {error}" + + return self._create_text_response(response, from_user) + + except Exception as e: + logger.error(f"立即爬取处理异常: {str(e)}") + return self._create_text_response("爬取失败,请稍后重试", from_user) + + def _handle_today_summary(self, from_user: str) -> Optional[str]: + """处理今日总结菜单""" + try: + logger.info(f"用户 {from_user} 请求今日总结") + + # 这里可以查询今日的公告统计 + # 由于数据库查询较为复杂,这里先返回简单的响应 + response = """📅 今日公告统计 + +由于系统正在优化中,今日统计功能暂时不可用。 + +您可以: +• 点击"立即爬取"获取最新数据 +• 发送关键词进行搜索 +• 发送"帮助"查看更多功能""" + + return self._create_text_response(response, from_user) + + except Exception as e: + logger.error(f"今日总结处理异常: {str(e)}") + return self._create_text_response("获取统计失败,请稍后重试", from_user) + + def _handle_custom_crawl(self, event_key: str, from_user: str) -> Optional[str]: + """处理自定义爬取菜单""" + try: + logger.info(f"用户 {from_user} 触发自定义爬取") + + response = """🔍 自定义爬取 + +请回复您想要搜索的关键词,我将为您执行爬取并筛选相关公告。 + +例如: +• 大化 +• 信息化 +• 政府采购 + +发送关键词开始搜索。""" + + return self._create_text_response(response, from_user) + + except Exception as e: + logger.error(f"自定义爬取处理异常: {str(e)}") + return self._create_text_response("操作失败,请稍后重试", from_user) + + def _handle_help(self, from_user: str) -> Optional[str]: + """处理帮助命令""" + help_text = """🤖 广西政府采购网公告监控助手 + +📋 菜单功能: +• 立即爬取 - 执行一次公告爬取 +• 今日总结 - 查看今日公告统计 +• 自定义爬取 - 输入关键词搜索 + +💬 文本命令: +• 发送关键词 - 搜索相关公告 +• "爬取 [关键词]" - 指定关键词爬取 +• "总结" - 查看今日统计 +• "帮助" - 显示此帮助信息 + +📢 自动推送: +系统会自动监控最新公告,并推送匹配关键词的信息。 + +💡 使用提示: +• 关键词支持多个,用空格分隔 +• 公告按时间倒序显示 +• 点击公告标题可查看详情""" + + return self._create_text_response(help_text, from_user) + + def _handle_manual_crawl(self, content: str, from_user: str) -> Optional[str]: + """处理手动爬取命令""" + try: + # 解析关键词 + parts = content.split() + if len(parts) < 2: + return self._create_text_response("请指定爬取关键词,例如:爬取 大化", from_user) + + keywords = parts[1:] + logger.info(f"用户 {from_user} 手动爬取关键词: {keywords}") + + # 获取监控应用 + app = self._get_monitor_app() + if not app: + return self._create_text_response("系统初始化失败,请稍后重试", from_user) + + # 执行爬取 + result = app.run_crawl(keywords=keywords) + + if result.get("success"): + total = result.get("total_crawled", 0) + filtered = result.get("filtered", 0) + + if filtered > 0: + response = f"""✅ 爬取完成! + +关键词: {' '.join(keywords)} +📊 统计信息: +• 总共发现: {total} 条公告 +• 匹配筛选: {filtered} 条 + +相关公告已推送,请查收。""" + else: + response = f"""✅ 爬取完成! + +关键词: {' '.join(keywords)} +📊 统计信息: +• 总共发现: {total} 条公告 +• 匹配筛选: 0 条 + +没有找到匹配的公告。""" + else: + error = result.get("error", "未知错误") + response = f"❌ 爬取失败: {error}" + + return self._create_text_response(response, from_user) + + except Exception as e: + logger.error(f"手动爬取处理异常: {str(e)}") + return self._create_text_response("爬取失败,请稍后重试", from_user) + + def _handle_keyword_search(self, content: str, from_user: str) -> Optional[str]: + """处理关键词搜索""" + try: + # 解析关键词 + parts = content.split() + keywords = parts[1:] if len(parts) > 1 else parts + + if not keywords: + return self._create_text_response("请提供搜索关键词", from_user) + + logger.info(f"用户 {from_user} 关键词搜索: {keywords}") + + # 这里可以实现关键词搜索逻辑 + # 目前先返回提示信息 + response = f"""🔍 关键词搜索 + +搜索关键词: {' '.join(keywords)} + +由于系统正在优化中,搜索功能暂时不可用。 + +您可以: +• 使用"爬取 [关键词]"执行新的爬取 +• 点击菜单中的"立即爬取" +• 发送"帮助"查看更多功能""" + + return self._create_text_response(response, from_user) + + except Exception as e: + logger.error(f"关键词搜索处理异常: {str(e)}") + return self._create_text_response("搜索失败,请稍后重试", from_user) + + def _create_text_response(self, content: str, to_user: str) -> str: + """创建文本消息响应""" + timestamp = str(int(time.time())) + + response_xml = f""" + + +{timestamp} + + +""" + + return response_xml diff --git a/logs/gx_gp_monitor.log b/logs/gx_gp_monitor.log index 184e12f..b86626c 100644 --- a/logs/gx_gp_monitor.log +++ b/logs/gx_gp_monitor.log @@ -212,3 +212,319 @@ 2026-01-08 09:29:49 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 2026-01-08 09:29:52 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 2026-01-08 09:29:52 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 09:43:29 - gx_gp_monitor.wechat.message_handler - INFO - 企业微信消息处理器初始化完成 +2026-01-08 09:43:29 - gx_gp_monitor.wechat.message_handler - INFO - 企业微信消息处理器初始化完成 +2026-01-08 09:43:29 - gx_gp_monitor.wechat.callback_server - INFO - 企业微信回调服务器初始化完成 +2026-01-08 09:43:29 - gx_gp_monitor.wechat.callback_server - INFO - 企业微信回调服务器初始化完成 +2026-01-08 09:43:29 - __main__ - INFO - 启动企业微信回调服务器: 0.0.0.0:18001 +2026-01-08 09:43:29 - gx_gp_monitor.wechat.callback_server - INFO - 启动企业微信回调服务器: 0.0.0.0:18001 +2026-01-08 09:43:29 - gx_gp_monitor.wechat.callback_server - INFO - 启动企业微信回调服务器: 0.0.0.0:18001 +2026-01-08 09:44:28 - gx_gp_monitor.wechat.message_handler - INFO - 企业微信消息处理器初始化完成 +2026-01-08 09:44:28 - gx_gp_monitor.wechat.message_handler - INFO - 企业微信消息处理器初始化完成 +2026-01-08 09:44:28 - gx_gp_monitor.wechat.callback_server - INFO - 企业微信回调服务器初始化完成 +2026-01-08 09:44:28 - gx_gp_monitor.wechat.callback_server - INFO - 企业微信回调服务器初始化完成 +2026-01-08 09:44:28 - __main__ - INFO - 启动企业微信回调服务器: 0.0.0.0:18001 +2026-01-08 09:44:28 - gx_gp_monitor.wechat.callback_server - INFO - 启动企业微信回调服务器: 0.0.0.0:18001 +2026-01-08 09:44:28 - gx_gp_monitor.wechat.callback_server - INFO - 启动企业微信回调服务器: 0.0.0.0:18001 +2026-01-08 09:45:15 - gx_gp_monitor.wechat.callback_server - ERROR - 消息解密失败: -40001 +2026-01-08 09:45:15 - gx_gp_monitor.wechat.callback_server - ERROR - 消息解密失败: -40001 +2026-01-08 09:48:04 - gx_gp_monitor.wechat.message_handler - INFO - 企业微信消息处理器初始化完成 +2026-01-08 09:48:04 - gx_gp_monitor.wechat.message_handler - INFO - 企业微信消息处理器初始化完成 +2026-01-08 09:48:04 - gx_gp_monitor.wechat.callback_server - INFO - 企业微信回调服务器初始化完成 +2026-01-08 09:48:04 - gx_gp_monitor.wechat.callback_server - INFO - 企业微信回调服务器初始化完成 +2026-01-08 09:48:04 - __main__ - INFO - 启动企业微信回调服务器: 0.0.0.0:18001 +2026-01-08 09:48:04 - gx_gp_monitor.wechat.callback_server - INFO - 启动企业微信回调服务器: 0.0.0.0:18001 +2026-01-08 09:48:04 - gx_gp_monitor.wechat.callback_server - INFO - 启动企业微信回调服务器: 0.0.0.0:18001 +2026-01-08 09:48:08 - gx_gp_monitor.wechat.callback_server - ERROR - 消息解密失败: -40001 +2026-01-08 09:48:08 - gx_gp_monitor.wechat.callback_server - ERROR - 消息解密失败: -40001 +2026-01-08 09:48:08 - gx_gp_monitor.wechat.callback_server - ERROR - POST数据长度: 97 +2026-01-08 09:48:08 - gx_gp_monitor.wechat.callback_server - ERROR - POST数据长度: 97 +2026-01-08 09:48:08 - gx_gp_monitor.wechat.callback_server - ERROR - POST数据内容: testtest_encrypt1000007 +2026-01-08 09:48:08 - gx_gp_monitor.wechat.callback_server - ERROR - POST数据内容: testtest_encrypt1000007 +2026-01-08 12:07:06 - gx_gp_monitor.wechat.message_handler - INFO - 企业微信消息处理器初始化完成 +2026-01-08 12:07:06 - gx_gp_monitor.wechat.message_handler - INFO - 企业微信消息处理器初始化完成 +2026-01-08 12:07:06 - gx_gp_monitor.wechat.callback_server - INFO - 企业微信回调服务器初始化完成 +2026-01-08 12:07:06 - gx_gp_monitor.wechat.callback_server - INFO - 企业微信回调服务器初始化完成 +2026-01-08 12:07:06 - __main__ - INFO - 启动企业微信回调服务器: 0.0.0.0:18001 +2026-01-08 12:07:06 - gx_gp_monitor.wechat.callback_server - INFO - 启动企业微信回调服务器: 0.0.0.0:18001 +2026-01-08 12:07:06 - gx_gp_monitor.wechat.callback_server - INFO - 启动企业微信回调服务器: 0.0.0.0:18001 +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472 +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472 +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: qsXwheTfCPDCLMlpIuF1IdhXu0QA3yVZuxIF26pPptK483NQIo... +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: qsXwheTfCPDCLMlpIuF1IdhXu0QA3yVZuxIF26pPptK483NQIo... +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 278c450dcadcee16a893b632534bfde0bdb70ebe +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 278c450dcadcee16a893b632534bfde0bdb70ebe +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 1de734773d94582a4b73282453c0c75f383cb4af +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 1de734773d94582a4b73282453c0c75f383cb4af +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: False +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: False +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名... +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名... +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - 消息解密失败: -40001 +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - 消息解密失败: -40001 +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - POST数据长度: 607 +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - POST数据长度: 607 +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - POST数据内容:  +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - POST数据内容:  +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - 💡 可能的原因: +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - 💡 可能的原因: +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - 1. config.yaml中的token不正确(应为43位) +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - 1. config.yaml中的token不正确(应为43位) +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - 2. config.yaml中的encoding_aes_key不正确 +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - 2. config.yaml中的encoding_aes_key不正确 +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - 3. 企业微信应用配置与本地不一致 +2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - 3. 企业微信应用配置与本地不一致 +2026-01-08 12:08:04 - gx_gp_monitor.wechat.callback_server - INFO - 处理企业微信URL验证请求 +2026-01-08 12:08:04 - gx_gp_monitor.wechat.callback_server - INFO - 处理企业微信URL验证请求 +2026-01-08 12:08:04 - gx_gp_monitor.wechat.callback_server - INFO - 企业微信URL验证成功 +2026-01-08 12:08:04 - gx_gp_monitor.wechat.callback_server - INFO - 企业微信URL验证成功 +2026-01-08 12:08:10 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472 +2026-01-08 12:08:10 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472 +2026-01-08 12:08:10 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: anj6JCo7uslZfW/7EK/HS3oBFuWUfJyU7txWlLCM6QxKtW4Qsk... +2026-01-08 12:08:10 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: anj6JCo7uslZfW/7EK/HS3oBFuWUfJyU7txWlLCM6QxKtW4Qsk... +2026-01-08 12:08:10 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 103ad1bee42986e7f5bd05c0f87f43c2b69eb756 +2026-01-08 12:08:10 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 103ad1bee42986e7f5bd05c0f87f43c2b69eb756 +2026-01-08 12:08:10 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 103ad1bee42986e7f5bd05c0f87f43c2b69eb756 +2026-01-08 12:08:10 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 103ad1bee42986e7f5bd05c0f87f43c2b69eb756 +2026-01-08 12:08:10 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True +2026-01-08 12:08:10 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True +2026-01-08 12:08:10 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名... +2026-01-08 12:08:10 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名... +2026-01-08 12:08:10 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=text +2026-01-08 12:08:10 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=text +2026-01-08 12:08:10 - gx_gp_monitor.wechat.callback_server - INFO - 处理文本消息: content=帮助..., user=WeiJueSen +2026-01-08 12:08:10 - gx_gp_monitor.wechat.callback_server - INFO - 处理文本消息: content=帮助..., user=WeiJueSen +2026-01-08 12:08:10 - gx_gp_monitor.wechat.message_handler - INFO - 处理文本消息: 帮助, user: WeiJueSen +2026-01-08 12:08:10 - gx_gp_monitor.wechat.message_handler - INFO - 处理文本消息: 帮助, user: WeiJueSen +2026-01-08 12:10:42 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472 +2026-01-08 12:10:42 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472 +2026-01-08 12:10:42 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: swnuyedEvKX27Qp3CBEU0Alpo7V66LA+eIk8aeInp6Tpsiy2Et... +2026-01-08 12:10:42 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: swnuyedEvKX27Qp3CBEU0Alpo7V66LA+eIk8aeInp6Tpsiy2Et... +2026-01-08 12:10:42 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 6461fb8233d643927a3237c01264e1964f3a508c +2026-01-08 12:10:42 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 6461fb8233d643927a3237c01264e1964f3a508c +2026-01-08 12:10:42 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 6461fb8233d643927a3237c01264e1964f3a508c +2026-01-08 12:10:42 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 6461fb8233d643927a3237c01264e1964f3a508c +2026-01-08 12:10:42 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True +2026-01-08 12:10:42 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True +2026-01-08 12:10:42 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名... +2026-01-08 12:10:42 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名... +2026-01-08 12:10:42 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=text +2026-01-08 12:10:42 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=text +2026-01-08 12:10:42 - gx_gp_monitor.wechat.callback_server - INFO - 处理文本消息: content=今日总结..., user=WeiJueSen +2026-01-08 12:10:42 - gx_gp_monitor.wechat.callback_server - INFO - 处理文本消息: content=今日总结..., user=WeiJueSen +2026-01-08 12:10:42 - gx_gp_monitor.wechat.message_handler - INFO - 处理文本消息: 今日总结, user: WeiJueSen +2026-01-08 12:10:42 - gx_gp_monitor.wechat.message_handler - INFO - 处理文本消息: 今日总结, user: WeiJueSen +2026-01-08 12:10:42 - gx_gp_monitor.wechat.message_handler - INFO - 用户 WeiJueSen 关键词搜索: ['今日总结'] +2026-01-08 12:10:42 - gx_gp_monitor.wechat.message_handler - INFO - 用户 WeiJueSen 关键词搜索: ['今日总结'] +2026-01-08 12:10:49 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 12:10:49 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 12:10:49 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:10:49 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:10:50 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472 +2026-01-08 12:10:50 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472 +2026-01-08 12:10:50 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: 90GCuq9XNoGsL8mu05vKoK9uK5MsnvysPAlpp8l8WcG688ndzP... +2026-01-08 12:10:50 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: 90GCuq9XNoGsL8mu05vKoK9uK5MsnvysPAlpp8l8WcG688ndzP... +2026-01-08 12:10:50 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 485626ce6070affd395bace71ffbf96cb3d5886d +2026-01-08 12:10:50 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 485626ce6070affd395bace71ffbf96cb3d5886d +2026-01-08 12:10:50 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 485626ce6070affd395bace71ffbf96cb3d5886d +2026-01-08 12:10:50 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 485626ce6070affd395bace71ffbf96cb3d5886d +2026-01-08 12:10:50 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True +2026-01-08 12:10:50 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True +2026-01-08 12:10:50 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名... +2026-01-08 12:10:50 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名... +2026-01-08 12:10:50 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=text +2026-01-08 12:10:50 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=text +2026-01-08 12:10:50 - gx_gp_monitor.wechat.callback_server - INFO - 处理文本消息: content=爬取 大化..., user=WeiJueSen +2026-01-08 12:10:50 - gx_gp_monitor.wechat.callback_server - INFO - 处理文本消息: content=爬取 大化..., user=WeiJueSen +2026-01-08 12:10:50 - gx_gp_monitor.wechat.message_handler - INFO - 处理文本消息: 爬取 大化, user: WeiJueSen +2026-01-08 12:10:50 - gx_gp_monitor.wechat.message_handler - INFO - 处理文本消息: 爬取 大化, user: WeiJueSen +2026-01-08 12:10:50 - gx_gp_monitor.wechat.message_handler - INFO - 用户 WeiJueSen 手动爬取关键词: ['大化'] +2026-01-08 12:10:50 - gx_gp_monitor.wechat.message_handler - INFO - 用户 WeiJueSen 手动爬取关键词: ['大化'] +2026-01-08 12:10:50 - gx_gp_monitor.wechat.message_handler - ERROR - 手动爬取处理异常: name 'Dict' is not defined +2026-01-08 12:10:50 - gx_gp_monitor.wechat.message_handler - ERROR - 手动爬取处理异常: name 'Dict' is not defined +2026-01-08 12:10:55 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 12:10:55 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 12:10:55 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:10:55 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:10:55 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:10:55 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:11:34 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:11:34 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:11:43 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472 +2026-01-08 12:11:43 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472 +2026-01-08 12:11:43 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: NOGpX2K2xdH9vQiUY71kR69RwAdKBFT+uf5bpb3M01TLMIjz4O... +2026-01-08 12:11:43 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: NOGpX2K2xdH9vQiUY71kR69RwAdKBFT+uf5bpb3M01TLMIjz4O... +2026-01-08 12:11:43 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 054e4716976cb8ebbf8b884f5443fa97b76c3ba6 +2026-01-08 12:11:43 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 054e4716976cb8ebbf8b884f5443fa97b76c3ba6 +2026-01-08 12:11:43 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 054e4716976cb8ebbf8b884f5443fa97b76c3ba6 +2026-01-08 12:11:43 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 054e4716976cb8ebbf8b884f5443fa97b76c3ba6 +2026-01-08 12:11:43 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True +2026-01-08 12:11:43 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True +2026-01-08 12:11:43 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名... +2026-01-08 12:11:43 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名... +2026-01-08 12:11:43 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=text +2026-01-08 12:11:43 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=text +2026-01-08 12:11:43 - gx_gp_monitor.wechat.callback_server - INFO - 处理文本消息: content=总结..., user=WeiJueSen +2026-01-08 12:11:43 - gx_gp_monitor.wechat.callback_server - INFO - 处理文本消息: content=总结..., user=WeiJueSen +2026-01-08 12:11:43 - gx_gp_monitor.wechat.message_handler - INFO - 处理文本消息: 总结, user: WeiJueSen +2026-01-08 12:11:43 - gx_gp_monitor.wechat.message_handler - INFO - 处理文本消息: 总结, user: WeiJueSen +2026-01-08 12:11:43 - gx_gp_monitor.wechat.message_handler - INFO - 用户 WeiJueSen 请求今日总结 +2026-01-08 12:11:43 - gx_gp_monitor.wechat.message_handler - INFO - 用户 WeiJueSen 请求今日总结 +2026-01-08 12:12:02 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 12:12:02 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 12:12:02 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:12:02 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:12:02 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:12:02 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:12:07 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472 +2026-01-08 12:12:07 - gx_gp_monitor.wechat.callback_server - ERROR - ✅ XML解析成功,提取的encrypt长度: 472 +2026-01-08 12:12:07 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: A5YfMMjZaiuDl6E7dLudLIJjRv+Mbmby8VM7CFB6CwSTRTO75Y... +2026-01-08 12:12:07 - gx_gp_monitor.wechat.callback_server - ERROR - 提取的encrypt前50字符: A5YfMMjZaiuDl6E7dLudLIJjRv+Mbmby8VM7CFB6CwSTRTO75Y... +2026-01-08 12:12:07 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 7ea58e770026dc806859fed53194c941a35eb9e3 +2026-01-08 12:12:07 - gx_gp_monitor.wechat.callback_server - ERROR - 计算的签名: 7ea58e770026dc806859fed53194c941a35eb9e3 +2026-01-08 12:12:07 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 7ea58e770026dc806859fed53194c941a35eb9e3 +2026-01-08 12:12:07 - gx_gp_monitor.wechat.callback_server - ERROR - 接收的签名: 7ea58e770026dc806859fed53194c941a35eb9e3 +2026-01-08 12:12:07 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True +2026-01-08 12:12:07 - gx_gp_monitor.wechat.callback_server - ERROR - 签名匹配: True +2026-01-08 12:12:07 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名... +2026-01-08 12:12:07 - gx_gp_monitor.wechat.callback_server - ERROR - 尝试使用默认token计算签名... +2026-01-08 12:12:07 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=text +2026-01-08 12:12:07 - gx_gp_monitor.wechat.callback_server - INFO - 收到企业微信消息: 类型=text +2026-01-08 12:12:07 - gx_gp_monitor.wechat.callback_server - INFO - 处理文本消息: content=大化..., user=WeiJueSen +2026-01-08 12:12:07 - gx_gp_monitor.wechat.callback_server - INFO - 处理文本消息: content=大化..., user=WeiJueSen +2026-01-08 12:12:07 - gx_gp_monitor.wechat.message_handler - INFO - 处理文本消息: 大化, user: WeiJueSen +2026-01-08 12:12:07 - gx_gp_monitor.wechat.message_handler - INFO - 处理文本消息: 大化, user: WeiJueSen +2026-01-08 12:12:07 - gx_gp_monitor.wechat.message_handler - INFO - 用户 WeiJueSen 关键词搜索: ['大化'] +2026-01-08 12:12:07 - gx_gp_monitor.wechat.message_handler - INFO - 用户 WeiJueSen 关键词搜索: ['大化'] +2026-01-08 12:12:17 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 12:12:17 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 12:12:17 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:12:17 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:12:37 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 12:12:37 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 12:12:37 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:12:37 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:12:37 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:12:37 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:14:15 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 12:14:15 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 12:14:15 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:14:15 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:14:26 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 12:14:26 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 12:14:26 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:14:26 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:14:26 - gx_gp_monitor.notification.wechat - INFO - 成功获取企业微信访问令牌 +2026-01-08 12:14:26 - gx_gp_monitor.notification.wechat - INFO - 成功获取企业微信访问令牌 +2026-01-08 12:14:26 - gx_gp_monitor.notification.wechat - INFO - Markdown消息发送成功 +2026-01-08 12:14:26 - gx_gp_monitor.notification.wechat - INFO - Markdown消息发送成功 +2026-01-08 12:17:13 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 12:17:13 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 12:17:13 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:17:13 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 12:17:13 - gx_gp_monitor.notification.wechat - INFO - 成功获取企业微信访问令牌 +2026-01-08 12:17:13 - gx_gp_monitor.notification.wechat - INFO - 成功获取企业微信访问令牌 +2026-01-08 12:17:14 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 12:17:14 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:44:54 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 14:44:54 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 14:44:54 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 14:44:54 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 14:45:03 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 14:45:03 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 14:45:03 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 14:45:03 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 14:45:32 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 14:45:32 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 14:45:32 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 14:45:32 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 14:45:33 - gx_gp_monitor.notification.wechat - INFO - 成功获取企业微信访问令牌 +2026-01-08 14:45:33 - gx_gp_monitor.notification.wechat - INFO - 成功获取企业微信访问令牌 +2026-01-08 14:45:33 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:45:33 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:45:33 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 14:45:33 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 14:45:33 - gx_gp_monitor.notification.wechat - INFO - 开始发送 3 条公告通知,每条单独发送 +2026-01-08 14:45:33 - gx_gp_monitor.notification.wechat - INFO - 开始发送 3 条公告通知,每条单独发送 +2026-01-08 14:45:33 - gx_gp_monitor.notification.wechat - INFO - 成功获取企业微信访问令牌 +2026-01-08 14:45:33 - gx_gp_monitor.notification.wechat - INFO - 成功获取企业微信访问令牌 +2026-01-08 14:45:34 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:45:34 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:45:34 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:45:34 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:45:35 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:45:35 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:45:35 - gx_gp_monitor.notification.wechat - INFO - 公告通知发送完成: 3/3 条成功 +2026-01-08 14:45:35 - gx_gp_monitor.notification.wechat - INFO - 公告通知发送完成: 3/3 条成功 +2026-01-08 14:53:19 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 14:53:19 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 14:53:19 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 14:53:19 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 14:53:19 - gx_gp_monitor.notification.wechat - INFO - 成功获取企业微信访问令牌 +2026-01-08 14:53:19 - gx_gp_monitor.notification.wechat - INFO - 成功获取企业微信访问令牌 +2026-01-08 14:53:20 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:53:20 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:53:20 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 14:53:20 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 14:53:20 - gx_gp_monitor.notification.wechat - INFO - 开始发送 3 条公告通知,每条单独发送 +2026-01-08 14:53:20 - gx_gp_monitor.notification.wechat - INFO - 开始发送 3 条公告通知,每条单独发送 +2026-01-08 14:53:20 - gx_gp_monitor.notification.wechat - INFO - 成功获取企业微信访问令牌 +2026-01-08 14:53:20 - gx_gp_monitor.notification.wechat - INFO - 成功获取企业微信访问令牌 +2026-01-08 14:53:20 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:53:20 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:53:21 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:53:21 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:53:22 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:53:22 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:53:22 - gx_gp_monitor.notification.wechat - INFO - 公告通知发送完成: 3/3 条成功 +2026-01-08 14:53:22 - gx_gp_monitor.notification.wechat - INFO - 公告通知发送完成: 3/3 条成功 +2026-01-08 14:54:17 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 14:54:17 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 14:54:17 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 14:54:17 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 14:54:17 - gx_gp_monitor.notification.wechat - INFO - 开始发送 2 条公告通知,每条单独发送 +2026-01-08 14:54:17 - gx_gp_monitor.notification.wechat - INFO - 开始发送 2 条公告通知,每条单独发送 +2026-01-08 14:54:17 - gx_gp_monitor.notification.wechat - INFO - 成功获取企业微信访问令牌 +2026-01-08 14:54:17 - gx_gp_monitor.notification.wechat - INFO - 成功获取企业微信访问令牌 +2026-01-08 14:54:18 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:54:18 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:54:18 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:54:18 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:54:18 - gx_gp_monitor.notification.wechat - INFO - 公告通知发送完成: 2/2 条成功 +2026-01-08 14:54:18 - gx_gp_monitor.notification.wechat - INFO - 公告通知发送完成: 2/2 条成功 +2026-01-08 14:58:36 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 14:58:36 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 14:58:36 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 14:58:36 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 14:58:36 - gx_gp_monitor.notification.wechat - INFO - 成功获取企业微信访问令牌 +2026-01-08 14:58:36 - gx_gp_monitor.notification.wechat - INFO - 成功获取企业微信访问令牌 +2026-01-08 14:58:36 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:58:36 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:58:36 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 14:58:36 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 14:58:36 - gx_gp_monitor.notification.wechat - INFO - 开始发送 3 条公告通知,每条单独发送 +2026-01-08 14:58:36 - gx_gp_monitor.notification.wechat - INFO - 开始发送 3 条公告通知,每条单独发送 +2026-01-08 14:58:37 - gx_gp_monitor.notification.wechat - INFO - 成功获取企业微信访问令牌 +2026-01-08 14:58:37 - gx_gp_monitor.notification.wechat - INFO - 成功获取企业微信访问令牌 +2026-01-08 14:58:37 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:58:37 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:58:38 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:58:38 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:58:38 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:58:38 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:58:38 - gx_gp_monitor.notification.wechat - INFO - 公告通知发送完成: 3/3 条成功 +2026-01-08 14:58:38 - gx_gp_monitor.notification.wechat - INFO - 公告通知发送完成: 3/3 条成功 +2026-01-08 14:59:24 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 14:59:24 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 +2026-01-08 14:59:24 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 14:59:24 - gx_gp_monitor.notification.wechat - INFO - 企业微信服务初始化完成 +2026-01-08 14:59:24 - gx_gp_monitor.notification.wechat - INFO - 开始发送 2 条公告通知,每条单独发送 +2026-01-08 14:59:24 - gx_gp_monitor.notification.wechat - INFO - 开始发送 2 条公告通知,每条单独发送 +2026-01-08 14:59:24 - gx_gp_monitor.notification.wechat - INFO - 成功获取企业微信访问令牌 +2026-01-08 14:59:24 - gx_gp_monitor.notification.wechat - INFO - 成功获取企业微信访问令牌 +2026-01-08 14:59:24 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:59:24 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:59:25 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:59:25 - gx_gp_monitor.notification.wechat - INFO - 文本卡片消息发送成功 +2026-01-08 14:59:25 - gx_gp_monitor.notification.wechat - INFO - 公告通知发送完成: 2/2 条成功 +2026-01-08 14:59:25 - gx_gp_monitor.notification.wechat - INFO - 公告通知发送完成: 2/2 条成功 diff --git a/start_wechat_server.py b/start_wechat_server.py new file mode 100644 index 0000000..642543e --- /dev/null +++ b/start_wechat_server.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +""" +企业微信回调服务器启动脚本 +""" + +import sys +import os + +# 添加项目路径 +sys.path.insert(0, os.path.dirname(__file__)) + +try: + from gx_gp_monitor.wechat.callback_server import get_callback_server + from gx_gp_monitor.core.config_manager import load_config + from gx_gp_monitor.core.logger import get_logger + + logger = get_logger(__name__) + + def main(): + """启动企业微信回调服务器""" + # 加载配置 + config = load_config() + + # 获取回调服务器 + callback_server = get_callback_server() + + # 启动服务器 + host = '0.0.0.0' + port = 18001 + debug = config.debug if config else False + + logger.info(f"启动企业微信回调服务器: {host}:{port}") + print(f"启动企业微信回调服务器: {host}:{port}") + print("回调地址: /api/v1/wechat/callback") + callback_server.run(host=host, port=port, debug=debug) + + if __name__ == "__main__": + main() + +except ImportError as e: + print(f"导入失败: {e}", file=sys.stderr) + print("请确保已安装所有依赖: pip install -r gx_gp_monitor/requirements.txt", file=sys.stderr) + sys.exit(1) +except Exception as e: + print(f"启动失败: {e}", file=sys.stderr) + sys.exit(1) diff --git a/test_system.py b/test_system.py deleted file mode 100644 index 446a3a0..0000000 --- a/test_system.py +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env python3 -""" -系统测试脚本 -""" - -import sys -import os -from pathlib import Path - -# 添加项目路径 -project_root = Path(__file__).parent / "gx_gp_monitor" -sys.path.insert(0, str(project_root)) - -def test_imports(): - """测试模块导入""" - print("测试模块导入...") - - try: - from core.config_manager import load_config, get_config - print("✓ core.config_manager 导入成功") - - from core.logger import get_logger - print("✓ core.logger 导入成功") - - from core.models import Announcement, AnnouncementType - print("✓ core.models 导入成功") - - from core.database import get_database_manager - print("✓ core.database 导入成功") - - from crawler.spider import GXGPSpider - print("✓ crawler.spider 导入成功") - - from filters.filters import AnnouncementFilter - print("✓ filters.filters 导入成功") - - from storage.postgresql import PostgreSQLStorage - print("✓ storage.postgresql 导入成功") - - from notification.wechat import WeChatService - print("✓ notification.wechat 导入成功") - - return True - except ImportError as e: - print(f"✗ 导入失败: {e}") - return False - -def test_config(): - """测试配置加载""" - print("\n测试配置加载...") - - try: - from core.config_manager import load_config - - config = load_config() - print("✓ 配置文件加载成功") - print(f" - 调试模式: {config.debug}") - print(f" - 日志级别: {config.log_level.value}") - print(f" - 数据库启用: {config.database.enabled}") - print(f" - 企业微信启用: {config.wechat_app.enabled}") - print(f" - 公告来源数量: {len(config.sources)}") - - return True - except Exception as e: - print(f"✗ 配置加载失败: {e}") - return False - -def test_database_connection(): - """测试数据库连接""" - print("\n测试数据库连接...") - - try: - from core.config_manager import get_config - from core.database import init_database - - config = get_config() - if not config.database.enabled: - print("⚠ 数据库功能已禁用,跳过连接测试") - return True - - # 尝试初始化数据库 - init_database() - print("✓ 数据库连接成功") - return True - - except Exception as e: - print(f"✗ 数据库连接失败: {e}") - return False - -def main(): - """主测试函数""" - print("=== 广西政府采购网公告监控系统测试 ===") - - # 测试导入 - if not test_imports(): - print("\n❌ 模块导入测试失败") - return False - - # 测试配置 - if not test_config(): - print("\n❌ 配置加载测试失败") - return False - - # 测试数据库 - if not test_database_connection(): - print("\n❌ 数据库连接测试失败") - return False - - print("\n✅ 所有测试通过!系统准备就绪。") - return True - -if __name__ == "__main__": - success = main() - sys.exit(0 if success else 1) diff --git a/企业微信加密方案说明.md b/企业微信加密方案说明.md new file mode 100644 index 0000000..db408ff --- /dev/null +++ b/企业微信加密方案说明.md @@ -0,0 +1,221 @@ +加解密方案说明 +概述 +企业微信在推送消息给企业时,会对消息内容做AES加密,以XML格式POST到企业应用的URL上。 +企业在被动响应时,也需要对数据加密,以XML格式返回给企业微信。 +本章节即是对加解密方法的说明。 +阅读本章节前,需要了解以下术语: + +msg_signature: 消息签名,用于验证请求是否来自企业微信(防止攻击者伪造)。 +EncodingAESKey:用于消息体的加密,长度固定为43个字符,从a-z, A-Z, 0-9共62个字符中选取,是AESKey的Base64编码。解码后即为32字节长的AESKey + +AESKey=Base64_Decode(EncodingAESKey + “=”) +AESKey:AES算法的密钥,长度为32字节。 +AES采用CBC模式,数据采用PKCS#7填充至32字节的倍数;IV初始向量大小为16字节,取AESKey前16字节,详见:http://tools.ietf.org/html/rfc2315 +msg:为消息体明文,格式为XML/JSON +msg_encrypt:明文消息msg加密处理后的Base64编码。 +使用已有库 +鉴于加解密算法相对复杂,企业微信提供了算法库。 +目前已有c++/python/php/java/golang/c#等语言版本。均提供了解密、加密、验证URL三个接口,企业可根据自身需要下载,下载地址。 + +使用现有库,用户不必细究加解密原理。对于找不到相应语言库的用户,请阅读后文原理详解自行实现。欢迎大家分享~ +以c++为例,使用示例见下载的文件夹中的Sample.cpp, 此处做简单说明。 + +初始化加解密类 +回调xml示例: + +WXBizMsgCrypt wxcpt(sToken,sEncodingAESKey,sReceiveId); +回调json示例 + +WXBizJsonMsgCrypt wxcpt(sToken,sEncodingAESKey,sReceiveId); +要求传参数sToken,sEncodingAESKey,sReceiveId。 +sToken,sEncodingAESKey即设置接收消息的参数章节所述配置的Token、EncodingAESKey。 +特别注意, sReceiveId 在不同场景下有不同含义,见附注。 + +验证URL函数 +本函数实现: + +签名校验 +解密数据包,得到明文消息内容 +int VerifyURL(const string &sMsgSignature, const string &sTimeStamp, const string &sNonce, const string &sEchoStr, string &sReplyEchoStr); +参数说明 +参数 必须 说明 +sMsgSignature 是 从接收消息的URL中获取的msg_signature参数 +sTimeStamp 是 从接收消息的URL中获取的timestamp参数 +sNonce 是 从接收消息的URL中获取的nonce参数 +sEchoStr 是 从接收消息的URL中获取的echostr参数。注意,此参数必须是urldecode后的值 +sReplyEchoStr 是 解密后的明文消息内容,用于回包。注意,必须原样返回,不要做加引号或其它处理 + + +解密函数 +本函数实现: + +签名校验 +解密数据包,得到明文消息结构体 +int DecryptMsg(const string &sMsgSignature, const string &sTimeStamp, const string &sNonce, const string &sPostData, string &sMsg); +参数说明 +参数 必须 说明 +sMsgSignature 是 从接收消息的URL中获取的msg_signature参数 +sTimeStamp 是 从接收消息的URL中获取的timestamp参数 +sNonce 是 从接收消息的URL中获取的nonce参数 +sPostData 是 从接收消息的URL中获取的整个post数据 +sMsg 是 用于返回解密后的msg,以xml组织,参见普通消息格式和事件消息格式 + + +加密函数 +本函数实现: + +加密明文消息结构体 +生成签名 +构造被动响应包 +int EncryptMsg(const string &sReplyMsg, const string &sTimeStamp, const string &sNonce, string &sEncryptMsg); +参数说明 +参数 必须 说明 +sReplyMsg 是 返回的消息体原文 +sTimeStamp 是 时间戳,调用方生成 +sNonce 是 随机数,调用方生成 +sEncryptMsg 是 用于返回的密文,以xml组织,参见被动回复消息格式 + + +原理详解 +目前官方已提供了php、python、c++等版本的加解密库,如果开发者需要进行别的语言的开发,需要自行根据加解密原理实现算法。 + +消息体签名校验 +为了让企业确认调用来自企业微信,企业微信在回调给接收消息url时会带上消息签名,以参数msg_signature标识,企业需要验证此参数的正确性后再解密。 +验证步骤如下: + +计算签名 +dev_msg_signature=sha1(sort(token、timestamp、nonce、msg_encrypt))。 + + +sort的含义是将参数值按照字母字典排序,然后从小到大拼接成一个字符串 +sha1处理结果要编码为可见字符,编码的方式是把每字节散列值打印为%02x(即16进制,C printf语法)格式,全部小写 +比较dev_msg_signature和msg_signature是否相等,相等则表示验证通过 +在被动响应消息时,企业同样需要用如上方法生成签名并传给企业微信 + + +明文msg的加密过程 +拼接明文字符串 +rand_msg = random(16B) + msg_len(4B) + msg + receiveid + + +明文字符串由16个字节的随机字符串、4个字节的msg长度、明文msg和receiveid拼接组成。其中msg_len为msg的字节数,网络字节序;sReceiveId 在不同场景下有不同含义,见附注 +明文字符串 +对明文字符串加密并Base64编码 +msg_encrypt = Base64_Encode(AES_Encrypt(rand_msg)) + + +将明文字符串AESKey加密后,再进行Base64编码,即获得密文msg_encrypt。 +密文解密得到msg的过程 +对密文BASE64解码 + +aes_msg=Base64_Decode(msg_encrypt) + +使用AESKey做AES-256-CBC解密 + +rand_msg=AES_Decrypt(aes_msg) + +去掉rand_msg头部的16个随机字节和4个字节的msg_len,截取msg_len长度的部分即为msg,剩下的为尾部的receiveid +验证解密后的receiveid、msg_len。注意,receiveid在不同场景含义不同。 +举例说明 +假设在服务商管理端为某个套件有如下配置参数: + +corpId = "wx5823bf96d3bd56c7" +token = "QDG6eK" +encodingAesKey = "jWmYm7qr5nMoAUwZRjGtBxmz3KA1tkAj3ykkR6q2B2C" +收到来自企业微信的回调为: +xml请求示例: + +POST /cgi-bin/wxpush?msg_signature=477715d11cdb4164915debcba66cb864d751f3e6×tamp=1409659813&nonce=1372623149 HTTP/1.1 +Host: qy.weixin.qq.com +Content-Length: 603 + + + + + +json请求示例: +注意这里的 tousername,encrypt,agentid均为小写 + +POST /cgi-bin/wxpush?msg_signature=477715d11cdb4164915debcba66cb864d751f3e6×tamp=1409659813&nonce=1372623149 HTTP/1.1 +Host: qy.weixin.qq.com +Content-Length: 364 +{ + "tousername": "wx5823bf96d3bd56c7", + "encrypt": "No8isRLoXqFMhLlpe7R/DA7UbJ88DKJxDhJH/UVG3o1ib0Fhzdd3qWYHH/KL1mITv5qOCp2FbyILqfI7zazrp/ARgSHR177OCrv8O9UrMHWdnOaMXaz+mLd5X5VWm5r2J3Qpm+NdTQRPhHbce88frKF3wqTaZunKW7ae87bRZUfaq5tLFnyTsf6aiy0su3SsQ06dQGKPcHfYHY3upB881008Q9t9xeAZ/uqfXpYQgSLQfaX+fk/K/FQEl4QpLk94eD1YjluFY3uLnKp40zDyxgeWRAmgTtmx1eLwediVqZ8=", + "agentid": "218" +} +第一步:准备相关参数 + +AESKey = Base64_Decode(EncodingAESKey + "=") +signature = "477715d11cdb4164915debcba66cb864d751f3e6"; +timestamps = "1409659813"; +nonce = "1372623149"; +msg_encrypt = "RypEvHKD8QQKFhvQ6QleEB4J58tiPdvo+rtK1I9qca6aM/wvqnLSV5zEPeusUiX5L5X/0lWfrf0QADHHhGd3QczcdCUpj911L3vg3W/sYYvuJTs3TUUkSUXxaccAS0qhxchrRYt66wiSpGLYL42aM6A8dTT+6k4aSknmPj48kzJs8qLjvd4Xgpue06DOdnLxAUHzM6+kDZ+HMZfJYuR+LtwGc2hgf5gsijff0ekUNXZiqATP7PF5mZxZ3Izoun1s4zG4LUMnvw2r+KqCKIw+3IQH03v+BCA9nMELNqbSf6tiWSrXJB3LAVGUcallcrw8V2t9EL4EhzJWrQUax5wLVMNS0+rUPA3k22Ncx4XXZS9o0MBH27Bo6BpNelZpS+/uh9KsNlY6bHCmJU9p8g7m3fVKn28H3KDYA5Pl/T8Z1ptDAVe0lXdQ2YoyyH2uyPIGHBZZIs2pDBS8R07+qN+E7Q=="; +第二步:校验签名 + +token、timestamp、nonce、msg_encrypt 这四个参数按照字典序排序 + +"1372623149" +"1409659813" +"QDG6eK" +"RypEvHKD8QQKFhvQ6QleEB4J58tiPdvo+rtK1I9qca6aM/wvqnLSV5zEPeusUiX5L5X/0lWfrf0QADHHhGd3QczcdCUpj911L3vg3W/sYYvuJTs3TUUkSUXxaccAS0qhxchrRYt66wiSpGLYL42aM6A8dTT+6k4aSknmPj48kzJs8qLjvd4Xgpue06DOdnLxAUHzM6+kDZ+HMZfJYuR+LtwGc2hgf5gsijff0ekUNXZiqATP7PF5mZxZ3Izoun1s4zG4LUMnvw2r+KqCKIw+3IQH03v+BCA9nMELNqbSf6tiWSrXJB3LAVGUcallcrw8V2t9EL4EhzJWrQUax5wLVMNS0+rUPA3k22Ncx4XXZS9o0MBH27Bo6BpNelZpS+/uh9KsNlY6bHCmJU9p8g7m3fVKn28H3KDYA5Pl/T8Z1ptDAVe0lXdQ2YoyyH2uyPIGHBZZIs2pDBS8R07+qN+E7Q==" + +拼接为一个字符串 + +sort_str = "13726231491409659813QDG6eKRypEvHKD8QQKFhvQ6QleEB4J58tiPdvo+rtK1I9qca6aM/wvqnLSV5zEPeusUiX5L5X/0lWfrf0QADHHhGd3QczcdCUpj911L3vg3W/sYYvuJTs3TUUkSUXxaccAS0qhxchrRYt66wiSpGLYL42aM6A8dTT+6k4aSknmPj48kzJs8qLjvd4Xgpue06DOdnLxAUHzM6+kDZ+HMZfJYuR+LtwGc2hgf5gsijff0ekUNXZiqATP7PF5mZxZ3Izoun1s4zG4LUMnvw2r+KqCKIw+3IQH03v+BCA9nMELNqbSf6tiWSrXJB3LAVGUcallcrw8V2t9EL4EhzJWrQUax5wLVMNS0+rUPA3k22Ncx4XXZS9o0MBH27Bo6BpNelZpS+/uh9KsNlY6bHCmJU9p8g7m3fVKn28H3KDYA5Pl/T8Z1ptDAVe0lXdQ2YoyyH2uyPIGHBZZIs2pDBS8R07+qN+E7Q==" + +对该字符串进行sha1计算得到签名 + +signature = sha1(sort_str) = "477715d11cdb4164915debcba66cb864d751f3e6" + +对比从URL得到的签名,发现两者一致,签名通过,说明没被篡改,是安全的 +第三步: 解密消息 + +对密文base64解码 + +aes_msg = base64_decode(msg_encrypt) + +使用AESKey做AES解密(注意,不是EncodingAESKey) + +rand_msg = aes_decrypt(aes_msg, AESKey) + +去掉rand_msg头部的16个随机字节和4个字节的msg_len,截取msg_len长度的部分即为msg,剩下的为尾部的receiveid +下面为类似python的伪代码 + +content = rand_msg[16:] # 去掉前16随机字节 +msg_len = str_to_uint(content[0:4]) # 取出4字节的msg_len +msg = content[4:msg_len+4] # 截取msg_len 长度的msg +receiveid = content[msg_len+4:] = "wx5823bf96d3bd56c7" # 剩余字节为receiveid + + +对于回调xml解密后得到明文为: + + + + + 1409659813 + + + 4561255354251345929 + 218 + + +对于回调json解密后的明文为: + { + "ToUserName": "wx5823bf96d3bd56c7", + "FromUserName": "mycreate", + "CreateTime": "1409659813", + "MsgType": "text", + "Content": "hello", + "MsgId": "4561255354251345929", + "AgentID": "218" + } + + +根据明文中的MsgType可知,此为应用消息回调,因此receiveid应该为corpid,对比receiveid与corpid是否一致。 +附注:ReceiveId 含义 +加解密库里,ReceiveId 在各个场景的含义不同: + +企业应用的回调,表示corpid +第三方事件的回调,表示suiteid +机器人场景的回调,是一个空字符串 \ No newline at end of file