feat: 添加中国节假日感知定时功能

- 新增 chinese_holidays 表,通过 timor.tech API 同步节假日数据
- 修正 holiday 字段解读:holiday=true → 休息日,holiday=false → 调休工作日
- 工作日 8:00-22:00 每小时爬取,周末/节假日/夜间自动跳过
- 新增 /api/v1/holidays/sync 和 /api/v1/holidays/today 接口
- 企微菜单新增「同步节假日」按钮,支持手动触发同步
This commit is contained in:
2026-05-09 18:27:27 +08:00
parent c3e06c997f
commit 1f18d2ec87
12 changed files with 381 additions and 15 deletions
+83 -2
View File
@@ -1,7 +1,11 @@
import logging
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__)
class WeChatMessageHandler:
@@ -11,6 +15,7 @@ class WeChatMessageHandler:
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
@@ -48,10 +53,86 @@ class WeChatMessageHandler:
return encrypted
return None
def handle_event(
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)
return None
def handle_text(self, content: str, from_user: str) -> str | 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:
from app.api.deps import get_crawl_service
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)
+87
View File
@@ -0,0 +1,87 @@
import logging
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
MENU = {
"button": [
{
"name": "今日公告",
"type": "click",
"key": "today_stats",
},
{
"name": "系统管理",
"sub_button": [
{
"name": "立即爬取",
"type": "click",
"key": "trigger_crawl",
},
{
"name": "同步节假日",
"type": "click",
"key": "sync_holidays",
},
],
},
]
}
class MenuManager:
def __init__(self, client=None):
self.client = client
async def _get_token(self) -> str | None:
from app.wechat.client import WeChatClient
c = self.client or WeChatClient()
return await c._get_access_token()
async def create(self) -> bool:
token = await self._get_token()
if not token:
return False
url = "https://qyapi.weixin.qq.com/cgi-bin/menu/create"
params = {"access_token": token, "agentid": int(settings.wechat_agent_id)}
async with httpx.AsyncClient(timeout=15) as client:
response = await client.post(url, params=params, json=MENU)
data = response.json()
if data.get("errcode") == 0:
logger.info("企微菜单创建成功")
return True
logger.error(f"企微菜单创建失败: {data}")
return False
async def delete(self) -> bool:
token = await self._get_token()
if not token:
return False
url = "https://qyapi.weixin.qq.com/cgi-bin/menu/delete"
params = {"access_token": token, "agentid": int(settings.wechat_agent_id)}
async with httpx.AsyncClient(timeout=15) as client:
response = await client.get(url, params=params)
data = response.json()
return data.get("errcode") == 0
async def get(self) -> dict | None:
token = await self._get_token()
if not token:
return None
url = "https://qyapi.weixin.qq.com/cgi-bin/menu/get"
params = {"access_token": token, "agentid": int(settings.wechat_agent_id)}
async with httpx.AsyncClient(timeout=15) as client:
response = await client.get(url, params=params)
data = response.json()
if data.get("errcode") == 0:
return data
return None