1ec20ee53e
保存合并前工作树中遗留的进行中改动,避免分支合并时丢失: - scheduler: 填报汇总改为定向推送给支局长/领导(而非广播@all) - router: JWT token 校验修复,bind token(UUID)不被误剥离 - 工作计划/计划列表: 搜索+筛选+分页 UI - 各列表 API 增加 search 参数支持 - docker-compose.backend.yml + backend/.dockerignore 纳入版本管理 Co-Authored-By: Claude <noreply@anthropic.com>
363 lines
13 KiB
Python
363 lines
13 KiB
Python
import hashlib
|
|
import hmac
|
|
import struct
|
|
import base64
|
|
import uuid
|
|
from typing import Optional
|
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
from defusedxml import ElementTree as ET
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from fastapi.responses import PlainTextResponse, HTMLResponse
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from app.config import settings
|
|
from app.database import async_session, 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, store_bind_token, consume_bind_token
|
|
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
|
|
|
|
|
|
class BindConfirmRequest(BaseModel):
|
|
token: str
|
|
|
|
|
|
# ── Crypto helpers ──
|
|
|
|
def _verify_signature(token: str, timestamp: str, nonce: str, encrypted: str, signature: str) -> bool:
|
|
items = sorted([token, timestamp, nonce, encrypted])
|
|
raw = "".join(items)
|
|
# The WeCom callback protocol mandates SHA-1; compare_digest avoids timing leaks.
|
|
expected = hashlib.sha1(raw.encode(), usedforsecurity=False).hexdigest()
|
|
return hmac.compare_digest(expected, signature)
|
|
|
|
|
|
def _decrypt_msg(encrypted: str) -> str:
|
|
"""Decrypt WeChat Work callback message. Returns plaintext XML."""
|
|
key = base64.b64decode(settings.WECOM_ENCODING_AES_KEY + "=")
|
|
ciphertext = base64.b64decode(encrypted)
|
|
decryptor = Cipher(algorithms.AES(key), modes.CBC(key[:16])).decryptor()
|
|
plaintext = decryptor.update(ciphertext) + decryptor.finalize()
|
|
pad_len = plaintext[-1]
|
|
if pad_len < 1 or pad_len > 32 or plaintext[-pad_len:] != bytes([pad_len]) * pad_len:
|
|
raise ValueError("Invalid callback padding")
|
|
plaintext = plaintext[:-pad_len]
|
|
msg_len = struct.unpack(">I", plaintext[16:20])[0]
|
|
return plaintext[20:20 + msg_len].decode("utf-8")
|
|
|
|
|
|
# ── Callback ──
|
|
|
|
@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 mismatch")
|
|
try:
|
|
plaintext = _decrypt_msg(echostr)
|
|
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). Handles menu clicks and text messages."""
|
|
token = settings.WECOM_TOKEN
|
|
if not token or not settings.WECOM_ENCODING_AES_KEY:
|
|
return PlainTextResponse(content="success")
|
|
|
|
# Parse XML envelope
|
|
try:
|
|
root = ET.fromstring(await request.body())
|
|
encrypt = root.findtext("Encrypt", "")
|
|
except ET.ParseError:
|
|
return PlainTextResponse(content="success")
|
|
|
|
# Verify signature
|
|
msg_signature = request.query_params.get("msg_signature", "")
|
|
timestamp = request.query_params.get("timestamp", "")
|
|
nonce = request.query_params.get("nonce", "")
|
|
if not _verify_signature(token, timestamp, nonce, encrypt, msg_signature):
|
|
return PlainTextResponse(content="success")
|
|
|
|
# Decrypt inner XML
|
|
try:
|
|
xml_str = _decrypt_msg(encrypt)
|
|
msg = ET.fromstring(xml_str)
|
|
except Exception:
|
|
return PlainTextResponse(content="success")
|
|
|
|
msg_type = msg.findtext("MsgType", "")
|
|
from_user = msg.findtext("FromUserName", "")
|
|
content = (msg.findtext("Content", "") or "").strip()
|
|
event = (msg.findtext("Event", "") or "").strip()
|
|
event_key = (msg.findtext("EventKey", "") or "").strip()
|
|
|
|
import logging
|
|
_log = logging.getLogger("wecom_callback")
|
|
_log.info("wecom callback received: type=%s event=%s key=%s", msg_type, event, event_key)
|
|
|
|
# Triggers for binding
|
|
if (msg_type == "text" and content == "绑定") or (msg_type == "event" and event == "click" and event_key == "BIND_ACCOUNT"):
|
|
_log.info("wecom bind triggered")
|
|
await _handle_bind_request(from_user)
|
|
return PlainTextResponse(content="success")
|
|
|
|
_log.info("wecom message ignored (no matching action)")
|
|
return PlainTextResponse(content="success")
|
|
|
|
|
|
async def _handle_bind_request(wecom_userid: str):
|
|
"""Generate bind token, store it, and push a bind link to the user."""
|
|
if not wecom_userid:
|
|
return
|
|
|
|
async with async_session() as db:
|
|
bind_token = await store_bind_token(db, wecom_userid)
|
|
|
|
content = (
|
|
f"【账号绑定】\n\n"
|
|
f"请在10分钟内点击以下链接完成账号绑定:\n"
|
|
f"https://qj.dhdx.fun/wecom-bind?token={bind_token}\n\n"
|
|
f"绑定后可使用企微一键登录,并接收填报提醒通知。"
|
|
)
|
|
|
|
await wecom_client.send_text_message([wecom_userid], content)
|
|
|
|
|
|
# ── Bind confirmation (JWT-protected) ──
|
|
|
|
@router.post("/bind-confirm")
|
|
async def bind_confirm(
|
|
data: BindConfirmRequest,
|
|
current_user: dict = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""User confirms binding after clicking the link in WeChat Work message."""
|
|
if not data.token:
|
|
raise HTTPException(status_code=400, detail="token 不能为空")
|
|
|
|
wecom_userid = await consume_bind_token(db, data.token)
|
|
if not wecom_userid:
|
|
raise HTTPException(status_code=400, detail="绑定链接已过期或无效,请重新在企微发送「绑定」")
|
|
|
|
# Check if this wecom_userid is already bound to another user
|
|
result = await db.execute(
|
|
select(User).where(User.wecom_userid == wecom_userid)
|
|
)
|
|
existing = result.scalar_one_or_none()
|
|
if existing and str(existing.id) != current_user["user_id"]:
|
|
raise HTTPException(status_code=400, detail=f"该企业微信已绑定账号「{existing.name}」")
|
|
|
|
# Bind
|
|
result = await db.execute(
|
|
select(User).where(User.id == uuid.UUID(current_user["user_id"]))
|
|
)
|
|
user = result.scalar_one()
|
|
user.wecom_userid = wecom_userid
|
|
try:
|
|
await db.commit()
|
|
except IntegrityError as exc:
|
|
await db.rollback()
|
|
raise HTTPException(status_code=409, detail="该企业微信已绑定其他账号") from exc
|
|
|
|
return {"code": 200, "msg": "绑定成功", "wecom_userid": wecom_userid}
|
|
|
|
|
|
# ── Manual actions (director only) ──
|
|
|
|
@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_template_card(
|
|
user_ids=wecom_ids,
|
|
title="📋 填报提醒",
|
|
desc=content,
|
|
url="https://qj.dhdx.fun/m",
|
|
btn_text="去填报",
|
|
)
|
|
if not success:
|
|
success = await wecom_client.send_text_message(wecom_ids, content)
|
|
|
|
return {"success": success, "sent_to": len(wecom_ids), "rich": True}
|
|
|
|
|
|
@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 (markdown)."""
|
|
result = await db.execute(select(User.wecom_userid).where(User.wecom_userid.isnot(None)))
|
|
wecom_ids = [r[0] for r in result.all()]
|
|
|
|
markdown = f"## 📢 支局长公告\n\n{data.content}"
|
|
success = await wecom_client.send_markdown_message(markdown)
|
|
if not success:
|
|
success = await wecom_client.send_text_message(wecom_ids, f"📢 支局长公告\n\n{data.content}")
|
|
|
|
return {"success": success, "sent_to": len(wecom_ids), "rich": True}
|
|
|
|
|
|
@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 (开始填报 view + 绑定账号 click)."""
|
|
oauth_url = _build_oauth_url()
|
|
buttons = [
|
|
{"type": "view", "name": "开始填报", "url": oauth_url},
|
|
{"type": "click", "name": "绑定账号", "key": "BIND_ACCOUNT"},
|
|
]
|
|
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
|
|
|
|
|
|
# ── OAuth silent login ──
|
|
|
|
import urllib.parse
|
|
from app.services.auth import build_token_for_user
|
|
|
|
|
|
def _build_oauth_url(redirect_path: str = "/m") -> str:
|
|
"""Build the WeChat Work OAuth2 authorize URL (snsapi_base = silent).
|
|
The redirect target is encoded in the 'state' param to avoid query-string issues
|
|
with the OAuth redirect_uri validation."""
|
|
if not settings.WECOM_CORP_ID:
|
|
return f"https://qj.dhdx.fun{redirect_path}"
|
|
params = urllib.parse.urlencode({
|
|
"appid": settings.WECOM_CORP_ID,
|
|
"redirect_uri": "https://qj.dhdx.fun/api/wecom/oauth-callback",
|
|
"response_type": "code",
|
|
"scope": "snsapi_base",
|
|
"agentid": settings.WECOM_AGENT_ID,
|
|
"state": f"r={urllib.parse.quote(redirect_path)}",
|
|
})
|
|
return f"https://open.weixin.qq.com/connect/oauth2/authorize?{params}#wechat_redirect"
|
|
|
|
|
|
@router.get("/oauth-url")
|
|
async def get_oauth_url(redirect: str = Query("/m")):
|
|
"""Redirect to the WeChat Work OAuth authorize URL (silent login)."""
|
|
from fastapi.responses import RedirectResponse
|
|
return RedirectResponse(url=_build_oauth_url(redirect))
|
|
|
|
|
|
@router.get("/oauth-callback")
|
|
async def oauth_callback(
|
|
code: str = Query(...),
|
|
state: str = Query(""),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""WeChat Work OAuth2 callback — exchange code for userid, find bound user, auto-login."""
|
|
# Parse redirect target from state param: "r=%2Fm"
|
|
redirect = "/m"
|
|
if state.startswith("r="):
|
|
redirect = urllib.parse.unquote(state[2:])
|
|
|
|
# Exchange code for wecom userinfo
|
|
userinfo = await wecom_client.get_userinfo_by_code(code)
|
|
if not userinfo:
|
|
raise HTTPException(status_code=400, detail="Failed to exchange code with WeChat Work")
|
|
|
|
wecom_userid = userinfo.get("UserId") or userinfo.get("userid")
|
|
if not wecom_userid:
|
|
raise HTTPException(status_code=400, detail="Could not get userid from WeChat Work")
|
|
|
|
# Find bound user (case-insensitive — WeChat Work userids may vary in case)
|
|
from sqlalchemy import func as sa_func
|
|
result = await db.execute(
|
|
select(User).where(sa_func.lower(User.wecom_userid) == wecom_userid.lower())
|
|
)
|
|
user = result.scalar_one_or_none()
|
|
if not user:
|
|
# Not bound — generate a one-time bind token and redirect to bind page
|
|
bind_token = await store_bind_token(db, wecom_userid)
|
|
bind_url = f"https://qj.dhdx.fun/wecom-bind?token={bind_token}&wecom_userid={wecom_userid}"
|
|
from fastapi.responses import RedirectResponse
|
|
return RedirectResponse(url=bind_url)
|
|
|
|
# Issue JWT and auto-login via HTML page — saves credentials to localStorage, then redirects
|
|
token = build_token_for_user(user)
|
|
import json as _json
|
|
auth_data = _json.dumps({
|
|
"token": token,
|
|
"userId": str(user.id),
|
|
"userName": user.name,
|
|
"userRole": user.role,
|
|
"theme": user.theme or "editorial",
|
|
}, ensure_ascii=False)
|
|
frontend_url = f"https://qj.dhdx.fun{redirect}"
|
|
html = f"""<!DOCTYPE html><html><head><meta charset="utf-8"><title>登录中...</title></head>
|
|
<body><script>
|
|
var d = {auth_data};
|
|
localStorage.setItem('token', d.token);
|
|
localStorage.setItem('userId', d.userId);
|
|
localStorage.setItem('userName', d.userName);
|
|
localStorage.setItem('userRole', d.userRole);
|
|
localStorage.setItem('theme', d.theme);
|
|
window.location.replace({_json.dumps(frontend_url)});
|
|
</script></body></html>"""
|
|
return HTMLResponse(content=html, status_code=200)
|