Files
qiji/backend/app/services/wecom.py
T
v6ole 36ba9338f1 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>
2026-06-26 09:37:13 +08:00

160 lines
5.9 KiB
Python

import time
import httpx
from app.config import settings
class WecomClient:
"""WeChat Work API client — token management, message sending, OAuth."""
def __init__(self):
self.corp_id = settings.WECOM_CORP_ID
self.agent_id = settings.WECOM_AGENT_ID
self.secret = settings.WECOM_SECRET
self._access_token: str | None = None
self._token_expires_at: float = 0 # epoch seconds
async def _get_token(self) -> str:
"""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
url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corp_id}&corpsecret={self.secret}"
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10)
data = resp.json()
if data.get("errcode") == 0:
self._access_token = data["access_token"]
self._token_expires_at = time.time() + data.get("expires_in", 7200)
return self._access_token
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:
"""Exchange OAuth2 code for userid (used in silent login)."""
try:
token = await self._get_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token={token}&code={code}"
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10)
data = resp.json()
if data.get("errcode") == 0:
return data
return None
except Exception:
return None
async def send_text_message(self, user_ids: list[str], content: str) -> bool:
"""Send a text app message to specified users."""
if not settings.WECOM_AGENT_ID:
return False
try:
body = {
"touser": "|".join(user_ids),
"msgtype": "text",
"agentid": int(settings.WECOM_AGENT_ID),
"text": {"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_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()