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