import asyncio import logging import time import xml.etree.ElementTree as ET from app.config import settings from app.wechat.crypto import WXBizMsgCrypt from app.wechat.client import WeChatClient logger = logging.getLogger(__name__) # 防重入:记录最近一次触发爬取的时间戳,60秒内不重复执行 _last_crawl_time: float = 0.0 _crawl_lock = asyncio.Lock() class WeChatMessageHandler: def __init__(self): self.wxcpt = WXBizMsgCrypt( sToken=settings.wechat_token, sEncodingAESKey=settings.wechat_encoding_aes_key, sReceiveId=settings.wechat_corp_id, ) self.client = WeChatClient() def verify_url( self, msg_signature: str, timestamp: str, nonce: str, echostr: str ) -> str | None: ret, sEchoStr = self.wxcpt.VerifyURL( msg_signature, timestamp, nonce, echostr ) if ret == 0: return ( sEchoStr.decode("utf-8") if isinstance(sEchoStr, bytes) else sEchoStr ) return None def decrypt_message( self, post_data: str, msg_signature: str, timestamp: str, nonce: str, ) -> ET.Element | None: ret, xml_content = self.wxcpt.DecryptMsg( post_data, msg_signature, timestamp, nonce ) if ret != 0: return None return ET.fromstring(xml_content) def encrypt_response( self, response_xml: str, nonce: str, timestamp: str ) -> str | None: ret, encrypted = self.wxcpt.EncryptMsg(response_xml, nonce, timestamp) if ret == 0: return encrypted return None async def handle_event( self, event: str, event_key: str | None, from_user: str ) -> str | None: if event != "click" or not event_key: return None if event_key == "today_stats": return await self._handle_today_stats(from_user) elif event_key == "trigger_crawl": return await self._handle_trigger_crawl(from_user) elif event_key == "sync_holidays": return await self._handle_sync_holidays(from_user) elif event_key == "monitor_config": return await self._handle_monitor_config(from_user) elif event_key == "system_status": return await self._handle_system_status(from_user) elif event_key == "workday_status": return await self._handle_workday_status(from_user) elif event_key == "latest_announcements": return await self._handle_latest_announcements(from_user) elif event_key == "pause_scheduler": return await self._handle_pause_scheduler(from_user) elif event_key == "resume_scheduler": return await self._handle_resume_scheduler(from_user) return None async def handle_text(self, content: str, from_user: str) -> str | None: return None async def _handle_today_stats(self, from_user: str) -> str | None: from app.api.deps import get_db try: async for db in get_db(): from sqlalchemy import func, select from app.models.announcement import Announcement total_result = await db.execute( select(func.count()).select_from(Announcement) ) total = total_result.scalar() or 0 today_result = await db.execute( select(func.count()).where( func.date(Announcement.publish_date) == func.current_date() ).select_from(Announcement) ) today = today_result.scalar() or 0 text = f"今日新增: {today} 条\n累计公告: {total} 条" await self.client.send_text(text, from_user) except Exception as e: logger.error(f"查询统计失败: {e}") await self.client.send_text("查询失败,请稍后再试", from_user) async def _handle_trigger_crawl(self, from_user: str) -> str | None: global _last_crawl_time from app.api.deps import get_crawl_service # 防重入:企业微信会对同一事件重试多次,60秒内只执行一次 async with _crawl_lock: now = time.monotonic() if now - _last_crawl_time < 60: logger.info(f"爬取请求被忽略(防重入),距上次 {now - _last_crawl_time:.1f}s") return None _last_crawl_time = now await self.client.send_text("开始爬取,请稍候...", from_user) try: service = get_crawl_service() results = await service.run_all() total = sum(r.total_count for r in results) stored = sum( r.pipeline_result.stored for r in results if r.pipeline_result ) notified = sum( r.pipeline_result.notified for r in results if r.pipeline_result ) errors = [r.error_message for r in results if not r.success] msg = f"爬取完成\n抓取: {total} 条\n新增: {stored} 条\n推送: {notified} 条" if errors: msg += f"\n异常: {errors[0][:50]}" await self.client.send_text(msg, from_user) except Exception as e: logger.error(f"手动爬取失败: {e}") await self.client.send_text(f"爬取失败: {e}", from_user) async def _handle_sync_holidays(self, from_user: str) -> str | None: from app.services.holiday_service import now_in_china, sync_holidays from app.api.deps import get_db try: async for db in get_db(): year = now_in_china().year count = await sync_holidays(db, year) await self.client.send_text( f"已同步 {year} 年节假日\n共 {count} 条记录", from_user, ) except Exception as e: logger.error(f"同步节假日失败: {e}") await self.client.send_text(f"同步失败: {e}", from_user) async def _handle_monitor_config(self, from_user: str) -> str | None: import json try: keywords = settings.crawler_keywords sources = json.loads(settings.announcement_sources) source_names = "、".join(v["name"] for v in sources.values()) text = ( f"监控关键词: {', '.join(keywords)}\n" f"爬取页数: {settings.crawler_max_pages} 页\n" f"定时规则: {settings.scheduler_cron}\n" f"公告来源: {source_names}" ) await self.client.send_text(text, from_user) except Exception as e: logger.error(f"查询监控配置失败: {e}") await self.client.send_text("查询失败,请稍后再试", from_user) async def _handle_system_status(self, from_user: str) -> str | None: from app.api.deps import get_db from sqlalchemy import func, select from app.models.announcement import Announcement try: async for db in get_db(): total_result = await db.execute( select(func.count()).select_from(Announcement) ) total = total_result.scalar() or 0 today_result = await db.execute( select(func.count()).where( func.date(Announcement.publish_date) == func.current_date() ).select_from(Announcement) ) today = today_result.scalar() or 0 unsent_result = await db.execute( select(func.count()).where( Announcement.is_sent == False, # noqa: E712 Announcement.keyword_matched == True, # noqa: E712 ).select_from(Announcement) ) unsent = unsent_result.scalar() or 0 scheduler_status = "已启用" if settings.scheduler_enabled else "已禁用" text = ( f"累计公告: {total} 条\n" f"今日新增: {today} 条\n" f"待推送: {unsent} 条\n" f"定时任务: {scheduler_status}\n" f"定时规则: {settings.scheduler_cron}" ) await self.client.send_text(text, from_user) except Exception as e: logger.error(f"查询系统状态失败: {e}") await self.client.send_text("查询失败,请稍后再试", from_user) async def _handle_workday_status(self, from_user: str) -> str | None: from app.services.holiday_service import now_in_china, is_workday from app.api.deps import get_db try: async for db in get_db(): today = now_in_china() workday = await is_workday(db, today) status = "工作日,正常爬取" if workday else "非工作日,跳过爬取" text = f"今天 {today.strftime('%Y-%m-%d %A')}\n{status}" await self.client.send_text(text, from_user) except Exception as e: logger.error(f"查询工作日状态失败: {e}") await self.client.send_text("查询失败,请稍后再试", from_user) async def _handle_latest_announcements(self, from_user: str) -> str | None: from app.api.deps import get_db from sqlalchemy import select, desc from app.models.announcement import Announcement try: async for db in get_db(): result = await db.execute( select(Announcement) .order_by(desc(Announcement.publish_date)) .limit(6) ) items = result.scalars().all() if not items: await self.client.send_text("暂无公告", from_user) return None for ann in items: title = ann.title if len(ann.title) <= 128 else ann.title[:125] + "..." purchase_name = ann.purchase_name or "" if len(purchase_name) > 25: purchase_name = purchase_name[:22] + "..." time_str = ann.publish_date.strftime("%Y-%m-%d %H:%M") if ann.publish_date else "时间未知" source_name = ann.source_name or ann.source_code or "" description = f"{source_name} | {purchase_name} | {time_str}" await self.client.send_textcard(title, description, ann.content_url or "", from_user) except Exception as e: logger.error(f"查询最新公告失败: {e}") await self.client.send_text("查询失败,请稍后再试", from_user) async def _handle_pause_scheduler(self, from_user: str) -> str | None: try: from app.scheduler.jobs import scheduler if scheduler.running: scheduler.pause() await self.client.send_text("定时任务已暂停", from_user) else: await self.client.send_text("定时任务未在运行", from_user) except Exception as e: logger.error(f"暂停定时任务失败: {e}") await self.client.send_text(f"操作失败: {e}", from_user) async def _handle_resume_scheduler(self, from_user: str) -> str | None: try: from app.scheduler.jobs import scheduler scheduler.resume() await self.client.send_text("定时任务已恢复", from_user) except Exception as e: logger.error(f"恢复定时任务失败: {e}") await self.client.send_text(f"操作失败: {e}", from_user)