diff --git a/backend/app/api/users.py b/backend/app/api/users.py index 346c97d..96fde87 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -80,3 +80,42 @@ async def update_user_role( "role": user.role, "department": user.department, } + + +class UpdateWecomRequest(BaseModel): + wecom_userid: Optional[str] = None # None or "" to unbind + + +@router.put("/{user_id}/wecom") +async def update_user_wecom( + user_id: str, + data: UpdateWecomRequest, + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + """Director binds or unbinds a user's WeChat Work account.""" + result = await db.execute(select(User).where(User.id == uuid.UUID(user_id))) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + new_id = (data.wecom_userid or "").strip() + + # Check duplicate + if new_id: + dup = await db.execute( + select(User).where(User.wecom_userid == new_id, User.id != uuid.UUID(user_id)) + ) + existing = dup.scalar_one_or_none() + if existing: + raise HTTPException(status_code=400, detail=f"该企微ID已被「{existing.name}」绑定") + + user.wecom_userid = new_id if new_id else None + await db.commit() + await db.refresh(user) + + return { + "id": str(user.id), + "name": user.name, + "wecom_userid": user.wecom_userid, + } diff --git a/backend/app/api/wecom.py b/backend/app/api/wecom.py index 5a75f70..647e97f 100644 --- a/backend/app/api/wecom.py +++ b/backend/app/api/wecom.py @@ -2,6 +2,7 @@ import hashlib import struct import base64 import uuid +import xml.etree.ElementTree as ET from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import PlainTextResponse @@ -12,7 +13,7 @@ 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.wecom import wecom_client, store_bind_token, consume_bind_token from app.services.scheduler import check_daily_reporting router = APIRouter(prefix="/wecom", tags=["WeChatWork"]) @@ -27,36 +28,33 @@ class AnnouncementRequest(BaseModel): content: str -# ── Callback verification helpers ── +class BindConfirmRequest(BaseModel): + token: str + + +# ── Crypto 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 + items = sorted([token, timestamp, nonce, encrypted]) + raw = "".join(items) + return hashlib.sha1(raw.encode()).hexdigest() == signature -def _decrypt_echostr(encrypted: str) -> str: - """Decrypt WeChat Work callback echostr. Returns plaintext.""" +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) - - # 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 + return plaintext[20:20 + msg_len].decode("utf-8") +# ── Callback ── + @router.get("/callback") async def wecom_callback_verify( msg_signature: str = Query(...), @@ -68,13 +66,10 @@ async def wecom_callback_verify( 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") - + raise HTTPException(status_code=403, detail="Signature mismatch") try: - plaintext = _decrypt_echostr(echostr) - # Must return raw plaintext (not JSON) — WeChat Work requires exact match + 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}") @@ -82,13 +77,110 @@ async def wecom_callback_verify( @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" + """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() + + # Triggers for binding + if (msg_type == "text" and content == "绑定") or (msg_type == "event" and event == "click" and event_key == "BIND_ACCOUNT"): + _handle_bind_request(from_user) + return PlainTextResponse(content="success") + + return PlainTextResponse(content="success") -# ── Manual actions ── +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 + + import asyncio + + bind_token = store_bind_token(wecom_userid) + + content = ( + f"【账号绑定】\n\n" + f"请在10分钟内点击以下链接完成账号绑定:\n" + f"https://qj.dhdx.fun/wecom-bind?token={bind_token}\n\n" + f"绑定后可使用企微一键登录,并接收填报提醒通知。" + ) + + try: + # Must run async in sync context + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + loop.run_until_complete( + 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 = consume_bind_token(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 + await db.commit() + + return {"code": 200, "msg": "绑定成功", "wecom_userid": wecom_userid} + + +# ── Manual actions (director only) ── @router.post("/remind") async def send_reminder( @@ -139,13 +231,13 @@ async def test_message( @router.post("/setup-menu") async def setup_menu(current_user: dict = Depends(require_director)): - """Create the standard app menu (开始填报 + 绑定账号).""" + """Create the standard app menu (开始填报 view + 绑定账号 click).""" buttons = [ {"type": "view", "name": "开始填报", "url": "https://qj.dhdx.fun/m"}, - {"type": "view", "name": "绑定账号", "url": "https://qj.dhdx.fun/bind-wechat"}, + {"type": "click", "name": "绑定账号", "key": "BIND_ACCOUNT"}, ] success = await wecom_client.create_menu(buttons) - return {"success": success, "message": "菜单已部署" if success else "菜单创建失败"} + return {"success": success, "message": "菜单已部署 (view + click)" if success else "菜单创建失败"} @router.get("/menu") diff --git a/backend/app/services/wecom.py b/backend/app/services/wecom.py index 41d9fd5..f0c7df6 100644 --- a/backend/app/services/wecom.py +++ b/backend/app/services/wecom.py @@ -1,7 +1,31 @@ import time +import uuid import httpx from app.config import settings +# In-memory bind token store (TTL 600s). Replace with Redis if scaling to multiple workers. +_bind_tokens: dict[str, tuple[str, float]] = {} # token → (wecom_userid, expires_at) + + +def store_bind_token(wecom_userid: str, ttl: int = 600) -> str: + """Store a bind token → wecom_userid mapping. Returns the token.""" + token = uuid.uuid4().hex + _bind_tokens[token] = (wecom_userid, time.time() + ttl) + # Cleanup expired tokens + now = time.time() + for k in list(_bind_tokens): + if _bind_tokens[k][1] < now: + del _bind_tokens[k] + return token + + +def consume_bind_token(token: str) -> str | None: + """Lookup and consume a bind token. Returns wecom_userid or None.""" + entry = _bind_tokens.pop(token, None) + if entry and entry[1] > time.time(): + return entry[0] + return None + class WecomClient: """WeChat Work API client — token management, message sending, OAuth.""" @@ -13,9 +37,12 @@ class WecomClient: self._access_token: str | None = None self._token_expires_at: float = 0 # epoch seconds + @property + def access_token(self) -> str | None: + return self._access_token + async def _get_token(self) -> str: """Get a valid access token, refreshing if expired.""" - # Token valid for 7200s; refresh 60s early to be safe if self._access_token and time.time() < self._token_expires_at - 60: return self._access_token @@ -45,17 +72,14 @@ class WecomClient: if errcode == 0: return data - # Token expired mid-request — clear and retry once if errcode == 42001: self._access_token = None self._token_expires_at = 0 if attempt < max_retries - 1: continue - # Rate limit — wait and retry if errcode == 45009: if attempt < max_retries - 1: - await httpx.AsyncClient().aclose() time.sleep(1 * (attempt + 1)) continue @@ -67,6 +91,8 @@ class WecomClient: raise Exception(f"WeCom API error after {max_retries} retries: {last_error}") + # ── OAuth ── + async def get_userinfo_by_code(self, code: str) -> dict | None: """Exchange OAuth2 code for userid (used in silent login).""" try: @@ -81,6 +107,8 @@ class WecomClient: except Exception: return None + # ── Messaging ── + async def send_text_message(self, user_ids: list[str], content: str) -> bool: """Send a text app message to specified users.""" if not settings.WECOM_AGENT_ID: @@ -117,13 +145,30 @@ class WecomClient: except Exception: return False + async def send_text_card(self, user_id: str, title: str, description: str, url: str) -> bool: + """Send a textcard message (clickable card) to a single user.""" + if not settings.WECOM_AGENT_ID: + return False + try: + body = { + "touser": user_id, + "msgtype": "textcard", + "agentid": int(settings.WECOM_AGENT_ID), + "textcard": { + "title": title, + "description": description, + "url": url, + }, + } + await self._post_with_retry( + f"{settings.WECOM_API_BASE}/cgi-bin/message/send", body + ) + return True + except Exception: + return False + async def send_template_card( - self, - user_ids: list[str], - title: str, - desc: str, - url: str, - btn_text: str = "查看详情", + self, user_ids: list[str], title: str, desc: str, url: str, btn_text: str = "查看详情" ) -> bool: """Send a text_notice template card with a deep-link button.""" if not settings.WECOM_AGENT_ID: @@ -136,17 +181,8 @@ class WecomClient: "template_card": { "card_type": "text_notice", "main_title": {"title": title, "desc": desc}, - "card_action": { - "type": 1, # jump to URL - "url": url, - }, - "button_list": [ - { - "text": btn_text, - "style": 1, # primary - "key": "open_url", - } - ], + "card_action": {"type": 1, "url": url}, + "button_list": [{"text": btn_text, "style": 1, "key": "open_url"}], }, } await self._post_with_retry( @@ -156,18 +192,16 @@ class WecomClient: except Exception: return False - # ── Menu management ── async def create_menu(self, buttons: list[dict]) -> bool: - """Create/replace the app's custom menu (visible in chat window).""" + """Create/replace the app's custom menu.""" if not settings.WECOM_AGENT_ID: return False try: body = {"button": buttons} await self._post_with_retry( - f"{settings.WECOM_API_BASE}/cgi-bin/menu/create?agentid={settings.WECOM_AGENT_ID}", - body, + f"{settings.WECOM_API_BASE}/cgi-bin/menu/create?agentid={settings.WECOM_AGENT_ID}", body ) return True except Exception: diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 87eec57..2bdefa6 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -22,6 +22,12 @@ const router = createRouter({ component: () => import('@/views/BindWechat.vue'), meta: { public: true }, }, + { + path: '/wecom-bind', + name: 'WecomBind', + component: () => import('@/views/WecomBind.vue'), + meta: { public: true }, + }, // Mobile routes (manager-facing) { path: '/m', diff --git a/frontend/src/views/WecomBind.vue b/frontend/src/views/WecomBind.vue new file mode 100644 index 0000000..02d8ed5 --- /dev/null +++ b/frontend/src/views/WecomBind.vue @@ -0,0 +1,104 @@ + + + + + + {{ errorMsg }} + + + 💬 + 企业微信账号绑定 + 绑定后可接收填报提醒,支持企微一键登录 + + + + 当前账号 + {{ auth.user?.name || '--' }} + + + 绑定状态 + + {{ auth.user?.wecom_userid ? '已绑定' : '未绑定' }} + + + + 企微ID + {{ auth.user.wecom_userid }} + + + + + 确认绑定 + 已绑定 ✓ + + + 链接有效期 10 分钟,过期请重新在企微发送「绑定」 + + + + + diff --git a/frontend/src/views/desktop/UserManage.vue b/frontend/src/views/desktop/UserManage.vue index 5a650e7..089e00c 100644 --- a/frontend/src/views/desktop/UserManage.vue +++ b/frontend/src/views/desktop/UserManage.vue @@ -9,6 +9,7 @@ const editDialogVisible = ref(false) const editUser = ref(null) const editRole = ref('') const editDepartment = ref('') +const editWecomId = ref('') const roleOptions = [ { label: '客户经理', value: 'manager' }, @@ -38,6 +39,7 @@ function openEdit(user: any) { editUser.value = user editRole.value = user.role editDepartment.value = user.department || '' + editWecomId.value = user.wecom_userid || '' editDialogVisible.value = true } @@ -45,11 +47,25 @@ async function handleSave() { if (!editUser.value) return try { await api.put(`/users/${editUser.value.id}/role`, { role: editRole.value, department: editDepartment.value }) - ElMessage.success('角色已更新') + const newWecomId = editWecomId.value.trim() + if (newWecomId !== (editUser.value.wecom_userid || '')) { + await api.put(`/users/${editUser.value.id}/wecom`, { wecom_userid: newWecomId || null }) + } + ElMessage.success('已更新') editDialogVisible.value = false await loadUsers() } catch (e: any) { ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message)) } } + +async function handleUnbind(row: any) { + if (!row.wecom_userid) return + try { + await ElMessageBox.confirm(`确定解除「${row.name}」的企微绑定?`, '确认解绑', { type: 'warning' }) + await api.put(`/users/${row.id}/wecom`, { wecom_userid: null }) + ElMessage.success('已解绑') + await loadUsers() + } catch (e: any) { if (e !== 'cancel') ElMessage.error('操作失败') } +} @@ -67,18 +83,24 @@ async function handleSave() { {{ roleLabel[row.role] || row.role }} - - {{ row.wecom_userid || '-' }} + + + + {{ row.wecom_userid }} + 解绑 + + 未绑定 + - 修改角色 + 编辑 - + @@ -88,6 +110,10 @@ async function handleSave() { + + + 在企微后台 → 通讯录 → 成员详情 → 账号 中查看 + @@ -107,4 +133,6 @@ async function handleSave() { color: var(--ink); letter-spacing: 0.06em; } .page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; } +.wecom-id { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--sage); background: var(--c-bg-light, #f0ede5); padding: 2px 8px; border-radius: 4px; } +.form-hint { font-size: 12px; color: var(--c-text-muted); margin-top: 4px; }
{{ errorMsg }}
绑定后可接收填报提醒,支持企微一键登录
链接有效期 10 分钟,过期请重新在企微发送「绑定」