36ba9338f1
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>
133 lines
4.3 KiB
Python
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
|