Files
qiji/backend/app/api/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

133 lines
4.3 KiB
Python

import hashlib
import struct
import base64
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.config import settings
from app.database import get_db
from app.middleware.auth import get_current_user, require_director
from app.models.user import User
from app.services.wecom import wecom_client
from app.services.scheduler import check_daily_reporting
router = APIRouter(prefix="/wecom", tags=["WeChatWork"])
class RemindRequest(BaseModel):
user_ids: list[str]
message: Optional[str] = None
class AnnouncementRequest(BaseModel):
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")
async def send_reminder(
data: RemindRequest,
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Director manually sends reminder to specific managers."""
result = await db.execute(
select(User.wecom_userid).where(User.id.in_([uuid.UUID(uid) for uid in data.user_ids]))
)
wecom_ids = [r[0] for r in result.all() if r[0]]
content = data.message or "📋 请及时完成今日拜访记录填报。"
success = await wecom_client.send_text_message(wecom_ids, content)
return {"success": success, "sent_to": len(wecom_ids)}
@router.post("/announcement")
async def send_announcement(
data: AnnouncementRequest,
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Director sends an announcement to all team members."""
result = await db.execute(select(User.wecom_userid).where(User.wecom_userid.isnot(None)))
wecom_ids = [r[0] for r in result.all()]
content = f"📢 支局长公告\n\n{data.content}"
success = await wecom_client.send_text_message(wecom_ids, content)
return {"success": success, "sent_to": len(wecom_ids)}
@router.post("/trigger-daily-check")
async def trigger_daily_check(
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Manually trigger the daily reporting check (for testing or manual use)."""
result = await check_daily_reporting(db)
return result