Files
GX-gp-notify/app/wechat/handler.py
T
v6ole 4f16134955 fix: 最新公告防重入覆盖整个函数,推送改为时间升序间隔1秒
- 防重入锁移到函数入口,60秒内重复点击直接忽略,避免企微重试导致重复推送
- 取最新6条后按 publish_date 升序推送,企微向上滑即为时间正序
- 每条间隔1秒发送

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 17:59:10 +08:00

315 lines
13 KiB
Python

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()
# 防重入:最新公告爬取,60秒内不重复执行
_last_latest_time: float = 0.0
_latest_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:
global _last_latest_time
from app.api.deps import get_db, get_crawl_service
from sqlalchemy import select, asc, or_
from app.models.announcement import Announcement
# 防重入:60秒内整个函数只执行一次(含推送)
async with _latest_lock:
now = time.monotonic()
if (now - _last_latest_time) < 60:
logger.info("最新公告请求被忽略(防重入)")
return None
_last_latest_time = now
try:
await self.client.send_text("正在获取最新公告,请稍候...", from_user)
service = get_crawl_service()
await service.run_all()
# 查:广西政采网关键词匹配 + 大化县政府网全部,取最新6条后按时间升序推送
async for db in get_db():
from sqlalchemy import select, desc, or_
result = await db.execute(
select(Announcement)
.where(or_(
Announcement.keyword_matched == True, # noqa: E712
Announcement.source_code == "dahuagov",
))
.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 sorted(items, key=lambda a: a.publish_date):
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)
await asyncio.sleep(1)
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)