feat(wechat): 新增企微菜单功能并修复重复触发问题

- 新增「查询」菜单组:监控配置、系统状态、今日工作日、最新公告
- 新增「系统管理」子菜单:暂停/恢复定时任务处理器
- 修复立即爬取被企微重试机制触发多次的问题(60秒防重入锁)
- docker-compose 补充 image 名称和 container_name

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 13:59:57 +08:00
parent d84c838e76
commit 9f90e16661
3 changed files with 177 additions and 0 deletions
+150
View File
@@ -1,4 +1,6 @@
import asyncio
import logging import logging
import time
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from app.config import settings from app.config import settings
@@ -7,6 +9,10 @@ from app.wechat.client import WeChatClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 防重入:记录最近一次触发爬取的时间戳,60秒内不重复执行
_last_crawl_time: float = 0.0
_crawl_lock = asyncio.Lock()
class WeChatMessageHandler: class WeChatMessageHandler:
def __init__(self): def __init__(self):
@@ -65,6 +71,18 @@ class WeChatMessageHandler:
return await self._handle_trigger_crawl(from_user) return await self._handle_trigger_crawl(from_user)
elif event_key == "sync_holidays": elif event_key == "sync_holidays":
return await self._handle_sync_holidays(from_user) 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 return None
async def handle_text(self, content: str, from_user: str) -> str | None: async def handle_text(self, content: str, from_user: str) -> str | None:
@@ -97,8 +115,17 @@ class WeChatMessageHandler:
await self.client.send_text("查询失败,请稍后再试", from_user) await self.client.send_text("查询失败,请稍后再试", from_user)
async def _handle_trigger_crawl(self, from_user: str) -> str | None: async def _handle_trigger_crawl(self, from_user: str) -> str | None:
global _last_crawl_time
from app.api.deps import get_crawl_service 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) await self.client.send_text("开始爬取,请稍候...", from_user)
try: try:
@@ -136,3 +163,126 @@ class WeChatMessageHandler:
except Exception as e: except Exception as e:
logger.error(f"同步节假日失败: {e}") logger.error(f"同步节假日失败: {e}")
await self.client.send_text(f"同步失败: {e}", from_user) 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():
stmt = (
select(Announcement)
.where(Announcement.keyword_matched == True) # noqa: E712
.order_by(desc(Announcement.publish_date))
.limit(5)
)
result = await db.execute(stmt)
items = result.scalars().all()
if not items:
await self.client.send_text("暂无匹配关键词的公告", from_user)
return None
lines = ["最新匹配公告(最近5条):"]
for i, ann in enumerate(items, 1):
date_str = ann.publish_date.strftime("%m-%d") if ann.publish_date else "?"
title = ann.title[:30] + "..." if len(ann.title) > 30 else ann.title
lines.append(f"{i}. [{date_str}] {title}")
await self.client.send_text("\n".join(lines), 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)
+25
View File
@@ -13,6 +13,31 @@ MENU = {
"type": "click", "type": "click",
"key": "today_stats", "key": "today_stats",
}, },
{
"name": "查询",
"sub_button": [
{
"name": "监控配置",
"type": "click",
"key": "monitor_config",
},
{
"name": "系统状态",
"type": "click",
"key": "system_status",
},
{
"name": "今日工作日",
"type": "click",
"key": "workday_status",
},
{
"name": "最新公告",
"type": "click",
"key": "latest_announcements",
},
],
},
{ {
"name": "系统管理", "name": "系统管理",
"sub_button": [ "sub_button": [
+2
View File
@@ -3,6 +3,8 @@ services:
build: build:
context: .. context: ..
dockerfile: docker/Dockerfile dockerfile: docker/Dockerfile
image: gx-gp-notify:latest
container_name: gx-gp-notify
ports: ports:
- "18001:8000" - "18001:8000"
env_file: env_file: