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:
@@ -1,9 +1,13 @@
|
||||
import hashlib
|
||||
import struct
|
||||
import base64
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
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
|
||||
@@ -22,6 +26,68 @@ 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,
|
||||
@@ -29,7 +95,6 @@ async def send_reminder(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Director manually sends reminder to specific managers."""
|
||||
# Get wecom_userids for the selected users
|
||||
result = await db.execute(
|
||||
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),
|
||||
):
|
||||
"""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)))
|
||||
wecom_ids = [r[0] for r in result.all()]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user