da1f24c710
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>
174 lines
5.7 KiB
Python
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
|