feat: 企业微信 Phase 1 — token 续期 + 回调验证 + 调度器注册

wecom.py:
- Token 自动续期 (7200s 过期 + 60s 提前量 + 42001 重试)
- POST 请求自动重试 (最多 3 次,含速率限制退避)
- 新增 send_markdown_message() / send_template_card() 富消息方法

wecom.py API:
- GET /api/wecom/callback: 企微回调 URL 验证 (SHA1 签名 + AES 解密)
- POST /api/wecom/callback: 事件接收占位

scheduler.py:
- 仅推送给已绑定 wecom_userid 的经理
- 消息内容优化 (已填报/未填报人数统计)

main.py:
- APScheduler 注册每日 17:30 自动检查填报

已验证: WECOM_TOKEN 获取成功

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-26 09:37:13 +08:00
parent 72fb7524f3
commit 36ba9338f1
5 changed files with 232 additions and 35 deletions
+67 -3
View File
@@ -1,9 +1,13 @@
import hashlib
import struct
import base64
import uuid import uuid
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.config import settings
from app.database import get_db from app.database import get_db
from app.middleware.auth import get_current_user, require_director from app.middleware.auth import get_current_user, require_director
from app.models.user import User from app.models.user import User
@@ -22,6 +26,68 @@ class AnnouncementRequest(BaseModel):
content: str content: str
# ── Callback verification helpers ──
def _verify_signature(token: str, timestamp: str, nonce: str, encrypted: str, signature: str) -> bool:
"""Verify WeChat Work callback signature."""
params = sorted([token, timestamp, nonce, encrypted])
raw = "".join(params)
computed = hashlib.sha1(raw.encode()).hexdigest()
return computed == signature
def _decrypt_echostr(encrypted: str) -> str:
"""Decrypt WeChat Work callback echostr. Returns plaintext."""
key = base64.b64decode(settings.WECOM_ENCODING_AES_KEY + "=")
ciphertext = base64.b64decode(encrypted)
# AES-256-CBC decrypt
from Crypto.Cipher import AES
cipher = AES.new(key, AES.MODE_CBC, iv=key[:16])
plaintext = cipher.decrypt(ciphertext)
# Strip PKCS#7 padding
pad_len = plaintext[-1]
plaintext = plaintext[:-pad_len]
# Format: random(16) + msg_len(4) + msg + corpid
msg_len = struct.unpack(">I", plaintext[16:20])[0]
msg = plaintext[20:20 + msg_len].decode("utf-8")
return msg
@router.get("/callback")
async def wecom_callback_verify(
msg_signature: str = Query(...),
timestamp: str = Query(...),
nonce: str = Query(...),
echostr: str = Query(...),
):
"""WeChat Work callback URL verification (GET)."""
token = settings.WECOM_TOKEN
if not token or not settings.WECOM_ENCODING_AES_KEY:
raise HTTPException(status_code=500, detail="WeCom callback not configured")
if not _verify_signature(token, timestamp, nonce, echostr, msg_signature):
raise HTTPException(status_code=403, detail="Signature verification failed")
try:
plaintext = _decrypt_echostr(echostr)
return plaintext
except Exception as e:
raise HTTPException(status_code=500, detail=f"Decrypt failed: {e}")
@router.post("/callback")
async def wecom_callback_event(request: Request):
"""WeChat Work callback event receiver (POST). Placeholder for future use."""
body = await request.body()
# TODO: decrypt and handle events (app install, user enter, etc.)
return "ok"
# ── Manual actions ──
@router.post("/remind") @router.post("/remind")
async def send_reminder( async def send_reminder(
data: RemindRequest, data: RemindRequest,
@@ -29,7 +95,6 @@ async def send_reminder(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Director manually sends reminder to specific managers.""" """Director manually sends reminder to specific managers."""
# Get wecom_userids for the selected users
result = await db.execute( result = await db.execute(
select(User.wecom_userid).where(User.id.in_([uuid.UUID(uid) for uid in data.user_ids])) select(User.wecom_userid).where(User.id.in_([uuid.UUID(uid) for uid in data.user_ids]))
) )
@@ -48,7 +113,6 @@ async def send_announcement(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Director sends an announcement to all team members.""" """Director sends an announcement to all team members."""
# Get all wecom_userids in the department
result = await db.execute(select(User.wecom_userid).where(User.wecom_userid.isnot(None))) result = await db.execute(select(User.wecom_userid).where(User.wecom_userid.isnot(None)))
wecom_ids = [r[0] for r in result.all()] wecom_ids = [r[0] for r in result.all()]
+18 -1
View File
@@ -1,11 +1,21 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.config import settings from app.config import settings
from app.database import engine, Base from app.database import engine, Base, async_session
from app.api import router as api_router from app.api import router as api_router
from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits
from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary
from app.services.scheduler import check_daily_reporting
_scheduler = AsyncIOScheduler()
async def _scheduled_check():
"""Wrapper for APScheduler: create a fresh session and run the daily check."""
async with async_session() as db:
await check_daily_reporting(db)
@asynccontextmanager @asynccontextmanager
@@ -28,8 +38,15 @@ async def lifespan(app: FastAPI):
await conn.run_sync(lambda c, t=tbl: c.exec_driver_sql( await conn.run_sync(lambda c, t=tbl: c.exec_driver_sql(
f"ALTER TABLE {t} ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'" f"ALTER TABLE {t} ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'"
)) ))
# Start daily reporting scheduler (17:30 CST = 09:30 UTC)
_scheduler.add_job(_scheduled_check, "cron", hour=17, minute=30, id="daily_check")
_scheduler.start()
yield yield
# Shutdown # Shutdown
_scheduler.shutdown(wait=False)
await engine.dispose() await engine.dispose()
+20 -9
View File
@@ -15,8 +15,10 @@ async def check_daily_reporting(db: AsyncSession) -> dict:
if weekday >= 5: # Skip weekends if weekday >= 5: # Skip weekends
return {"status": "weekend", "date": str(today)} return {"status": "weekend", "date": str(today)}
# Get all managers # Get all managers with wecom_userid
result = await db.execute(select(User).where(User.role == "manager")) result = await db.execute(
select(User).where(User.role == "manager", User.wecom_userid.isnot(None))
)
managers = result.scalars().all() managers = result.scalars().all()
# Get managers who have reported today # Get managers who have reported today
@@ -31,16 +33,25 @@ async def check_daily_reporting(db: AsyncSession) -> dict:
reported_map[str(uid)] = True reported_map[str(uid)] = True
not_reported = [] not_reported = []
reported_names = []
for m in managers: for m in managers:
if str(m.id) not in reported_map: if str(m.id) in reported_map:
reported_names.append(m.name)
else:
not_reported.append(m) not_reported.append(m)
if not_reported and managers: # Send markdown message to not-reported managers
content = f"📋 今日填报提醒({today}\n\n以下同事尚未提交今日拜访记录:\n" if not_reported:
for m in not_reported: names = "".join(m.name for m in not_reported)
content += f"{m.name}\n" content = (
content += "\n请尽快完成今日拜访填报 🙏" f"## 📋 今日填报提醒\n\n"
f"> 日期:{today}\n"
f"> 已填报:{len(reported_names)}\n"
f"> 未填报:**{len(not_reported)} 人**\n\n"
f"以下同事尚未提交今日拜访记录:\n"
+ "".join(f"- **{m.name}**\n" for m in not_reported)
+ f"\n请尽快完成今日拜访填报 🙏"
)
user_ids = [m.wecom_userid for m in not_reported if m.wecom_userid] user_ids = [m.wecom_userid for m in not_reported if m.wecom_userid]
if user_ids: if user_ids:
await wecom_client.send_text_message(user_ids, content) await wecom_client.send_text_message(user_ids, content)
+126 -22
View File
@@ -1,55 +1,159 @@
import time
import httpx import httpx
from app.config import settings from app.config import settings
class WecomClient: class WecomClient:
"""Minimal WeChat Work API client for sending app messages.""" """WeChat Work API client — token management, message sending, OAuth."""
def __init__(self): def __init__(self):
self.corp_id = settings.WECOM_CORP_ID self.corp_id = settings.WECOM_CORP_ID
self.agent_id = settings.WECOM_AGENT_ID self.agent_id = settings.WECOM_AGENT_ID
self.secret = settings.WECOM_SECRET self.secret = settings.WECOM_SECRET
self._access_token: str | None = None self._access_token: str | None = None
self._token_expires_at: float = 0 # epoch seconds
async def _get_token(self) -> str: async def _get_token(self) -> str:
if self._access_token: """Get a valid access token, refreshing if expired."""
# Token valid for 7200s; refresh 60s early to be safe
if self._access_token and time.time() < self._token_expires_at - 60:
return self._access_token return self._access_token
url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corp_id}&corpsecret={self.secret}" url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corp_id}&corpsecret={self.secret}"
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10) resp = await client.get(url, timeout=10)
data = resp.json() data = resp.json()
if data.get("errcode") == 0: if data.get("errcode") == 0:
self._access_token = data["access_token"] self._access_token = data["access_token"]
self._token_expires_at = time.time() + data.get("expires_in", 7200)
return self._access_token return self._access_token
raise Exception(f"Failed to get wecom token: {data}") raise Exception(f"Failed to get wecom token: {data}")
async def _post_with_retry(self, url: str, body: dict, max_retries: int = 3) -> dict:
"""POST with retry on network errors and automatic token refresh on 42001."""
last_error = None
for attempt in range(max_retries):
try:
token = await self._get_token()
full_url = f"{url}?access_token={token}"
async with httpx.AsyncClient() as client:
resp = await client.post(full_url, json=body, timeout=10)
data = resp.json()
errcode = data.get("errcode", 0)
if errcode == 0:
return data
# Token expired mid-request — clear and retry once
if errcode == 42001:
self._access_token = None
self._token_expires_at = 0
if attempt < max_retries - 1:
continue
# Rate limit — wait and retry
if errcode == 45009:
if attempt < max_retries - 1:
await httpx.AsyncClient().aclose()
time.sleep(1 * (attempt + 1))
continue
last_error = data
except (httpx.TimeoutException, httpx.ConnectError) as e:
last_error = {"errcode": -1, "errmsg": str(e)}
if attempt < max_retries - 1:
time.sleep(0.5 * (attempt + 1))
raise Exception(f"WeCom API error after {max_retries} retries: {last_error}")
async def get_userinfo_by_code(self, code: str) -> dict | None: async def get_userinfo_by_code(self, code: str) -> dict | None:
"""Exchange OAuth2 code for userid (used in silent login).""" """Exchange OAuth2 code for userid (used in silent login)."""
token = await self._get_token() try:
url = f"https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token={token}&code={code}" token = await self._get_token()
async with httpx.AsyncClient() as client: url = f"https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token={token}&code={code}"
resp = await client.get(url, timeout=10) async with httpx.AsyncClient() as client:
data = resp.json() resp = await client.get(url, timeout=10)
if data.get("errcode") == 0: data = resp.json()
return data if data.get("errcode") == 0:
return data
return None
except Exception:
return None return None
async def send_text_message(self, user_ids: list[str], content: str) -> bool: async def send_text_message(self, user_ids: list[str], content: str) -> bool:
"""Send a text app message to specified users.""" """Send a text app message to specified users."""
if not settings.WECOM_AGENT_ID: if not settings.WECOM_AGENT_ID:
return False # Not configured, skip silently in dev return False
token = await self._get_token() try:
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}" body = {
body = { "touser": "|".join(user_ids),
"touser": "|".join(user_ids), "msgtype": "text",
"msgtype": "text", "agentid": int(settings.WECOM_AGENT_ID),
"agentid": int(settings.WECOM_AGENT_ID), "text": {"content": content},
"text": {"content": content}, }
} await self._post_with_retry(
async with httpx.AsyncClient() as client: "https://qyapi.weixin.qq.com/cgi-bin/message/send", body
resp = await client.post(url, json=body, timeout=10) )
data = resp.json() return True
return data.get("errcode") == 0 except Exception:
return False
async def send_markdown_message(self, content: str) -> bool:
"""Send a markdown message to all users in the app (broadcast)."""
if not settings.WECOM_AGENT_ID:
return False
try:
body = {
"touser": "@all",
"msgtype": "markdown",
"agentid": int(settings.WECOM_AGENT_ID),
"markdown": {"content": content},
}
await self._post_with_retry(
"https://qyapi.weixin.qq.com/cgi-bin/message/send", body
)
return True
except Exception:
return False
async def send_template_card(
self,
user_ids: list[str],
title: str,
desc: str,
url: str,
btn_text: str = "查看详情",
) -> bool:
"""Send a text_notice template card with a deep-link button."""
if not settings.WECOM_AGENT_ID:
return False
try:
body = {
"touser": "|".join(user_ids),
"msgtype": "template_card",
"agentid": int(settings.WECOM_AGENT_ID),
"template_card": {
"card_type": "text_notice",
"main_title": {"title": title, "desc": desc},
"card_action": {
"type": 1, # jump to URL
"url": url,
},
"button_list": [
{
"text": btn_text,
"style": 1, # primary
"key": "open_url",
}
],
},
}
await self._post_with_retry(
"https://qyapi.weixin.qq.com/cgi-bin/message/send", body
)
return True
except Exception:
return False
wecom_client = WecomClient() wecom_client = WecomClient()
+1
View File
@@ -12,3 +12,4 @@ minio==7.2.10
openpyxl==3.1.5 openpyxl==3.1.5
apscheduler==3.11.0 apscheduler==3.11.0
python-dotenv==1.0.1 python-dotenv==1.0.1
pycryptodome