diff --git a/backend/app/api/wecom.py b/backend/app/api/wecom.py index 59b52ab..b207aab 100644 --- a/backend/app/api/wecom.py +++ b/backend/app/api/wecom.py @@ -7,7 +7,7 @@ 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 +from fastapi.responses import PlainTextResponse, HTMLResponse from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -248,12 +248,13 @@ async def test_message( @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": "https://qj.dhdx.fun/m"}, + {"type": "view", "name": "开始填报", "url": oauth_url}, {"type": "click", "name": "绑定账号", "key": "BIND_ACCOUNT"}, ] success = await wecom_client.create_menu(buttons) - return {"success": success, "message": "菜单已部署 (view + click)" if success else "菜单创建失败"} + return {"success": success, "message": "菜单已部署 (含静默登录)" if success else "菜单创建失败"} @router.get("/menu") @@ -271,3 +272,90 @@ async def trigger_daily_check( """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 — redirect to bind page + bind_url = f"https://qj.dhdx.fun/wecom-bind?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"""登录中... +""" + return HTMLResponse(content=html, status_code=200) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index aa7190a..b86f048 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -80,6 +80,28 @@ const router = createRouter({ router.beforeEach((to, _from, next) => { const auth = useAuthStore() + + // Handle WeChat Work OAuth silent login callback: ?token=JWT + const tokenParam = to.query.token as string + if (tokenParam) { + // Save token to auth store (the JWT contains user_id, name, role, theme) + try { + const payload = JSON.parse(atob(tokenParam.split('.')[1])) + auth.saveLogin({ + access_token: tokenParam, + user_id: payload.user_id, + name: payload.name, + role: payload.role, + theme: payload.theme, + }) + } catch (_) { /* invalid token, ignore */ } + // Remove token from URL + const cleanQuery = { ...to.query } + delete cleanQuery.token + next({ path: to.path, query: cleanQuery, replace: true }) + return + } + if (to.meta.public) { next() return diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index c3c6784..0f2ba6c 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -9,6 +9,11 @@ const route = useRoute() const auth = useAuthStore() const loading = ref(false) +function isWecom(): boolean { + const ua = navigator.userAgent || '' + return /wxwork/i.test(ua) +} + function isMobileDevice(): boolean { const ua = navigator.userAgent || '' return /Android|iPhone|iPad|iPod|webOS/i.test(ua) || window.innerWidth < 768 @@ -80,6 +85,16 @@ onMounted(async () => { return } + // ── WeChat Work silent login: redirect to OAuth (no user interaction needed) ── + if (isWecom()) { + const targetPath = (route.query.redirect as string) || '/m' + const endpoint = import.meta.env.VITE_CASDOOR_ENDPOINT || '' + // In production, the backend builds the OAuth URL; use the API + const apiBase = window.location.origin + window.location.href = `${apiBase}/api/wecom/oauth-url?redirect=${encodeURIComponent(targetPath)}` + return + } + // ── Auto-redirect to Casdoor (no button needed) ── loading.value = true goCasdoorLogin()