Files
qiji/backend/app/api/wecom.py
T
v6ole da1f24c710 feat: 企微菜单管理 — create_menu / get_menu / delete_menu
wecom.py 新增:
- create_menu(buttons) — 创建/替换应用自定义菜单
- get_menu() — 获取当前菜单配置
- delete_menu() — 删除菜单

API 新增:
- POST /api/wecom/setup-menu — 一键部署标准菜单(开始填报 + 绑定账号)
- GET /api/wecom/menu — 查看当前菜单

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 11:36:16 +08:00

174 lines
5.7 KiB
Python

import hashlib
import struct
import base64
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse
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)
# Must return raw plaintext (not JSON) — WeChat Work requires exact match
return PlainTextResponse(content=plaintext, status_code=200)
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("/test-message")
async def test_message(
wecom_userid: str,
current_user: dict = Depends(require_director),
):
"""Send a test message to a specific wecom_userid for debugging."""
success = await wecom_client.send_text_message(
[wecom_userid],
"🧪 企迹周报系统 — 测试消息\n\n如果您收到此消息,说明企微消息推送配置成功!"
)
return {"success": success, "sent_to": wecom_userid}
@router.post("/setup-menu")
async def setup_menu(current_user: dict = Depends(require_director)):
"""Create the standard app menu (开始填报 + 绑定账号)."""
buttons = [
{
"type": "view",
"name": "📝 开始填报",
"url": "https://qj.dhdx.fun/m",
},
{
"type": "view",
"name": "🔗 绑定账号",
"url": "https://qj.dhdx.fun/bind-wechat",
},
]
success = await wecom_client.create_menu(buttons)
return {"success": success, "message": "菜单已部署" if success else "菜单创建失败"}
@router.get("/menu")
async def get_menu(current_user: dict = Depends(require_director)):
"""Get current app menu configuration."""
menu = await wecom_client.get_menu()
return menu or {"message": "No menu configured"}
@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