Remove legacy test script and enhance main application with WeChat server and menu management features. Added commands for starting the WeChat callback server and managing WeChat menus, along with necessary imports and error handling. Updated requirements to include Flask for the WeChat server functionality.

This commit is contained in:
2026-01-08 15:16:42 +08:00
parent 317dbf3c58
commit a8108d9afd
25 changed files with 3089 additions and 152 deletions
+231
View File
@@ -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. 网络连接和配置
+4 -1
View File
@@ -2,7 +2,10 @@
包的main入口,使项目可以直接通过 python -m gx_gp_monitor 运行 包的main入口,使项目可以直接通过 python -m gx_gp_monitor 运行
""" """
from .main import main try:
from .main import main
except ImportError:
from main import main
if __name__ == "__main__": if __name__ == "__main__":
main() main()
Binary file not shown.
+120 -1
View File
@@ -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.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 .storage.md_generator import generate_onu_md
from .notification.wechat import send_announcements_notification, send_system_notification 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: except ImportError:
try: 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.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 storage.md_generator import generate_onu_md
from notification.wechat import send_announcements_notification, send_system_notification 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: except ImportError as e:
print(f"导入错误: {e}", file=sys.stderr) print(f"导入错误: {e}", file=sys.stderr)
print("请确保依赖已正确安装: pip install -r requirements.txt", file=sys.stderr) print("请确保依赖已正确安装: pip install -r requirements.txt", file=sys.stderr)
@@ -164,6 +174,61 @@ class GXGPMonitorApp:
logger.error(f"数据清理任务执行失败: {str(e)}") logger.error(f"数据清理任务执行失败: {str(e)}")
return {"success": False, "error": 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): def show_status(self):
"""显示系统状态""" """显示系统状态"""
@@ -212,12 +277,14 @@ def create_argument_parser():
python main.py crawl --keywords "大化" # 爬取指定关键词 python main.py crawl --keywords "大化" # 爬取指定关键词
python main.py cleanup # 执行数据清理 python main.py cleanup # 执行数据清理
python main.py status # 查看系统状态 python main.py status # 查看系统状态
python main.py wechat-server # 启动企业微信回调服务器
python main.py wechat-menu --action create # 创建企业微信菜单
""" """
) )
parser.add_argument( parser.add_argument(
'command', 'command',
choices=['crawl', 'cleanup', 'status'], choices=['crawl', 'cleanup', 'status', 'wechat-server', 'wechat-menu'],
help='要执行的命令' help='要执行的命令'
) )
@@ -252,6 +319,28 @@ def create_argument_parser():
help='清理多少天前的过期数据' 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 return parser
@@ -302,6 +391,36 @@ def main():
# 显示状态 # 显示状态
app.show_status() 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: except KeyboardInterrupt:
logger.info("收到中断信号,正在退出...") logger.info("收到中断信号,正在退出...")
except Exception as e: except Exception as e:
+348 -35
View File
@@ -199,37 +199,208 @@ class WeChatService:
logger.error(f"发送Markdown消息异常: {str(e)}") logger.error(f"发送Markdown消息异常: {str(e)}")
return False 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], def send_announcement_notification(self, announcements: List[Announcement],
max_count: int = 20) -> bool: max_count: int = 20) -> bool:
""" """
发送公告通知 发送公告通知(每条公告发送一条单独的文本卡片消息)
Args: Args:
announcements: 公告列表 announcements: 公告列表
max_count: 最大显示数量 max_count: 最大显示数量
Returns: Returns:
bool: 发送是否成功 bool: 是否至少有一条消息发送成功
""" """
if not announcements: if not announcements:
logger.info("没有新公告,跳过通知") logger.info("没有新公告,跳过通知")
return True return True
try: success_count = 0
# 生成通知内容 total_count = len(announcements)
notification_content = self._generate_announcement_notification(announcements, max_count)
# 发送Markdown消息 logger.info(f"开始发送 {total_count} 条公告通知,每条单独发送")
return self.send_markdown_message(notification_content)
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: except Exception as e:
logger.error(f"发送公告通知失败: {str(e)}") logger.error(f"发送单条公告通知失败: {str(e)}")
return False 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'<div style="font-size: 14px; margin-top: 8px;">{announcement_type_display} | {source_name} | {time_str}</div>'
# URL:公告详情链接
url = announcement.content_url
return title, description, url
def _generate_announcement_notification(self, announcements: List[Announcement], def _generate_announcement_notification(self, announcements: List[Announcement],
max_count: int) -> str: max_count: int) -> str:
""" """
生成公告通知内容 生成公告通知内容(改进版)
Args: Args:
announcements: 公告列表 announcements: 公告列表
@@ -238,10 +409,19 @@ class WeChatService:
Returns: Returns:
str: Markdown格式的通知内容 str: Markdown格式的通知内容
""" """
if not announcements:
return f"""# 🔔 广西政府采购网公告更新
**暂无新公告**
---
*更新时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*
*点击公告标题查看详情*"""
# 按日期分组 # 按日期分组
today_announcements = [] today_announcements = []
other_announcements = [] other_announcements = []
today = datetime.now().date() today = datetime.now().date()
for announcement in announcements: for announcement in announcements:
@@ -252,29 +432,36 @@ class WeChatService:
lines = [] lines = []
# 标题 # 标题和概要
total_count = len(announcements) total_count = len(announcements)
lines.append(f"# 🔔 广西政府采购网公告更新") lines.append("# 🔔 广西政府采购网公告更新")
lines.append("") lines.append("")
lines.append(f"**发现 {total_count} 条新公告**") lines.append(f"📊 **发现 {total_count} 条新公告**")
lines.append("") lines.append("")
# 今日公告 # 今日公告
if today_announcements: if today_announcements:
lines.append(f"## 📅 今日公告 ({len(today_announcements)}条)") lines.append(f"## 🔥 今日公告 ({len(today_announcements)}条)")
lines.append("") lines.append("")
display_today = today_announcements[:max_count//2] display_today = today_announcements[:max_count//2]
for announcement in display_today:
for i, announcement in enumerate(display_today, 1):
# 改进标题显示:保留更多字符,但确保美观
title = announcement.title title = announcement.title
if len(title) > 40: if len(title) > 50:
title = title[:40] + "..." title = title[:47] + "..."
publish_time = announcement.publish_date.strftime("%H:%M") if announcement.publish_date else "N/A"
lines.append(f"• [{title}]({announcement.content_url}) - {publish_time}") # 显示时间
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): if len(today_announcements) > len(display_today):
lines.append(f"• ... 还有 {len(today_announcements) - len(display_today)} 条今日公告") lines.append(f"⚠️ 还有 {len(today_announcements) - len(display_today)} 条今日公告未显示")
lines.append("")
lines.append("")
# 其他公告 # 其他公告
if other_announcements: if other_announcements:
@@ -283,37 +470,163 @@ class WeChatService:
remaining_slots = max_count - len(today_announcements) if today_announcements else max_count remaining_slots = max_count - len(today_announcements) if today_announcements else max_count
display_other = other_announcements[:remaining_slots] display_other = other_announcements[:remaining_slots]
for announcement in display_other: for i, announcement in enumerate(display_other, 1):
title = announcement.title title = announcement.title
if len(title) > 40: if len(title) > 45:
title = title[:40] + "..." title = title[:42] + "..."
publish_date = announcement.publish_date.strftime("%m-%d") if announcement.publish_date else "N/A"
lines.append(f"• [{title}]({announcement.content_url}) - {publish_date}") 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): 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("## 📈 数据统计")
lines.append("")
# 按来源统计
source_stats = {} source_stats = {}
for announcement in announcements: for announcement in announcements:
source = announcement.source_name source = announcement.source_name
source_stats[source] = source_stats.get(source, 0) + 1 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()): for source, count in sorted(source_stats.items()):
lines.append(f"{source}: {count}") lines.append(f"{source}: {count}")
lines.append("") 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("") lines.append("")
lines.append(f"*更新时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*") lines.append(f"🕒 *更新时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*")
lines.append("*点击公告标题查看详情*") lines.append("💡 *点击公告标题查看详情*")
return "\n".join(lines) 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('<div class="highlight">📊 发现 {total_count} 条新公告</div>'.format(total_count=total_count))
html_parts.append("")
# 今日公告
if today_announcements:
html_parts.append('<div class="normal">🔥 今日公告 ({count}条)</div>'.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}. <a href="{url}">{title}</a>'.format(
i=i, url=announcement.content_url, title=ann_title))
html_parts.append('<div class="gray">⏰ {time} | 📍 {source}</div>'.format(
time=time_str, source=source))
if len(today_announcements) > len(display_today):
remaining = len(today_announcements) - len(display_today)
html_parts.append('<div class="gray">还有 {remaining} 条今日公告...</div>'.format(remaining=remaining))
# 其他公告
if other_announcements:
html_parts.append("")
html_parts.append('<div class="normal">📄 其他公告 ({count}条)</div>'.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}. <a href="{url}">{title}</a> <span class="gray">({date})</span>'.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('<div class="gray">还有 {remaining} 条历史公告...</div>'.format(remaining=remaining))
# 统计信息
html_parts.append("")
html_parts.append('<div class="normal">📈 数据统计</div>')
# 按来源统计
source_stats = {}
for announcement in announcements:
source = announcement.source_name
source_stats[source] = source_stats.get(source, 0) + 1
html_parts.append('<div class="gray">按来源: {stats}</div>'.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('<div class="gray">🕒 更新时间: {time}</div>'.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, def send_system_notification(self, title: str, content: str,
message_type: str = "text") -> bool: message_type: str = "text") -> bool:
""" """
+555
View File
@@ -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)
+3 -1
View File
@@ -26,11 +26,13 @@ openpyxl>=3.0.10 # Excel文件处理(可选)
cryptography>=39.0.0 # 加密库(用于微信消息加密) cryptography>=39.0.0 # 加密库(用于微信消息加密)
pycryptodome>=3.17.0 # 加密算法库 pycryptodome>=3.17.0 # 加密算法库
# Web框架(企业微信回调服务器)
flask>=2.3.0 # Web框架
# 可选依赖(根据需要安装) # 可选依赖(根据需要安装)
# redis>=4.5.0 # Redis缓存(如果需要) # redis>=4.5.0 # Redis缓存(如果需要)
# sqlalchemy>=2.0.0 # ORM(如果需要更复杂的数据库操作) # sqlalchemy>=2.0.0 # ORM(如果需要更复杂的数据库操作)
# celery>=5.3.0 # 分布式任务队列(如果需要) # celery>=5.3.0 # 分布式任务队列(如果需要)
# flask>=2.3.0 # Web框架(如果需要Web界面)
# 开发依赖(仅开发环境需要) # 开发依赖(仅开发环境需要)
# pytest>=7.2.0 # 测试框架 # pytest>=7.2.0 # 测试框架
+283
View File
@@ -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 = """<xml>
<Encrypt><![CDATA[%(msg_encrypt)s]]></Encrypt>
<MsgSignature><![CDATA[%(msg_signaturet)s]]></MsgSignature>
<TimeStamp>%(timestamp)s</TimeStamp>
<Nonce><![CDATA[%(nonce)s]]></Nonce>
</xml>"""
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:成功0sEncryptMsg,失败返回对应的错误码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
+10
View File
@@ -0,0 +1,10 @@
"""
企业微信交互模块
提供企业微信回调服务器、消息处理、菜单管理等功能
"""
from .callback_server import WeChatCallbackServer
from .message_handler import WeChatMessageHandler
from .menu_manager import WeChatMenuManager
__all__ = ['WeChatCallbackServer', 'WeChatMessageHandler', 'WeChatMenuManager']
+265
View File
@@ -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
+20
View File
@@ -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
+310
View File
@@ -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
+357
View File
@@ -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"""<xml>
<ToUserName><![CDATA[{to_user}]]></ToUserName>
<FromUserName><![CDATA[{self.config.wechat_app.corp_id}]]></FromUserName>
<CreateTime>{timestamp}</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[{content}]]></Content>
</xml>"""
return response_xml
+316
View File
@@ -212,3 +212,319 @@
2026-01-08 09:29:49 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功 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: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数据内容: <xml><ToUserName>test</ToUserName><Encrypt>test_encrypt</Encrypt><AgentID>1000007</AgentID></xml>
2026-01-08 09:48:08 - gx_gp_monitor.wechat.callback_server - ERROR - POST数据内容: <xml><ToUserName>test</ToUserName><Encrypt>test_encrypt</Encrypt><AgentID>1000007</AgentID></xml>
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数据内容: <xml><ToUserName><![CDATA[ww69e8e44636f47780]]></ToUserName><Encrypt><![CDATA[qsXwheTfCPDCLMlpIuF1IdhXu0QA3yVZuxIF26pPptK483NQIouw1vE/660S0wEB0H97eoFGY3xnBrdvsbS0dWpC8IGsGSCOKkuuePmYackfXReYGoeDHGPDYu6GOjzpHwoTeE2dXTYTv4BEm+DQWIOgvxrojQykQMQd70wlsdRo4DgGCsiiNGgRoud+YmpKpf2xG+2u9/beIuaCeSRBAK//0SrV7G2IFzxMJX38l5xTGfaiLDnrh1N4UIUA52Kn1+7pSZEh8UrgdLdtUjQwL3m5Bc/ATqnqFFFEvmyAHBuSm+dMk4AKPncO6/Qe1smGbPq2iXaSzowJmoJKbu4z3c7sisoFAMkr4+X3LhhUHGmEykiELByWjrUyLRyvCkGBfDs5Qa7RvO80HbMP8cECgSalHW+eH/OkSa6SQFG2k0OkOD71z5oUhw+raqbasVUtvoMdb3n9TLQJ41jiYyHDag==]]></Encrypt><AgentID><![CDATA[1000007]]></AgentID></xml>
2026-01-08 12:07:12 - gx_gp_monitor.wechat.callback_server - ERROR - POST数据内容: <xml><ToUserName><![CDATA[ww69e8e44636f47780]]></ToUserName><Encrypt><![CDATA[qsXwheTfCPDCLMlpIuF1IdhXu0QA3yVZuxIF26pPptK483NQIouw1vE/660S0wEB0H97eoFGY3xnBrdvsbS0dWpC8IGsGSCOKkuuePmYackfXReYGoeDHGPDYu6GOjzpHwoTeE2dXTYTv4BEm+DQWIOgvxrojQykQMQd70wlsdRo4DgGCsiiNGgRoud+YmpKpf2xG+2u9/beIuaCeSRBAK//0SrV7G2IFzxMJX38l5xTGfaiLDnrh1N4UIUA52Kn1+7pSZEh8UrgdLdtUjQwL3m5Bc/ATqnqFFFEvmyAHBuSm+dMk4AKPncO6/Qe1smGbPq2iXaSzowJmoJKbu4z3c7sisoFAMkr4+X3LhhUHGmEykiELByWjrUyLRyvCkGBfDs5Qa7RvO80HbMP8cECgSalHW+eH/OkSa6SQFG2k0OkOD71z5oUhw+raqbasVUtvoMdb3n9TLQJ41jiYyHDag==]]></Encrypt><AgentID><![CDATA[1000007]]></AgentID></xml>
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 条成功
+46
View File
@@ -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)
-114
View File
@@ -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)
+221
View File
@@ -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&timestamp=1409659813&nonce=1372623149 HTTP/1.1
Host: qy.weixin.qq.com
Content-Length: 603
<xml>
<ToUserName><![CDATA[wx5823bf96d3bd56c7]]></ToUserName>
<Encrypt><![CDATA[RypEvHKD8QQKFhvQ6QleEB4J58tiPdvo+rtK1I9qca6aM/wvqnLSV5zEPeusUiX5L5X/0lWfrf0QADHHhGd3QczcdCUpj911L3vg3W/sYYvuJTs3TUUkSUXxaccAS0qhxchrRYt66wiSpGLYL42aM6A8dTT+6k4aSknmPj48kzJs8qLjvd4Xgpue06DOdnLxAUHzM6+kDZ+HMZfJYuR+LtwGc2hgf5gsijff0ekUNXZiqATP7PF5mZxZ3Izoun1s4zG4LUMnvw2r+KqCKIw+3IQH03v+BCA9nMELNqbSf6tiWSrXJB3LAVGUcallcrw8V2t9EL4EhzJWrQUax5wLVMNS0+rUPA3k22Ncx4XXZS9o0MBH27Bo6BpNelZpS+/uh9KsNlY6bHCmJU9p8g7m3fVKn28H3KDYA5Pl/T8Z1ptDAVe0lXdQ2YoyyH2uyPIGHBZZIs2pDBS8R07+qN+E7Q==]]></Encrypt>
<AgentID><![CDATA[218]]></AgentID>
</xml>
json请求示例:
注意这里的 tousernameencryptagentid均为小写
POST /cgi-bin/wxpush?msg_signature=477715d11cdb4164915debcba66cb864d751f3e6&timestamp=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解密后得到明文为:
<xml>
<ToUserName><![CDATA[wx5823bf96d3bd56c7]]></ToUserName>
<FromUserName><![CDATA[mycreate]]></FromUserName>
<CreateTime>1409659813</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[hello]]></Content>
<MsgId>4561255354251345929</MsgId>
<AgentID>218</AgentID>
</xml>
对于回调json解密后的明文为:
{
"ToUserName": "wx5823bf96d3bd56c7",
"FromUserName": "mycreate",
"CreateTime": "1409659813",
"MsgType": "text",
"Content": "hello",
"MsgId": "4561255354251345929",
"AgentID": "218"
}
根据明文中的MsgType可知,此为应用消息回调,因此receiveid应该为corpid,对比receiveid与corpid是否一致。
附注:ReceiveId 含义
加解密库里,ReceiveId 在各个场景的含义不同:
企业应用的回调,表示corpid
第三方事件的回调,表示suiteid
机器人场景的回调,是一个空字符串